From 4c35a7b4a325ad1f7084413c25e4c730eb42ba43 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Tue, 30 Apr 2024 17:57:48 +0100 Subject: [PATCH 01/29] feat: add Enumerable class --- src/common/__tests__/enumerable.test.ts | 430 ++++++++++++++++++++++++ src/common/enumerable.ts | 211 ++++++++++++ src/plugin/index.ts | 1 + src/ui/index.ts | 1 + 4 files changed, 643 insertions(+) create mode 100644 src/common/__tests__/enumerable.test.ts create mode 100644 src/common/enumerable.ts diff --git a/src/common/__tests__/enumerable.test.ts b/src/common/__tests__/enumerable.test.ts new file mode 100644 index 00000000..b9e71398 --- /dev/null +++ b/src/common/__tests__/enumerable.test.ts @@ -0,0 +1,430 @@ +import { Enumerable } from "../enumerable"; + +describe("Enumerable", () => { + const source = [ + { name: "Facecam" }, + { name: "Stream Deck" }, + { name: "Wave DX" } // + ]; + + const enumerable = Enumerable.from(source); + + /** + * Provides assertions for {@link Enumerable.from}. + */ + describe("from", () => { + /** + * With T[]. + */ + describe("T[]", () => { + it("iterates mutated array", () => { + // Arrange. + const fn = jest.fn(); + const arr = [1, 2]; + const enumerable = Enumerable.from(arr); + + // Act. + arr.push(3, 4); + enumerable.forEach(fn); + + // Assert. + expect(enumerable.length).toBe(4); + expect(fn).toHaveBeenCalledTimes(4); + expect(fn).toHaveBeenNthCalledWith(1, 1); + expect(fn).toHaveBeenNthCalledWith(2, 2); + expect(fn).toHaveBeenNthCalledWith(3, 3); + expect(fn).toHaveBeenNthCalledWith(4, 4); + }); + + it("reads length", () => { + // Arrange. + const arr = [1]; + + // Act, assert. + const enumerable = Enumerable.from(arr); + expect(enumerable.length).toBe(1); + + // Act, assert. + arr.push(2); + expect(enumerable.length).toBe(2); + }); + }); + + /** + * With Map. + */ + describe("Map", () => { + it("iterates mutated map", () => { + // Arrange (1). + const fnBefore = jest.fn(); + const map = new Map([ + [1, "One"], + [2, "Two"] + ]); + const enumerable = Enumerable.from(map); + + // Act (1), assert (1). + enumerable.forEach(fnBefore); + expect(enumerable.length).toBe(2); + expect(fnBefore).toHaveBeenCalledTimes(2); + expect(fnBefore).toHaveBeenNthCalledWith(1, "One"); + expect(fnBefore).toHaveBeenNthCalledWith(2, "Two"); + + // Arrange (2) + const fnAfter = jest.fn(); + map.set(1, "A"); + map.set(3, "Three"); + + // Act, assert (2). + enumerable.forEach(fnAfter); + expect(enumerable.length).toBe(3); + expect(fnAfter).toHaveBeenCalledTimes(3); + expect(fnAfter).toHaveBeenNthCalledWith(1, "A"); + expect(fnAfter).toHaveBeenNthCalledWith(2, "Two"); + expect(fnAfter).toHaveBeenNthCalledWith(3, "Three"); + }); + + it("reads length", () => { + // Arrange (1). + const map = new Map([ + [1, "One"], + [2, "Two"] + ]); + + // Act (1). + const enumerable = Enumerable.from(map); + + // Assert (1). + expect(enumerable.length).toBe(2); + + // Act (2) + map.delete(1); + map.set(3, "Three"); + map.set(4, "Four"); + + // Assert (2). + expect(enumerable.length).toBe(3); + }); + }); + + /** + * With Set. + */ + describe("Set", () => { + it("iterates mutated map", () => { + // Arrange (1). + const fnBefore = jest.fn(); + const set = new Set(["One", "Two"]); + const enumerable = Enumerable.from(set); + + // Act (1), assert (1). + enumerable.forEach(fnBefore); + expect(enumerable.length).toBe(2); + expect(fnBefore).toHaveBeenCalledTimes(2); + expect(fnBefore).toHaveBeenNthCalledWith(1, "One"); + expect(fnBefore).toHaveBeenNthCalledWith(2, "Two"); + + // Arrange (2) + const fnAfter = jest.fn(); + set.delete("One"); + set.add("Three"); + + // Act, assert (2). + enumerable.forEach(fnAfter); + expect(enumerable.length).toBe(2); + expect(fnAfter).toHaveBeenCalledTimes(2); + expect(fnAfter).toHaveBeenNthCalledWith(1, "Two"); + expect(fnAfter).toHaveBeenNthCalledWith(2, "Three"); + }); + + it("reads length", () => { + // Arrange (1). + const set = new Set(["One", "Two"]); + + // Act (1). + const enumerable = Enumerable.from(set); + + // Assert (1). + expect(enumerable.length).toBe(2); + + // Act (2) + set.delete("One"); + set.add("Three"); + set.add("Four"); + + // Assert (2). + expect(enumerable.length).toBe(3); + }); + }); + }); + + /** + * Provides assertions for {@link Enumerable.every}. + */ + describe("every", () => { + it("evaluates all when needed", () => { + // Arrange, act, assert + expect(enumerable.every((x) => typeof x.name === "string")).toBeTruthy(); + }); + + it("evaluates lazily", () => { + // Arrange, act. + const fn = jest.fn().mockReturnValue(false); + const every = enumerable.every(fn); + + // Assert. + expect(every).toBeFalsy(); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith({ + name: "Facecam" + }); + }); + }); + + /** + * Provides assertions for {@link Enumerable.filter}. + */ + describe("filter", () => { + it("filters items", () => { + // Arrange, act. + const fn = jest.fn().mockImplementation((x) => x.name !== "Stream Deck"); + const filtered = Array.from(enumerable.filter(fn)); + + // Assert. + expect(fn).toHaveBeenCalledTimes(3); + expect(fn).toHaveBeenNthCalledWith(1, { name: "Facecam" }); + expect(fn).toHaveBeenNthCalledWith(2, { name: "Stream Deck" }); + expect(fn).toHaveBeenNthCalledWith(3, { name: "Wave DX" }); + + expect(filtered).toHaveLength(2); + expect(filtered.at(0)).toEqual({ name: "Facecam" }); + expect(filtered.at(1)).toEqual({ name: "Wave DX" }); + }); + + it("can return no items", () => { + // Arrange, act, assert. + const filtered = Array.from(enumerable.filter((x) => x.name === "Test")); + expect(filtered).toHaveLength(0); + }); + + it("dot not evaluate unless iterated", () => { + // Arrange, act. + const fn = jest.fn(); + enumerable.filter(fn); + + // Assert. + expect(fn).toHaveBeenCalledTimes(0); + }); + }); + + /** + * Provides assertions for {@link Enumerable.find}. + */ + describe("find", () => { + it("finds the first", () => { + // Arrange, act. + const fn = jest.fn().mockImplementation((x) => x.name === "Facecam"); + const item = enumerable.find(fn); + + // Assert. + expect(item).toEqual({ name: "Facecam" }); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith({ name: "Facecam" }); + }); + + it("finds the last", () => { + // Arrange, act. + const fn = jest.fn().mockImplementation((x) => x.name === "Wave DX"); + const item = enumerable.find(fn); + + // Assert. + expect(item).toEqual({ name: "Wave DX" }); + expect(fn).toHaveBeenCalledTimes(3); + expect(fn).toHaveBeenCalledWith({ name: "Facecam" }); + expect(fn).toHaveBeenCalledWith({ name: "Stream Deck" }); + expect(fn).toHaveBeenCalledWith({ name: "Wave DX" }); + }); + + it("can find nothing", () => { + // Arrange, act. + const fn = jest.fn().mockImplementation((x) => x.name === "Top secret product"); + const item = enumerable.find(fn); + + // Assert. + expect(item).toBeUndefined(); + expect(fn).toHaveBeenCalledTimes(3); + expect(fn).toHaveBeenCalledWith({ name: "Facecam" }); + expect(fn).toHaveBeenCalledWith({ name: "Stream Deck" }); + expect(fn).toHaveBeenCalledWith({ name: "Wave DX" }); + }); + }); + + /** + * Provides assertions for {@link Enumerable.findLast}. + */ + describe("findLast", () => { + it("finds the first", () => { + // Arrange, act. + const fn = jest.fn().mockImplementation((x) => !x.name.match(/\s+/)); + const item = enumerable.findLast(fn); + + // Assert. + expect(item).toEqual({ name: "Facecam" }); + expect(fn).toHaveBeenCalledTimes(3); + expect(fn).toHaveBeenCalledWith({ name: "Facecam" }); + expect(fn).toHaveBeenCalledWith({ name: "Stream Deck" }); + expect(fn).toHaveBeenCalledWith({ name: "Wave DX" }); + }); + + it("finds the last", () => { + // Arrange, act. + const fn = jest.fn().mockImplementation((x) => x.name.match(/\s+/)); + const item = enumerable.findLast(fn); + + // Assert. + expect(item).toEqual({ name: "Wave DX" }); + expect(fn).toHaveBeenCalledTimes(3); + expect(fn).toHaveBeenCalledWith({ name: "Facecam" }); + expect(fn).toHaveBeenCalledWith({ name: "Stream Deck" }); + expect(fn).toHaveBeenCalledWith({ name: "Wave DX" }); + }); + + it("can find nothing", () => { + // Arrange, act. + const fn = jest.fn().mockImplementation((x) => x.name === "Top secret product"); + const item = enumerable.findLast(fn); + + // Assert. + expect(item).toBeUndefined(); + expect(fn).toHaveBeenCalledTimes(3); + expect(fn).toHaveBeenCalledWith({ name: "Facecam" }); + expect(fn).toHaveBeenCalledWith({ name: "Stream Deck" }); + expect(fn).toHaveBeenCalledWith({ name: "Wave DX" }); + }); + }); + + /** + * Provides assertions for {@link Enumerable.forEach}. + */ + describe("forEach", () => { + it("iterates over items", () => { + // Arrange, act. + const fn = jest.fn(); + enumerable.forEach(fn); + + // Assert. + expect(fn).toHaveBeenCalledTimes(3); + expect(fn).toHaveBeenCalledWith({ name: "Facecam" }); + expect(fn).toHaveBeenCalledWith({ name: "Stream Deck" }); + expect(fn).toHaveBeenCalledWith({ name: "Wave DX" }); + }); + }); + + /** + * Provides assertions for {@link Enumerable.includes}. + */ + describe("includes", () => { + it("matches reference", () => { + // Arrange, act, assert + expect(enumerable.includes(source[1])).toBeTruthy(); + expect(enumerable.includes({ name: "Stream Deck" })).toBeFalsy(); + expect(enumerable.includes(undefined!)).toBeFalsy(); + }); + }); + + /** + * Provides assertions for {@link Enumerable.map}. + */ + describe("map", () => { + it("maps each item", () => { + // Arrange, act. + const res = Array.from(enumerable.map(({ name }) => name)); + + // Assert. + expect(res).toHaveLength(3); + expect(res.at(0)).toBe("Facecam"); + expect(res.at(1)).toBe("Stream Deck"); + expect(res.at(2)).toBe("Wave DX"); + }); + + it("returns an empty array", () => { + // Arrange, act. + const empty = Enumerable.from([]); + const res = Array.from(empty.map((x) => x.toString())); + + // Assert. + expect(res).toHaveLength(0); + }); + + it("dot not evaluate unless iterated", () => { + // Arrange, act. + const fn = jest.fn(); + enumerable.map(fn); + + // Assert. + expect(fn).toHaveBeenCalledTimes(0); + }); + }); + + /** + * Provides assertions for {@link Enumerable.reduce}. + */ + describe("reduce", () => { + describe("without initial value", () => { + it("reduces all", () => { + // Arrange, act, assert. + const res = enumerable.reduce((prev, curr) => ({ name: `${prev.name}, ${curr.name}` })); + expect(res).toEqual({ name: "Facecam, Stream Deck, Wave DX" }); + }); + + it("throws when empty", () => { + // Arrange, act, assert. + const empty = Enumerable.from([]); + expect(() => empty.reduce((prev, curr) => curr)).toThrowError(new TypeError("Reduce of empty enumerable with no initial value.")); + }); + }); + + describe("with initial value", () => { + it("reduces all", () => { + // Arrange, act, assert. + const res = enumerable.reduce((prev, curr) => `${prev}, ${curr.name}`, "Initial"); + expect(res).toEqual("Initial, Facecam, Stream Deck, Wave DX"); + }); + + it("reduces empty", () => { + // Arrange, act, assert. + const empty = Enumerable.from([]); + expect(empty.reduce((prev, curr) => `${prev}, ${curr}`, "Initial")).toBe("Initial"); + }); + }); + }); + + /** + * Provides assertions for {@link Enumerable.some}. + */ + describe("some", () => { + it("evaluates lazily", () => { + // Arrange, act, assert. + const fn = jest.fn().mockReturnValue(true); + const some = enumerable.some(fn); + + // Assert. + expect(some).toBeTruthy(); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith({ name: "Facecam" }); + }); + + it("evaluates all when needed", () => { + // Arrange, act, assert. + const fn = jest.fn().mockReturnValue(false); + const some = enumerable.some(fn); + + // Assert. + expect(some).toBeFalsy(); + expect(fn).toHaveBeenCalledTimes(3); + expect(fn).toHaveBeenCalledWith({ name: "Facecam" }); + expect(fn).toHaveBeenCalledWith({ name: "Stream Deck" }); + expect(fn).toHaveBeenCalledWith({ name: "Wave DX" }); + }); + }); +}); diff --git a/src/common/enumerable.ts b/src/common/enumerable.ts new file mode 100644 index 00000000..5b075768 --- /dev/null +++ b/src/common/enumerable.ts @@ -0,0 +1,211 @@ +/** + * Provides a read-only iterable collection of items. + */ +export class Enumerable { + /** + * Backing function responsible for providing the iterator of items. + */ + readonly #items: () => Iterable; + + /** + * Backing function for {@link Enumerable.length}. + */ + readonly #length: () => number; + + /** + * Initializes a new instance of the {@link Enumerable} class. + * @param items Underlying iterator responsible for providing the items. + * @param length Function to get the number of items. + */ + private constructor(items: () => Iterable, length: () => number) { + this.#items = items; + this.#length = length; + } + + /** + * Gets the number of items in the enumerable. + * @returns The number of items. + */ + public get length(): number { + return this.#length(); + } + + /** + * Creates a new enumerable from the specified array. + * @param source Source array. + * @returns The enumerable. + */ + public static from(source: T[]): Enumerable; + /** + * Creates a new enumerable from the specified map. + * @param source Source map. + * @returns The enumerable. + */ + public static from(source: Map): Enumerable; + /** + * Creates a new enumerable from the specified set. + * @param source Source set. + * @returns The enumerable. + */ + public static from(source: Set): Enumerable; + /** + * Creates a new enumerable from the specified items. + * @param source Source that contains the items. + * @returns The enumerable. + */ + public static from(source: Map | Set | T[]): Enumerable { + if (Array.isArray(source)) { + return new Enumerable( + () => source, + () => source.length + ); + } + + return new Enumerable( + () => source.values(), + () => source.size + ); + } + + /** + * 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()) { + if (!predicate(item)) { + return false; + } + } + + return true; + } + + /** + * Returns an iterable of items that meet the specified condition. + * @param predicate Function that determines which items to filter. + * @yields The filtered items; items that returned `true` when invoked against the predicate. + */ + public *filter(predicate: (value: T) => boolean): IterableIterator { + for (const item of this.#items()) { + if (predicate(item)) { + yield item; + } + } + } + + /** + * Finds the first item that satisfies the specified predicate. + * @param predicate Predicate to match items against. + * @returns The first item that satisfied the predicate; otherwise `undefined`. + */ + public find(predicate: (value: T) => boolean): T | undefined { + for (const item of this.#items()) { + if (predicate(item)) { + return item; + } + } + } + + /** + * Finds the last item that satisfies the specified predicate. + * @param predicate Predicate to match items against. + * @returns The first item that satisfied the predicate; otherwise `undefined`. + */ + public findLast(predicate: (value: T) => boolean): T | undefined { + let result = undefined; + for (const item of this.#items()) { + if (predicate(item)) { + result = item; + } + } + + return result; + } + + /** + * 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()) { + fn(item); + } + } + + /** + * Determines whether the search item exists in the collection exists. + * @param search Item to search for. + * @returns `true` when the item was found; otherwise `false`. + */ + public includes(search: T): boolean { + return this.some((item) => item === search); + } + + /** + * Maps each item within the collection to a new structure using the specified mapping function. + * @param mapper Function responsible for mapping the items. + * @yields The mapped items. + */ + public *map(mapper: (value: T) => U): Iterable { + for (const item of this.#items()) { + yield mapper(item); + } + } + + /** + * Applies the accumulator function to each item, and returns the result. + * @param accumulator Function responsible for accumulating all items within the collection. + * @returns Result of accumulating each value. + */ + public reduce(accumulator: (previous: T, current: T) => T): T; + /** + * Applies the accumulator function to each item, and returns the result. + * @param accumulator Function responsible for accumulating all items within the collection. + * @param initial Initial value supplied to the accumulator. + * @returns Result of accumulating each value. + */ + public reduce(accumulator: (previous: R, current: T) => R, initial: R): R; + /** + * Applies the accumulator function to each item, and returns the result. + * @param accumulator Function responsible for accumulating all items within the collection. + * @param initial Initial value supplied to the accumulator. + * @returns Result of accumulating each value. + */ + public reduce(accumulator: (previous: R | T, current: T) => R | T, initial?: R | T): R | T { + if (this.length === 0) { + if (initial === undefined) { + throw new TypeError("Reduce of empty enumerable with no initial value."); + } + + return initial; + } + + let result = initial; + for (const item of this.#items()) { + if (result === undefined) { + result = item; + } else { + result = accumulator(result, item); + } + } + + return result!; + } + + /** + * 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()) { + if (predicate(item)) { + return true; + } + } + + return false; + } +} diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 827ccef5..1e318c75 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -31,6 +31,7 @@ export { type State, type Text } from "../api"; +export { Enumerable } from "../common/enumerable"; export { EventEmitter, EventsOf } from "../common/event-emitter"; export { type JsonObject, type JsonPrimitive, type JsonValue } from "../common/json"; export { LogLevel } from "../common/logging"; diff --git a/src/ui/index.ts b/src/ui/index.ts index 383591b4..f5388cfe 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -8,6 +8,7 @@ import * as settings from "./settings"; import * as system from "./system"; export { DeviceType, type ActionInfo, type ConnectElgatoStreamDeckSocketFn, type Controller, type RegistrationInfo } from "../api"; +export { Enumerable } from "../common/enumerable"; export { EventEmitter } from "../common/event-emitter"; export { type JsonObject, type JsonPrimitive, type JsonValue } from "../common/json"; export { LogLevel, type Logger } from "../common/logging"; From 9dd8a7c29f968694f387115f9814cfde9d11538b Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sat, 14 Sep 2024 18:57:28 +0100 Subject: [PATCH 02/29] refactor: split Action into KeyAction, DialAction, and KeyInMultiAction --- src/common/__tests__/enumerable.test.ts | 17 ++ src/common/enumerable.ts | 10 + src/plugin/actions/__tests__/action.test.ts | 250 ++++---------------- src/plugin/actions/__tests__/dial.test.ts | 200 ++++++++++++++++ src/plugin/actions/__tests__/key.test.ts | 160 +++++++++++++ src/plugin/actions/__tests__/multi.test.ts | 67 ++++++ src/plugin/actions/action.ts | 183 +------------- src/plugin/actions/dial.ts | 124 ++++++++++ src/plugin/actions/key.ts | 95 ++++++++ src/plugin/actions/multi.ts | 33 +++ src/plugin/index.ts | 5 +- 11 files changed, 765 insertions(+), 379 deletions(-) create mode 100644 src/plugin/actions/__tests__/dial.test.ts create mode 100644 src/plugin/actions/__tests__/key.test.ts create mode 100644 src/plugin/actions/__tests__/multi.test.ts create mode 100644 src/plugin/actions/dial.ts create mode 100644 src/plugin/actions/key.ts create mode 100644 src/plugin/actions/multi.ts diff --git a/src/common/__tests__/enumerable.test.ts b/src/common/__tests__/enumerable.test.ts index b9e71398..42bd08b3 100644 --- a/src/common/__tests__/enumerable.test.ts +++ b/src/common/__tests__/enumerable.test.ts @@ -158,6 +158,23 @@ describe("Enumerable", () => { }); }); + /** + * Asserts the iterator of an {@link Enumerable}. + */ + describe("iterator", () => { + // Arrange. + const source = ["a", "b", "c"]; + const enumerable = Enumerable.from(source); + + // Act, assert. + let i = 0; + for (const item of enumerable) { + expect(item).toBe(source[i++]); + } + + expect(i).toBe(3); + }); + /** * Provides assertions for {@link Enumerable.every}. */ diff --git a/src/common/enumerable.ts b/src/common/enumerable.ts index 5b075768..66a1f4e8 100644 --- a/src/common/enumerable.ts +++ b/src/common/enumerable.ts @@ -67,6 +67,16 @@ export class Enumerable { ); } + /** + * Gets the iterator for the enumerable. + * @returns The iterator. + */ + public *[Symbol.iterator](): IterableIterator { + for (const item of this.#items()) { + yield item; + } + } + /** * Determines whether all items satisfy the specified predicate. * @param predicate Function that determines whether each item fulfils the predicate. diff --git a/src/plugin/actions/__tests__/action.test.ts b/src/plugin/actions/__tests__/action.test.ts index 00cae0c6..365aefce 100644 --- a/src/plugin/actions/__tests__/action.test.ts +++ b/src/plugin/actions/__tests__/action.test.ts @@ -1,18 +1,4 @@ -import { - Target, - type ActionIdentifier, - type GetSettings, - type SendToPropertyInspector, - type SetFeedback, - type SetFeedbackLayout, - type SetImage, - type SetSettings, - type SetState, - type SetTitle, - type SetTriggerDescription, - type ShowAlert, - type ShowOk -} from "../../../api"; +import { type ActionIdentifier, type GetSettings, type SendToPropertyInspector, type SetSettings } from "../../../api"; import { Settings } from "../../../api/__mocks__/events"; import { connection } from "../../connection"; import { Action } from "../action"; @@ -107,6 +93,56 @@ describe("Action", () => { }); }); + // describe("type checking", () => { + // /** + // * Asserts {@link Action.isDial}. + // */ + // it("can be dial", () => { + // // Arrange. + // const action = new DialAction({ + // action: "com.elgato.test.one", + // context: "ABC123" + // }); + + // // Act, assert. + // expect(action.isDial()).toBe(true); + // expect(action.isKey()).toBe(false); + // expect(action.isKeyInMultiAction()).toBe(false); + // }); + + // /** + // * Asserts {@link Action.isKey}. + // */ + // it("can be key", () => { + // // Arrange. + // const action = new KeyAction({ + // action: "com.elgato.test.one", + // context: "ABC123" + // }); + + // // Act, assert. + // expect(action.isDial()).toBe(false); + // expect(action.isKey()).toBe(true); + // expect(action.isKeyInMultiAction()).toBe(false); + // }); + + // /** + // * Asserts {@link Action.isKeyInMultiAction}. + // */ + // it("can be key in multi-action", () => { + // // Arrange. + // const action = new KeyInMultiAction({ + // action: "com.elgato.test.one", + // context: "ABC123" + // }); + + // // Act, assert. + // expect(action.isDial()).toBe(false); + // expect(action.isKey()).toBe(false); + // expect(action.isKeyInMultiAction()).toBe(true); + // }); + // }); + describe("sending", () => { const action = new Action({ action: "com.elgato.test.one", @@ -133,80 +169,6 @@ describe("Action", () => { }); }); - /** - * Asserts {@link Action.setFeedback} forwards the command to the {@link connection}. - */ - it("setFeedback", async () => { - // Arrange, act. - await action.setFeedback({ - bar: 50, - title: "Hello world" - }); - - // Assert. - expect(connection.send).toHaveBeenCalledTimes(1); - expect(connection.send).toHaveBeenCalledWith<[SetFeedback]>({ - context: action.id, - event: "setFeedback", - payload: { - bar: 50, - title: "Hello world" - } - }); - }); - - /** - * Asserts {@link Action.setFeedbackLayout} forwards the command to the {@link connection}. - */ - it("Sends setFeedbackLayout", async () => { - // Arrange, act. - await action.setFeedbackLayout("CustomLayout.json"); - - // Assert. - expect(connection.send).toHaveBeenCalledTimes(1); - expect(connection.send).toHaveBeenCalledWith<[SetFeedbackLayout]>({ - context: action.id, - event: "setFeedbackLayout", - payload: { - layout: "CustomLayout.json" - } - }); - }); - - /** - * Asserts {@link Action.setImage} forwards the command to the {@link connection}. - */ - it("setImage", async () => { - // Arrange, act - await action.setImage(); - await action.setImage("./imgs/test.png", { - state: 1, - target: Target.Hardware - }); - - // Assert. - expect(connection.send).toHaveBeenCalledTimes(2); - expect(connection.send).toHaveBeenNthCalledWith<[SetImage]>(1, { - context: action.id, - event: "setImage", - payload: { - image: undefined, - state: undefined, - target: undefined - } - }); - - expect(connection.send).toHaveBeenNthCalledWith<[SetImage]>(2, { - context: action.id, - event: "setImage", - payload: { - image: "./imgs/test.png", - state: 1, - target: Target.Hardware - } - }); - }); - /** * Asserts {@link Action.setSettings} forwards the command to the {@link connection}. */ @@ -226,115 +188,5 @@ describe("Action", () => { } }); }); - - /** - * Asserts {@link Action.setState} forwards the command to the {@link connection}. - */ - it("setState", async () => { - // Arrange, act. - await action.setState(1); - - // Assert. - expect(connection.send).toHaveBeenCalledTimes(1); - expect(connection.send).toHaveBeenCalledWith<[SetState]>({ - context: action.id, - event: "setState", - payload: { - state: 1 - } - }); - }); - - /** - * Asserts {@link Action.setTitle} forwards the command to the {@link connection}. - */ - it("setTitle", async () => { - // Arrange, act. - await action.setTitle("Hello world"); - await action.setTitle("This is a test", { state: 1, target: Target.Software }); - - // Assert. - expect(connection.send).toHaveBeenCalledTimes(2); - expect(connection.send).toHaveBeenNthCalledWith<[SetTitle]>(1, { - event: "setTitle", - context: "ABC123", - payload: { - title: "Hello world" - } - }); - - expect(connection.send).toHaveBeenNthCalledWith<[SetTitle]>(2, { - event: "setTitle", - context: "ABC123", - payload: { - state: 1, - target: Target.Software, - title: "This is a test" - } - }); - }); - - /** - * Asserts {@link Action.setTriggerDescription} forwards the command to the {@link connection}. - */ - it("setTriggerDescription", async () => { - // Arrange, act. - await action.setTriggerDescription(); - await action.setTriggerDescription({ - longTouch: "Long-touch", - push: "Push", - rotate: "Rotate", - touch: "Touch" - }); - - // Assert. - expect(connection.send).toHaveBeenCalledTimes(2); - expect(connection.send).toHaveBeenNthCalledWith<[SetTriggerDescription]>(1, { - event: "setTriggerDescription", - context: action.id, - payload: {} - }); - - expect(connection.send).toHaveBeenNthCalledWith<[SetTriggerDescription]>(2, { - event: "setTriggerDescription", - context: action.id, - payload: { - longTouch: "Long-touch", - push: "Push", - rotate: "Rotate", - touch: "Touch" - } - }); - }); - - /** - * Asserts {@link Action.showAlert} forwards the command to the {@link connection}. - */ - it("showAlert", async () => { - // Arrange, act. - await action.showAlert(); - - // Assert. - expect(connection.send).toHaveBeenCalledTimes(1); - expect(connection.send).toHaveBeenCalledWith<[ShowAlert]>({ - context: action.id, - event: "showAlert" - }); - }); - - /** - * Asserts {@link Action.showOk} forwards the command to the {@link connection}. - */ - it("showOk", async () => { - // Arrange, act - await action.showOk(); - - // Assert. - expect(connection.send).toHaveBeenCalledTimes(1); - expect(connection.send).toHaveBeenCalledWith<[ShowOk]>({ - context: action.id, - event: "showOk" - }); - }); }); }); diff --git a/src/plugin/actions/__tests__/dial.test.ts b/src/plugin/actions/__tests__/dial.test.ts new file mode 100644 index 00000000..fbcf7992 --- /dev/null +++ b/src/plugin/actions/__tests__/dial.test.ts @@ -0,0 +1,200 @@ +import { Target, type ActionIdentifier, type SetFeedback, type SetFeedbackLayout, type SetImage, type SetTitle, type SetTriggerDescription, type ShowAlert } from "../../../api"; +import { connection } from "../../connection"; +import { Action } from "../action"; +import { DialAction } from "../dial"; + +jest.mock("../../logging"); +jest.mock("../../manifest"); +jest.mock("../../connection"); + +describe("Action", () => { + /** + * Asserts the constructor of {@link Dial} sets the {@link DialAction.manifestId} and {@link DialAction.id}. + */ + it("constructor sets manifestId and id", () => { + // Arrange. + const source: ActionIdentifier = { + action: "com.elgato.test.one", + context: "ABC123" + }; + + // Act. + const dialAction = new DialAction(source); + + // Assert. + expect(dialAction.id).toBe("ABC123"); + expect(dialAction.manifestId).toBe("com.elgato.test.one"); + }); + + /** + * Asserts the inheritance of {@link KeyAction}. + */ + it("inherits shared methods", () => { + // Arrange, act. + const dialAction = new DialAction({ + action: "com.elgato.test.one", + context: "ABC123" + }); + + // Assert. + expect(dialAction).toBeInstanceOf(Action); + }); + + describe("sending", () => { + const dialAction = new DialAction({ + action: "com.elgato.test.one", + context: "ABC123" + }); + + /** + * Asserts {@link DialAction.setFeedback} forwards the command to the {@link connection}. + */ + it("setFeedback", async () => { + // Arrange, act. + await dialAction.setFeedback({ + bar: 50, + title: "Hello world" + }); + + // Assert. + expect(connection.send).toHaveBeenCalledTimes(1); + expect(connection.send).toHaveBeenCalledWith<[SetFeedback]>({ + context: dialAction.id, + event: "setFeedback", + payload: { + bar: 50, + title: "Hello world" + } + }); + }); + + /** + * Asserts {@link DialAction.setFeedbackLayout} forwards the command to the {@link connection}. + */ + it("Sends setFeedbackLayout", async () => { + // Arrange, act. + await dialAction.setFeedbackLayout("CustomLayout.json"); + + // Assert. + expect(connection.send).toHaveBeenCalledTimes(1); + expect(connection.send).toHaveBeenCalledWith<[SetFeedbackLayout]>({ + context: dialAction.id, + event: "setFeedbackLayout", + payload: { + layout: "CustomLayout.json" + } + }); + }); + + /** + * Asserts {@link DialAction.setImage} forwards the command to the {@link connection}. + */ + it("setImage", async () => { + // Arrange, act + await dialAction.setImage(); + await dialAction.setImage("./imgs/test.png", { + state: 1, + target: Target.Hardware + }); + + // Assert. + expect(connection.send).toHaveBeenCalledTimes(2); + expect(connection.send).toHaveBeenNthCalledWith<[SetImage]>(1, { + context: dialAction.id, + event: "setImage", + payload: { + image: undefined, + state: undefined, + target: undefined + } + }); + + expect(connection.send).toHaveBeenNthCalledWith<[SetImage]>(2, { + context: dialAction.id, + event: "setImage", + payload: { + image: "./imgs/test.png", + state: 1, + target: Target.Hardware + } + }); + }); + + /** + * Asserts {@link DialAction.setTitle} forwards the command to the {@link connection}. + */ + it("setTitle", async () => { + // Arrange, act. + await dialAction.setTitle("Hello world"); + await dialAction.setTitle("This is a test", { state: 1, target: Target.Software }); + + // Assert. + expect(connection.send).toHaveBeenCalledTimes(2); + expect(connection.send).toHaveBeenNthCalledWith<[SetTitle]>(1, { + event: "setTitle", + context: "ABC123", + payload: { + title: "Hello world" + } + }); + + expect(connection.send).toHaveBeenNthCalledWith<[SetTitle]>(2, { + event: "setTitle", + context: "ABC123", + payload: { + state: 1, + target: Target.Software, + title: "This is a test" + } + }); + }); + + /** + * Asserts {@link DialAction.setTriggerDescription} forwards the command to the {@link connection}. + */ + it("setTriggerDescription", async () => { + // Arrange, act. + await dialAction.setTriggerDescription(); + await dialAction.setTriggerDescription({ + longTouch: "Long-touch", + push: "Push", + rotate: "Rotate", + touch: "Touch" + }); + + // Assert. + expect(connection.send).toHaveBeenCalledTimes(2); + expect(connection.send).toHaveBeenNthCalledWith<[SetTriggerDescription]>(1, { + event: "setTriggerDescription", + context: dialAction.id, + payload: {} + }); + + expect(connection.send).toHaveBeenNthCalledWith<[SetTriggerDescription]>(2, { + event: "setTriggerDescription", + context: dialAction.id, + payload: { + longTouch: "Long-touch", + push: "Push", + rotate: "Rotate", + touch: "Touch" + } + }); + }); + + /** + * Asserts {@link DialAction.showAlert} forwards the command to the {@link connection}. + */ + it("showAlert", async () => { + // Arrange, act. + await dialAction.showAlert(); + + // Assert. + expect(connection.send).toHaveBeenCalledTimes(1); + expect(connection.send).toHaveBeenCalledWith<[ShowAlert]>({ + context: dialAction.id, + event: "showAlert" + }); + }); + }); +}); diff --git a/src/plugin/actions/__tests__/key.test.ts b/src/plugin/actions/__tests__/key.test.ts new file mode 100644 index 00000000..b6b3858e --- /dev/null +++ b/src/plugin/actions/__tests__/key.test.ts @@ -0,0 +1,160 @@ +import { Target, type ActionIdentifier, type SetImage, type SetState, type SetTitle, type ShowAlert, type ShowOk } from "../../../api"; +import { connection } from "../../connection"; +import { Action } from "../action"; +import { KeyAction } from "../key"; + +jest.mock("../../logging"); +jest.mock("../../manifest"); +jest.mock("../../connection"); + +describe("KeyAction", () => { + /** + * Asserts the constructor of {@link KeyAction} sets the {@link KeyAction.manifestId} and {@link KeyAction.id}. + */ + it("constructor sets manifestId and id", () => { + // Arrange. + const source: ActionIdentifier = { + action: "com.elgato.test.one", + context: "ABC123" + }; + + // Act. + const keyAction = new KeyAction(source); + + // Assert. + expect(keyAction.id).toBe("ABC123"); + expect(keyAction.manifestId).toBe("com.elgato.test.one"); + }); + + /** + * Asserts the inheritance of {@link KeyAction}. + */ + it("inherits shared methods", () => { + // Arrange, act. + const keyAction = new KeyAction({ + action: "com.elgato.test.one", + context: "ABC123" + }); + + // Assert. + expect(keyAction).toBeInstanceOf(Action); + }); + + describe("sending", () => { + const keyAction = new KeyAction({ + action: "com.elgato.test.one", + context: "ABC123" + }); + + /** + * Asserts {@link KeyAction.setImage} forwards the command to the {@link connection}. + */ + it("setImage", async () => { + // Arrange, act + await keyAction.setImage(); + await keyAction.setImage("./imgs/test.png", { + state: 1, + target: Target.Hardware + }); + + // Assert. + expect(connection.send).toHaveBeenCalledTimes(2); + expect(connection.send).toHaveBeenNthCalledWith<[SetImage]>(1, { + context: keyAction.id, + event: "setImage", + payload: { + image: undefined, + state: undefined, + target: undefined + } + }); + + expect(connection.send).toHaveBeenNthCalledWith<[SetImage]>(2, { + context: keyAction.id, + event: "setImage", + payload: { + image: "./imgs/test.png", + state: 1, + target: Target.Hardware + } + }); + }); + + /** + * Asserts {@link KeyAction.setState} forwards the command to the {@link connection}. + */ + it("setState", async () => { + // Arrange, act. + await keyAction.setState(1); + + // Assert. + expect(connection.send).toHaveBeenCalledTimes(1); + expect(connection.send).toHaveBeenCalledWith<[SetState]>({ + context: keyAction.id, + event: "setState", + payload: { + state: 1 + } + }); + }); + + /** + * Asserts {@link KeyAction.setTitle} forwards the command to the {@link connection}. + */ + it("setTitle", async () => { + // Arrange, act. + await keyAction.setTitle("Hello world"); + await keyAction.setTitle("This is a test", { state: 1, target: Target.Software }); + + // Assert. + expect(connection.send).toHaveBeenCalledTimes(2); + expect(connection.send).toHaveBeenNthCalledWith<[SetTitle]>(1, { + event: "setTitle", + context: "ABC123", + payload: { + title: "Hello world" + } + }); + + expect(connection.send).toHaveBeenNthCalledWith<[SetTitle]>(2, { + event: "setTitle", + context: "ABC123", + payload: { + state: 1, + target: Target.Software, + title: "This is a test" + } + }); + }); + + /** + * Asserts {@link KeyAction.showAlert} forwards the command to the {@link connection}. + */ + it("showAlert", async () => { + // Arrange, act. + await keyAction.showAlert(); + + // Assert. + expect(connection.send).toHaveBeenCalledTimes(1); + expect(connection.send).toHaveBeenCalledWith<[ShowAlert]>({ + context: keyAction.id, + event: "showAlert" + }); + }); + + /** + * Asserts {@link KeyAction.showOk} forwards the command to the {@link connection}. + */ + it("showOk", async () => { + // Arrange, act + await keyAction.showOk(); + + // Assert. + expect(connection.send).toHaveBeenCalledTimes(1); + expect(connection.send).toHaveBeenCalledWith<[ShowOk]>({ + context: keyAction.id, + event: "showOk" + }); + }); + }); +}); diff --git a/src/plugin/actions/__tests__/multi.test.ts b/src/plugin/actions/__tests__/multi.test.ts new file mode 100644 index 00000000..93a8926b --- /dev/null +++ b/src/plugin/actions/__tests__/multi.test.ts @@ -0,0 +1,67 @@ +import { type ActionIdentifier, type SetState } from "../../../api"; +import { connection } from "../../connection"; +import { Action } from "../action"; +import { KeyInMultiAction } from "../multi"; + +jest.mock("../../logging"); +jest.mock("../../manifest"); +jest.mock("../../connection"); + +describe("KeyMultiAction", () => { + /** + * Asserts the constructor of {@link KeyInMultiAction} sets the {@link KeyInMultiAction.manifestId} and {@link KeyInMultiAction.id}. + */ + it("constructor sets manifestId and id", () => { + // Arrange. + const source: ActionIdentifier = { + action: "com.elgato.test.one", + context: "ABC123" + }; + + // Act. + const keyInMultiAction = new KeyInMultiAction(source); + + // Assert. + expect(keyInMultiAction.id).toBe("ABC123"); + expect(keyInMultiAction.manifestId).toBe("com.elgato.test.one"); + }); + + /** + * Asserts the inheritance of {@link KeyInMultiAction}. + */ + it("inherits shared methods", () => { + // Arrange, act. + const keyInMultiAction = new KeyInMultiAction({ + action: "com.elgato.test.one", + context: "ABC123" + }); + + // Assert. + expect(keyInMultiAction).toBeInstanceOf(Action); + }); + + describe("sending", () => { + const keyInMultiAction = new KeyInMultiAction({ + action: "com.elgato.test.one", + context: "ABC123" + }); + + /** + * Asserts {@link KeyInMultiAction.setState} forwards the command to the {@link connection}. + */ + it("setState", async () => { + // Arrange, act. + await keyInMultiAction.setState(1); + + // Assert. + expect(connection.send).toHaveBeenCalledTimes(1); + expect(connection.send).toHaveBeenCalledWith<[SetState]>({ + context: keyInMultiAction.id, + event: "setState", + payload: { + state: 1 + } + }); + }); + }); +}); diff --git a/src/plugin/actions/action.ts b/src/plugin/actions/action.ts index 064664cf..b2ef3686 100644 --- a/src/plugin/actions/action.ts +++ b/src/plugin/actions/action.ts @@ -1,6 +1,6 @@ -import type { JsonObject, JsonValue } from ".."; import type streamDeck from "../"; -import type { ActionIdentifier, DidReceiveSettings, FeedbackPayload, SetImage, SetTitle, SetTriggerDescription, State } from "../../api"; +import type { ActionIdentifier, DidReceiveSettings, SetImage, SetTitle } from "../../api"; +import type { JsonObject, JsonValue } from "../../common/json"; import type { KeyOf } from "../../common/utils"; import { connection } from "../connection"; import { ActionContext } from "./context"; @@ -20,7 +20,7 @@ export class Action extends ActionContext { } /** - * Gets the settings associated this action instance. See also {@link Action.setSettings}. + * Gets the settings associated this action instance. * @template U The type of settings associated with the action. * @returns Promise containing the action instance's settings. */ @@ -56,188 +56,18 @@ export class Action extends ActionContext { }); } - /** - * Sets the feedback for the current layout associated with this action instance, allowing for the visual items to be updated. Layouts are a powerful way to provide dynamic information - * to users, and can be assigned in the manifest, or dynamically via {@link Action.setFeedbackLayout}. - * - * The {@link feedback} payload defines which items within the layout will be updated, and are identified by their property name (defined as the `key` in the layout's definition). - * The values can either by a complete new definition, a `string` for layout item types of `text` and `pixmap`, or a `number` for layout item types of `bar` and `gbar`. - * - * For example, given the following custom layout definition saved relatively to the plugin at "layouts/MyCustomLayout.json". - * ``` - * { - * "id": "MyCustomLayout", - * "items": [{ - * "key": "text_item", // <-- Key used to identify which item is being updated. - * "type": "text", - * "rect": [16, 10, 136, 24], - * "alignment": "left", - * "value": "Some default value" - * }] - * } - * ``` - * - * And the layout assigned to an action within the manifest. - * ``` - * { - * "Actions": [{ - * "Encoder": { - * "Layout": "layouts/MyCustomLayout.json" - * } - * }] - * } - * ``` - * - * The layout item's text can be updated dynamically via. - * ``` - * action.setFeedback(ctx, { - * // "text_item" matches a "key" within the layout's JSON file. - * text_item: "Some new value" - * }) - * ``` - * - * Alternatively, more information can be updated. - * ``` - * action.setFeedback(ctx, { - * text_item: { // <-- "text_item" matches a "key" within the layout's JSON file. - * value: "Some new value", - * alignment: "center" - * } - * }); - * ``` - * @param feedback Object containing information about the layout items to be updated. - * @returns `Promise` resolved when the request to set the {@link feedback} has been sent to Stream Deck. - */ - public setFeedback(feedback: FeedbackPayload): Promise { - return connection.send({ - event: "setFeedback", - context: this.id, - payload: feedback - }); - } - - /** - * Sets the layout associated with this action instance. The layout must be either a built-in layout identifier, or path to a local layout JSON file within the plugin's folder. - * Use in conjunction with {@link Action.setFeedback} to update the layout's current items' settings. - * @param layout Name of a pre-defined layout, or relative path to a custom one. - * @returns `Promise` resolved when the new layout has been sent to Stream Deck. - */ - public setFeedbackLayout(layout: string): Promise { - return connection.send({ - event: "setFeedbackLayout", - context: this.id, - payload: { - layout - } - }); - } - - /** - * Sets the {@link image} to be display for this action instance. - * - * NB: The image can only be set by the plugin when the the user has not specified a custom image. - * @param image Image to display; this can be either a path to a local file within the plugin's folder, a base64 encoded `string` with the mime type declared (e.g. PNG, JPEG, etc.), - * or an SVG `string`. When `undefined`, the image from the manifest will be used. - * @param options Additional options that define where and how the image should be rendered. - * @returns `Promise` resolved when the request to set the {@link image} has been sent to Stream Deck. - */ - public setImage(image?: string, options?: ImageOptions): Promise { - return connection.send({ - event: "setImage", - context: this.id, - payload: { - image, - ...options - } - }); - } - /** * Sets the {@link settings} associated with this action instance. Use in conjunction with {@link Action.getSettings}. * @param settings Settings to persist. * @returns `Promise` resolved when the {@link settings} are sent to Stream Deck. */ - public setSettings(settings: T): Promise { + public setSettings(settings: U): Promise { return connection.send({ event: "setSettings", context: this.id, payload: settings }); } - - /** - * Sets the current {@link state} of this action instance; only applies to actions that have multiple states defined within the manifest. - * @param state State to set; this be either 0, or 1. - * @returns `Promise` resolved when the request to set the state of an action instance has been sent to Stream Deck. - */ - public setState(state: State): Promise { - return connection.send({ - event: "setState", - context: this.id, - payload: { - state - } - }); - } - - /** - * Sets the {@link title} displayed for this action instance. See also {@link SingletonAction.onTitleParametersDidChange}. - * - * NB: The title can only be set by the plugin when the the user has not specified a custom title. - * @param title Title to display; when `undefined` the title within the manifest will be used. - * @param options Additional options that define where and how the title should be rendered. - * @returns `Promise` resolved when the request to set the {@link title} has been sent to Stream Deck. - */ - public setTitle(title?: string, options?: TitleOptions): Promise { - return connection.send({ - event: "setTitle", - context: this.id, - payload: { - title, - ...options - } - }); - } - - /** - * Sets the trigger (interaction) {@link descriptions} associated with this action instance. Descriptions are shown within the Stream Deck application, and informs the user what - * will happen when they interact with the action, e.g. rotate, touch, etc. When {@link descriptions} is `undefined`, the descriptions will be reset to the values provided as part - * of the manifest. - * - * NB: Applies to encoders (dials / touchscreens) found on Stream Deck + devices. - * @param descriptions Descriptions that detail the action's interaction. - * @returns `Promise` resolved when the request to set the {@link descriptions} has been sent to Stream Deck. - */ - public setTriggerDescription(descriptions?: TriggerDescriptionOptions): Promise { - return connection.send({ - event: "setTriggerDescription", - context: this.id, - payload: descriptions || {} - }); - } - - /** - * Temporarily shows an alert (i.e. warning), in the form of an exclamation mark in a yellow triangle, on this action instance. Used to provide visual feedback when an action failed. - * @returns `Promise` resolved when the request to show an alert has been sent to Stream Deck. - */ - public showAlert(): Promise { - return connection.send({ - event: "showAlert", - context: this.id - }); - } - - /** - * Temporarily shows an "OK" (i.e. success), in the form of a check-mark in a green circle, on this action instance. Used to provide visual feedback when an action successfully - * executed. - * @returns `Promise` resolved when the request to show an "OK" has been sent to Stream Deck. - */ - public showOk(): Promise { - return connection.send({ - event: "showOk", - context: this.id - }); - } } /** @@ -249,8 +79,3 @@ export type ImageOptions = Omit, "image">; * Options that define how to render a title associated with an action. */ export type TitleOptions = Omit, "title">; - -/** - * Options that define the trigger descriptions associated with an action. - */ -export type TriggerDescriptionOptions = KeyOf; diff --git a/src/plugin/actions/dial.ts b/src/plugin/actions/dial.ts new file mode 100644 index 00000000..89d60037 --- /dev/null +++ b/src/plugin/actions/dial.ts @@ -0,0 +1,124 @@ +import type { ActionIdentifier, FeedbackPayload, SetTriggerDescription } from "../../api"; +import type { JsonObject } from "../../common/json"; +import type { KeyOf } from "../../common/utils"; +import { connection } from "../connection"; +import { Action, type ImageOptions, type TitleOptions } from "./action"; + +/** + * Provides a contextualized instance of a dial action. + * @template T The type of settings associated with the action. + */ +export class DialAction extends Action { + /** + * Initializes a new instance of the {@see DialAction} class. + * @param source Source of the action. + */ + constructor(source: ActionIdentifier) { + super(source); + } + + /** + * Sets the feedback for the current layout associated with this action instance, allowing for the visual items to be updated. Layouts are a powerful way to provide dynamic information + * to users, and can be assigned in the manifest, or dynamically via {@link Action.setFeedbackLayout}. + * + * The {@link feedback} payload defines which items within the layout will be updated, and are identified by their property name (defined as the `key` in the layout's definition). + * The values can either by a complete new definition, a `string` for layout item types of `text` and `pixmap`, or a `number` for layout item types of `bar` and `gbar`. + * @param feedback Object containing information about the layout items to be updated. + * @returns `Promise` resolved when the request to set the {@link feedback} has been sent to Stream Deck. + */ + public setFeedback(feedback: FeedbackPayload): Promise { + return connection.send({ + event: "setFeedback", + context: this.id, + payload: feedback + }); + } + + /** + * Sets the layout associated with this action instance. The layout must be either a built-in layout identifier, or path to a local layout JSON file within the plugin's folder. + * Use in conjunction with {@link Action.setFeedback} to update the layout's current items' settings. + * @param layout Name of a pre-defined layout, or relative path to a custom one. + * @returns `Promise` resolved when the new layout has been sent to Stream Deck. + */ + public setFeedbackLayout(layout: string): Promise { + return connection.send({ + event: "setFeedbackLayout", + context: this.id, + payload: { + layout + } + }); + } + + /** + * Sets the {@link image} to be display for this action instance. + * + * NB: The image can only be set by the plugin when the the user has not specified a custom image. + * @param image Image to display; this can be either a path to a local file within the plugin's folder, a base64 encoded `string` with the mime type declared (e.g. PNG, JPEG, etc.), + * or an SVG `string`. When `undefined`, the image from the manifest will be used. + * @param options Additional options that define where and how the image should be rendered. + * @returns `Promise` resolved when the request to set the {@link image} has been sent to Stream Deck. + */ + public setImage(image?: string, options?: ImageOptions): Promise { + return connection.send({ + event: "setImage", + context: this.id, + payload: { + image, + ...options + } + }); + } + + /** + * Sets the {@link title} displayed for this action instance. + * + * NB: The title can only be set by the plugin when the the user has not specified a custom title. + * @param title Title to display; when `undefined` the title within the manifest will be used. + * @param options Additional options that define where and how the title should be rendered. + * @returns `Promise` resolved when the request to set the {@link title} has been sent to Stream Deck. + */ + public setTitle(title?: string, options?: TitleOptions): Promise { + return connection.send({ + event: "setTitle", + context: this.id, + payload: { + title, + ...options + } + }); + } + + /** + * Sets the trigger (interaction) {@link descriptions} associated with this action instance. Descriptions are shown within the Stream Deck application, and informs the user what + * will happen when they interact with the action, e.g. rotate, touch, etc. When {@link descriptions} is `undefined`, the descriptions will be reset to the values provided as part + * of the manifest. + * + * NB: Applies to encoders (dials / touchscreens) found on Stream Deck + devices. + * @param descriptions Descriptions that detail the action's interaction. + * @returns `Promise` resolved when the request to set the {@link descriptions} has been sent to Stream Deck. + */ + public setTriggerDescription(descriptions?: TriggerDescriptionOptions): Promise { + return connection.send({ + event: "setTriggerDescription", + context: this.id, + payload: descriptions || {} + }); + } + + /** + * Temporarily shows an alert (i.e. warning), in the form of an exclamation mark in a yellow triangle, on this action instance. Used to provide visual feedback when an action failed. + * @returns `Promise` resolved when the request to show an alert has been sent to Stream Deck. + */ + public showAlert(): Promise { + return connection.send({ + event: "showAlert", + context: this.id + }); + } +} + +/** + * Options that define the trigger descriptions associated with an action. + */ +export type TriggerDescriptionOptions = KeyOf; diff --git a/src/plugin/actions/key.ts b/src/plugin/actions/key.ts new file mode 100644 index 00000000..9192b0fb --- /dev/null +++ b/src/plugin/actions/key.ts @@ -0,0 +1,95 @@ +import type { ActionIdentifier, State } from "../../api"; +import type { JsonObject } from "../../common/json"; +import { connection } from "../connection"; +import { Action, type ImageOptions, type TitleOptions } from "./action"; + +/** + * Provides a contextualized instance of a key action. + * @template T The type of settings associated with the action. + */ +export class KeyAction extends Action { + /** + * Initializes a new instance of the {@see KeyAction} class. + * @param source Source of the action. + */ + constructor(source: ActionIdentifier) { + super(source); + } + + /** + * Sets the {@link image} to be display for this action instance. + * + * NB: The image can only be set by the plugin when the the user has not specified a custom image. + * @param image Image to display; this can be either a path to a local file within the plugin's folder, a base64 encoded `string` with the mime type declared (e.g. PNG, JPEG, etc.), + * or an SVG `string`. When `undefined`, the image from the manifest will be used. + * @param options Additional options that define where and how the image should be rendered. + * @returns `Promise` resolved when the request to set the {@link image} has been sent to Stream Deck. + */ + public setImage(image?: string, options?: ImageOptions): Promise { + return connection.send({ + event: "setImage", + context: this.id, + payload: { + image, + ...options + } + }); + } + + /** + * Sets the current {@link state} of this action instance; only applies to actions that have multiple states defined within the manifest. + * @param state State to set; this be either 0, or 1. + * @returns `Promise` resolved when the request to set the state of an action instance has been sent to Stream Deck. + */ + public setState(state: State): Promise { + return connection.send({ + event: "setState", + context: this.id, + payload: { + state + } + }); + } + + /** + * Sets the {@link title} displayed for this action instance. + * + * NB: The title can only be set by the plugin when the the user has not specified a custom title. + * @param title Title to display; when `undefined` the title within the manifest will be used. + * @param options Additional options that define where and how the title should be rendered. + * @returns `Promise` resolved when the request to set the {@link title} has been sent to Stream Deck. + */ + public setTitle(title?: string, options?: TitleOptions): Promise { + return connection.send({ + event: "setTitle", + context: this.id, + payload: { + title, + ...options + } + }); + } + + /** + * Temporarily shows an alert (i.e. warning), in the form of an exclamation mark in a yellow triangle, on this action instance. Used to provide visual feedback when an action failed. + * @returns `Promise` resolved when the request to show an alert has been sent to Stream Deck. + */ + public showAlert(): Promise { + return connection.send({ + event: "showAlert", + context: this.id + }); + } + + /** + * Temporarily shows an "OK" (i.e. success), in the form of a check-mark in a green circle, on this action instance. Used to provide visual feedback when an action successfully + * executed. + * @returns `Promise` resolved when the request to show an "OK" has been sent to Stream Deck. + */ + public showOk(): Promise { + return connection.send({ + event: "showOk", + context: this.id + }); + } +} diff --git a/src/plugin/actions/multi.ts b/src/plugin/actions/multi.ts new file mode 100644 index 00000000..329928aa --- /dev/null +++ b/src/plugin/actions/multi.ts @@ -0,0 +1,33 @@ +import type { ActionIdentifier, State } from "../../api"; +import type { JsonObject } from "../../common/json"; +import { connection } from "../connection"; +import { Action } from "./action"; + +/** + * Provides a contextualized instance of a key action, within a multi-action. + * @template T The type of settings associated with the action. + */ +export class KeyInMultiAction extends Action { + /** + * Initializes a new instance of the {@see KeyMultiAction} class. + * @param source Source of the action. + */ + constructor(source: ActionIdentifier) { + super(source); + } + + /** + * Sets the current {@link state} of this action instance; only applies to actions that have multiple states defined within the manifest. + * @param state State to set; this be either 0, or 1. + * @returns `Promise` resolved when the request to set the state of an action instance has been sent to Stream Deck. + */ + public setState(state: State): Promise { + return connection.send({ + event: "setState", + context: this.id, + payload: { + state + } + }); + } +} diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 1e318c75..54462f22 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -36,8 +36,11 @@ export { EventEmitter, EventsOf } from "../common/event-emitter"; export { type JsonObject, type JsonPrimitive, type JsonValue } from "../common/json"; export { LogLevel } from "../common/logging"; export { type MessageRequestOptions, type MessageResponder, type MessageResponse, type RouteConfiguration, type StatusCode } from "../common/messaging"; -export { Action, ImageOptions, TitleOptions, TriggerDescriptionOptions } from "./actions/action"; +export { Action, ImageOptions, TitleOptions } from "./actions/action"; export { action } from "./actions/decorators"; +export { DialAction, TriggerDescriptionOptions } from "./actions/dial"; +export { KeyAction } from "./actions/key"; +export { KeyInMultiAction } from "./actions/multi"; export { SingletonAction } from "./actions/singleton-action"; export { type Device } from "./devices"; export * from "./events"; From 683a163bd7a30675f11d1afd3aab3a5d3b7787da Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sat, 14 Sep 2024 18:57:42 +0100 Subject: [PATCH 03/29] refactor: devices to use store, add initial tracking of actions --- src/plugin/devices.ts | 51 ++++------------------------------ src/plugin/store.ts | 64 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 45 deletions(-) create mode 100644 src/plugin/store.ts diff --git a/src/plugin/devices.ts b/src/plugin/devices.ts index 6a74348a..23be020f 100644 --- a/src/plugin/devices.ts +++ b/src/plugin/devices.ts @@ -2,57 +2,18 @@ import { type DeviceInfo } from "../api/device"; import type { IDisposable } from "../common/disposable"; import { connection } from "./connection"; import { DeviceDidConnectEvent, DeviceDidDisconnectEvent, DeviceEvent } from "./events"; +import store from "./store"; /** * Collection of tracked Stream Deck devices. */ class DeviceCollection { - /** - * Collection of tracked Stream Deck devices. - */ - private readonly devices = new Map(); - - /** - * Initializes a new instance of the {@link DeviceCollection} class. - */ - constructor() { - // Add the devices based on the registration parameters. - connection.once("connected", (info) => { - info.devices.forEach((dev) => { - this.devices.set(dev.id, { - ...dev, - isConnected: false - }); - }); - }); - - // Set newly connected devices. - connection.on("deviceDidConnect", ({ device: id, deviceInfo }) => { - this.devices.set( - id, - Object.assign(this.devices.get(id) || {}, { - id, - isConnected: true, - ...deviceInfo - }) - ); - }); - - // Updated disconnected devices. - connection.on("deviceDidDisconnect", ({ device: id }) => { - const device = this.devices.get(id); - if (device !== undefined) { - device.isConnected = false; - } - }); - } - /** * Gets the number of Stream Deck devices currently being tracked. * @returns The device count. */ public get length(): number { - return this.devices.size; + return store.devices.length; } /** @@ -60,7 +21,7 @@ class DeviceCollection { * @returns Collection of Stream Deck devices */ public [Symbol.iterator](): IterableIterator> { - return this.devices.values(); + return store.devices[Symbol.iterator](); } /** @@ -68,7 +29,7 @@ class DeviceCollection { * @param callback Function to invoke for each {@link Device}. */ public forEach(callback: (device: Readonly) => void): void { - this.devices.forEach((value) => callback(value)); + store.devices.forEach((value) => callback(value)); } /** @@ -77,7 +38,7 @@ class DeviceCollection { * @returns The Stream Deck device information; otherwise `undefined` if a device with the {@link deviceId} does not exist. */ public getDeviceById(deviceId: string): Device | undefined { - return this.devices.get(deviceId); + return store.devices.find((d) => d.id === deviceId); } /** @@ -105,7 +66,7 @@ class DeviceCollection { return connection.disposableOn("deviceDidDisconnect", (ev) => listener( new DeviceEvent(ev, { - ...this.devices.get(ev.device), + ...this.getDeviceById(ev.device), ...{ id: ev.device, isConnected: false } }) ) diff --git a/src/plugin/store.ts b/src/plugin/store.ts new file mode 100644 index 00000000..d6cf3947 --- /dev/null +++ b/src/plugin/store.ts @@ -0,0 +1,64 @@ +import { ActionIdentifier } from "../api"; +import { Enumerable } from "../common/enumerable"; +import { DialAction } from "./actions/dial"; +import { KeyAction } from "./actions/key"; +import { KeyInMultiAction } from "./actions/multi"; +import { connection } from "./connection"; +import type { Device } from "./devices"; + +const actions = new Map(); +const devices = new Map(); + +// Add actions appearing. +connection.prependListener("willAppear", (ev) => { + const context: ActionIdentifier = { + action: ev.action, + context: ev.context + }; + + if (ev.payload.controller === "Encoder") { + actions.set(context, new DialAction(context)); + } else if (ev.payload.isInMultiAction) { + actions.set(context, new KeyInMultiAction(context)); + } else { + actions.set(context, new KeyAction(context)); + } +}); + +// Remove actions disappearing. +connection.prependListener("willDisappear", (ev) => actions.delete(ev)); + +// Add the devices based on the registration parameters. +connection.once("connected", (info) => { + info.devices.forEach((dev) => { + devices.set(dev.id, { + ...dev, + isConnected: false + }); + }); +}); + +// Set newly connected devices. +connection.on("deviceDidConnect", ({ device: id, deviceInfo }) => { + devices.set( + id, + Object.assign(devices.get(id) || {}, { + id, + isConnected: true, + ...deviceInfo + }) + ); +}); + +// Updated disconnected devices. +connection.on("deviceDidDisconnect", ({ device: id }) => { + const device = devices.get(id); + if (device !== undefined) { + device.isConnected = false; + } +}); + +export default { + actions: Enumerable.from(actions), + devices: Enumerable.from(devices) +}; From c062b08affb3d1ed721ca8c31a15a8080d95516a Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sat, 14 Sep 2024 19:33:10 +0100 Subject: [PATCH 04/29] refactor: update Enumerable to be inheritable --- src/common/__tests__/enumerable.test.ts | 22 +++++------ src/common/enumerable.ts | 52 +++++-------------------- 2 files changed, 21 insertions(+), 53 deletions(-) diff --git a/src/common/__tests__/enumerable.test.ts b/src/common/__tests__/enumerable.test.ts index 42bd08b3..e36e8616 100644 --- a/src/common/__tests__/enumerable.test.ts +++ b/src/common/__tests__/enumerable.test.ts @@ -7,7 +7,7 @@ describe("Enumerable", () => { { name: "Wave DX" } // ]; - const enumerable = Enumerable.from(source); + const enumerable = new Enumerable(source); /** * Provides assertions for {@link Enumerable.from}. @@ -21,7 +21,7 @@ describe("Enumerable", () => { // Arrange. const fn = jest.fn(); const arr = [1, 2]; - const enumerable = Enumerable.from(arr); + const enumerable = new Enumerable(arr); // Act. arr.push(3, 4); @@ -41,7 +41,7 @@ describe("Enumerable", () => { const arr = [1]; // Act, assert. - const enumerable = Enumerable.from(arr); + const enumerable = new Enumerable(arr); expect(enumerable.length).toBe(1); // Act, assert. @@ -61,7 +61,7 @@ describe("Enumerable", () => { [1, "One"], [2, "Two"] ]); - const enumerable = Enumerable.from(map); + const enumerable = new Enumerable(map); // Act (1), assert (1). enumerable.forEach(fnBefore); @@ -92,7 +92,7 @@ describe("Enumerable", () => { ]); // Act (1). - const enumerable = Enumerable.from(map); + const enumerable = new Enumerable(map); // Assert (1). expect(enumerable.length).toBe(2); @@ -115,7 +115,7 @@ describe("Enumerable", () => { // Arrange (1). const fnBefore = jest.fn(); const set = new Set(["One", "Two"]); - const enumerable = Enumerable.from(set); + const enumerable = new Enumerable(set); // Act (1), assert (1). enumerable.forEach(fnBefore); @@ -142,7 +142,7 @@ describe("Enumerable", () => { const set = new Set(["One", "Two"]); // Act (1). - const enumerable = Enumerable.from(set); + const enumerable = new Enumerable(set); // Assert (1). expect(enumerable.length).toBe(2); @@ -164,7 +164,7 @@ describe("Enumerable", () => { describe("iterator", () => { // Arrange. const source = ["a", "b", "c"]; - const enumerable = Enumerable.from(source); + const enumerable = new Enumerable(source); // Act, assert. let i = 0; @@ -366,7 +366,7 @@ describe("Enumerable", () => { it("returns an empty array", () => { // Arrange, act. - const empty = Enumerable.from([]); + const empty = new Enumerable([]); const res = Array.from(empty.map((x) => x.toString())); // Assert. @@ -396,7 +396,7 @@ describe("Enumerable", () => { it("throws when empty", () => { // Arrange, act, assert. - const empty = Enumerable.from([]); + const empty = new Enumerable([]); expect(() => empty.reduce((prev, curr) => curr)).toThrowError(new TypeError("Reduce of empty enumerable with no initial value.")); }); }); @@ -410,7 +410,7 @@ describe("Enumerable", () => { it("reduces empty", () => { // Arrange, act, assert. - const empty = Enumerable.from([]); + const empty = new Enumerable([]); expect(empty.reduce((prev, curr) => `${prev}, ${curr}`, "Initial")).toBe("Initial"); }); }); diff --git a/src/common/enumerable.ts b/src/common/enumerable.ts index 66a1f4e8..8fe50848 100644 --- a/src/common/enumerable.ts +++ b/src/common/enumerable.ts @@ -14,12 +14,17 @@ export class Enumerable { /** * Initializes a new instance of the {@link Enumerable} class. - * @param items Underlying iterator responsible for providing the items. - * @param length Function to get the number of items. + * @param source Source that contains the items. + * @returns The enumerable. */ - private constructor(items: () => Iterable, length: () => number) { - this.#items = items; - this.#length = length; + public constructor(source: Map | Set | T[]) { + if (Array.isArray(source)) { + this.#items = () => source; + this.#length = () => source.length; + } else { + this.#items = () => source.values(); + this.#length = () => source.size; + } } /** @@ -30,43 +35,6 @@ export class Enumerable { return this.#length(); } - /** - * Creates a new enumerable from the specified array. - * @param source Source array. - * @returns The enumerable. - */ - public static from(source: T[]): Enumerable; - /** - * Creates a new enumerable from the specified map. - * @param source Source map. - * @returns The enumerable. - */ - public static from(source: Map): Enumerable; - /** - * Creates a new enumerable from the specified set. - * @param source Source set. - * @returns The enumerable. - */ - public static from(source: Set): Enumerable; - /** - * Creates a new enumerable from the specified items. - * @param source Source that contains the items. - * @returns The enumerable. - */ - public static from(source: Map | Set | T[]): Enumerable { - if (Array.isArray(source)) { - return new Enumerable( - () => source, - () => source.length - ); - } - - return new Enumerable( - () => source.values(), - () => source.size - ); - } - /** * Gets the iterator for the enumerable. * @returns The iterator. From 5041dc366aa9f1d810da548a78ea388c53a72aab Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sat, 14 Sep 2024 19:39:41 +0100 Subject: [PATCH 05/29] feat: allow Enumerable to be constructed from another Enumerable --- src/common/__tests__/enumerable.test.ts | 43 +++++++++++++++++++++++-- src/common/enumerable.ts | 7 ++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/common/__tests__/enumerable.test.ts b/src/common/__tests__/enumerable.test.ts index e36e8616..e9bf40b0 100644 --- a/src/common/__tests__/enumerable.test.ts +++ b/src/common/__tests__/enumerable.test.ts @@ -10,9 +10,48 @@ describe("Enumerable", () => { const enumerable = new Enumerable(source); /** - * Provides assertions for {@link Enumerable.from}. + * Provides assertions for the {@link Enumerable} constructor. */ - describe("from", () => { + describe("constructor", () => { + /** + * With Enumerable. + */ + describe("Enumerable", () => { + it("iterates enumerable", () => { + // Arrange. + const fn = jest.fn(); + const arr = [1, 2]; + const source = new Enumerable(arr); + const enumerable = new Enumerable(source); + + // Act. + arr.push(3, 4); + enumerable.forEach(fn); + + // Assert. + expect(enumerable.length).toBe(4); + expect(fn).toHaveBeenCalledTimes(4); + expect(fn).toHaveBeenNthCalledWith(1, 1); + expect(fn).toHaveBeenNthCalledWith(2, 2); + expect(fn).toHaveBeenNthCalledWith(3, 3); + expect(fn).toHaveBeenNthCalledWith(4, 4); + }); + + it("reads length", () => { + // Arrange. + const arr = [1]; + const source = new Enumerable(arr); + + // Act, assert. + const enumerable = new Enumerable(source); + expect(enumerable.length).toBe(1); + + // Act, assert. + arr.push(2); + expect(enumerable.length).toBe(2); + }); + }); + /** * With T[]. */ diff --git a/src/common/enumerable.ts b/src/common/enumerable.ts index 8fe50848..7327e688 100644 --- a/src/common/enumerable.ts +++ b/src/common/enumerable.ts @@ -17,8 +17,11 @@ export class Enumerable { * @param source Source that contains the items. * @returns The enumerable. */ - public constructor(source: Map | Set | T[]) { - if (Array.isArray(source)) { + public constructor(source: Enumerable | Map | Set | T[]) { + if (source instanceof Enumerable) { + this.#items = source.#items; + this.#length = source.#length; + } else if (Array.isArray(source)) { this.#items = () => source; this.#length = () => source.length; } else { From 23fe62587d5eeea7ba84b295ddffc336d3489eb4 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sat, 14 Sep 2024 21:15:37 +0100 Subject: [PATCH 06/29] feat: update action to include device and coordinates --- src/plugin/actions/__tests__/action.test.ts | 37 +++++++---- src/plugin/actions/__tests__/dial.test.ts | 52 +++++++++++---- src/plugin/actions/__tests__/key.test.ts | 54 ++++++++++++---- src/plugin/actions/__tests__/multi.test.ts | 37 +++++++---- src/plugin/actions/action.ts | 72 +++++++++++++++++++-- src/plugin/actions/context.ts | 25 ------- src/plugin/actions/dial.ts | 25 +++++-- src/plugin/actions/key.ts | 25 +++++-- src/plugin/actions/multi.ts | 10 +-- src/plugin/store.ts | 28 ++++---- 10 files changed, 255 insertions(+), 110 deletions(-) delete mode 100644 src/plugin/actions/context.ts diff --git a/src/plugin/actions/__tests__/action.test.ts b/src/plugin/actions/__tests__/action.test.ts index 365aefce..ed9e5001 100644 --- a/src/plugin/actions/__tests__/action.test.ts +++ b/src/plugin/actions/__tests__/action.test.ts @@ -1,7 +1,7 @@ -import { type ActionIdentifier, type GetSettings, type SendToPropertyInspector, type SetSettings } from "../../../api"; +import { type GetSettings, type SendToPropertyInspector, type SetSettings } from "../../../api"; import { Settings } from "../../../api/__mocks__/events"; import { connection } from "../../connection"; -import { Action } from "../action"; +import { Action, type ActionContext } from "../action"; jest.mock("../../logging"); jest.mock("../../manifest"); @@ -13,17 +13,22 @@ describe("Action", () => { */ it("constructor sets manifestId and id", () => { // Arrange. - const source: ActionIdentifier = { - action: "com.elgato.test.one", - context: "ABC123" + const context: ActionContext = { + device: { + id: "DEV123", + isConnected: false + }, + id: "ABC123", + manifestId: "com.elgato.test.one" }; // Act. - const action = new Action(source); + const action = new Action(context); // Assert. - expect(action.id).toBe("ABC123"); - expect(action.manifestId).toBe("com.elgato.test.one"); + expect(action.device).toBe(context.device); + expect(action.id).toBe(context.id); + expect(action.manifestId).toBe(context.manifestId); }); /** @@ -32,8 +37,12 @@ describe("Action", () => { it("getSettings", async () => { // Arrange. const action = new Action({ - action: "com.elgato.test.one", - context: "ABC123" + device: { + id: "DEV123", + isConnected: false + }, + id: "ABC123", + manifestId: "com.elgato.test.one" }); // Act (Command). @@ -145,8 +154,12 @@ describe("Action", () => { describe("sending", () => { const action = new Action({ - action: "com.elgato.test.one", - context: "ABC123" + device: { + id: "DEV123", + isConnected: false + }, + id: "ABC123", + manifestId: "com.elgato.test.one" }); /** diff --git a/src/plugin/actions/__tests__/dial.test.ts b/src/plugin/actions/__tests__/dial.test.ts index fbcf7992..f3c8520d 100644 --- a/src/plugin/actions/__tests__/dial.test.ts +++ b/src/plugin/actions/__tests__/dial.test.ts @@ -1,6 +1,6 @@ -import { Target, type ActionIdentifier, type SetFeedback, type SetFeedbackLayout, type SetImage, type SetTitle, type SetTriggerDescription, type ShowAlert } from "../../../api"; +import { Target, type SetFeedback, type SetFeedbackLayout, type SetImage, type SetTitle, type SetTriggerDescription, type ShowAlert } from "../../../api"; import { connection } from "../../connection"; -import { Action } from "../action"; +import { Action, type CoordinatedActionContext } from "../action"; import { DialAction } from "../dial"; jest.mock("../../logging"); @@ -9,21 +9,31 @@ jest.mock("../../connection"); describe("Action", () => { /** - * Asserts the constructor of {@link Dial} sets the {@link DialAction.manifestId} and {@link DialAction.id}. + * Asserts the constructor of {@link Dial} sets the context. */ - it("constructor sets manifestId and id", () => { + it("constructor sets context", () => { // Arrange. - const source: ActionIdentifier = { - action: "com.elgato.test.one", - context: "ABC123" + const source: CoordinatedActionContext = { + device: { + id: "DEV123", + isConnected: false + }, + id: "ABC123", + manifestId: "com.elgato.test.one", + coordinates: { + column: 1, + row: 2 + } }; // Act. const dialAction = new DialAction(source); // Assert. - expect(dialAction.id).toBe("ABC123"); - expect(dialAction.manifestId).toBe("com.elgato.test.one"); + expect(dialAction.coordinates).toBe(source.coordinates); + expect(dialAction.device).toBe(source.device); + expect(dialAction.id).toBe(source.id); + expect(dialAction.manifestId).toBe(source.manifestId); }); /** @@ -32,8 +42,16 @@ describe("Action", () => { it("inherits shared methods", () => { // Arrange, act. const dialAction = new DialAction({ - action: "com.elgato.test.one", - context: "ABC123" + device: { + id: "DEV123", + isConnected: false + }, + id: "ABC123", + manifestId: "com.elgato.test.one", + coordinates: { + column: 1, + row: 2 + } }); // Assert. @@ -42,8 +60,16 @@ describe("Action", () => { describe("sending", () => { const dialAction = new DialAction({ - action: "com.elgato.test.one", - context: "ABC123" + device: { + id: "DEV123", + isConnected: false + }, + id: "ABC123", + manifestId: "com.elgato.test.one", + coordinates: { + column: 1, + row: 2 + } }); /** diff --git a/src/plugin/actions/__tests__/key.test.ts b/src/plugin/actions/__tests__/key.test.ts index b6b3858e..774765d9 100644 --- a/src/plugin/actions/__tests__/key.test.ts +++ b/src/plugin/actions/__tests__/key.test.ts @@ -1,6 +1,6 @@ -import { Target, type ActionIdentifier, type SetImage, type SetState, type SetTitle, type ShowAlert, type ShowOk } from "../../../api"; +import { Target, type SetImage, type SetState, type SetTitle, type ShowAlert, type ShowOk } from "../../../api"; import { connection } from "../../connection"; -import { Action } from "../action"; +import { Action, type CoordinatedActionContext } from "../action"; import { KeyAction } from "../key"; jest.mock("../../logging"); @@ -9,21 +9,31 @@ jest.mock("../../connection"); describe("KeyAction", () => { /** - * Asserts the constructor of {@link KeyAction} sets the {@link KeyAction.manifestId} and {@link KeyAction.id}. + * Asserts the constructor of {@link KeyAction} sets the context. */ - it("constructor sets manifestId and id", () => { + it("constructor sets context", () => { // Arrange. - const source: ActionIdentifier = { - action: "com.elgato.test.one", - context: "ABC123" + const context: CoordinatedActionContext = { + device: { + id: "DEV123", + isConnected: false + }, + id: "ABC123", + manifestId: "com.elgato.test.one", + coordinates: { + column: 1, + row: 2 + } }; // Act. - const keyAction = new KeyAction(source); + const keyAction = new KeyAction(context); // Assert. - expect(keyAction.id).toBe("ABC123"); - expect(keyAction.manifestId).toBe("com.elgato.test.one"); + expect(keyAction.coordinates).toBe(context.coordinates); + expect(keyAction.device).toBe(context.device); + expect(keyAction.id).toBe(context.id); + expect(keyAction.manifestId).toBe(context.manifestId); }); /** @@ -32,8 +42,16 @@ describe("KeyAction", () => { it("inherits shared methods", () => { // Arrange, act. const keyAction = new KeyAction({ - action: "com.elgato.test.one", - context: "ABC123" + device: { + id: "DEV123", + isConnected: false + }, + id: "ABC123", + manifestId: "com.elgato.test.one", + coordinates: { + column: 1, + row: 2 + } }); // Assert. @@ -42,8 +60,16 @@ describe("KeyAction", () => { describe("sending", () => { const keyAction = new KeyAction({ - action: "com.elgato.test.one", - context: "ABC123" + device: { + id: "DEV123", + isConnected: false + }, + id: "ABC123", + manifestId: "com.elgato.test.one", + coordinates: { + column: 1, + row: 2 + } }); /** diff --git a/src/plugin/actions/__tests__/multi.test.ts b/src/plugin/actions/__tests__/multi.test.ts index 93a8926b..bd0c9cfe 100644 --- a/src/plugin/actions/__tests__/multi.test.ts +++ b/src/plugin/actions/__tests__/multi.test.ts @@ -1,6 +1,6 @@ -import { type ActionIdentifier, type SetState } from "../../../api"; +import { type SetState } from "../../../api"; import { connection } from "../../connection"; -import { Action } from "../action"; +import { Action, type ActionContext } from "../action"; import { KeyInMultiAction } from "../multi"; jest.mock("../../logging"); @@ -13,17 +13,22 @@ describe("KeyMultiAction", () => { */ it("constructor sets manifestId and id", () => { // Arrange. - const source: ActionIdentifier = { - action: "com.elgato.test.one", - context: "ABC123" + const context: ActionContext = { + device: { + id: "DEV123", + isConnected: false + }, + id: "ABC123", + manifestId: "com.elgato.test.one" }; // Act. - const keyInMultiAction = new KeyInMultiAction(source); + const keyInMultiAction = new KeyInMultiAction(context); // Assert. - expect(keyInMultiAction.id).toBe("ABC123"); - expect(keyInMultiAction.manifestId).toBe("com.elgato.test.one"); + expect(keyInMultiAction.device).toBe(context.device); + expect(keyInMultiAction.id).toBe(context.id); + expect(keyInMultiAction.manifestId).toBe(context.manifestId); }); /** @@ -32,8 +37,12 @@ describe("KeyMultiAction", () => { it("inherits shared methods", () => { // Arrange, act. const keyInMultiAction = new KeyInMultiAction({ - action: "com.elgato.test.one", - context: "ABC123" + device: { + id: "DEV123", + isConnected: false + }, + id: "ABC123", + manifestId: "com.elgato.test.one" }); // Assert. @@ -42,8 +51,12 @@ describe("KeyMultiAction", () => { describe("sending", () => { const keyInMultiAction = new KeyInMultiAction({ - action: "com.elgato.test.one", - context: "ABC123" + device: { + id: "DEV123", + isConnected: false + }, + id: "ABC123", + manifestId: "com.elgato.test.one" }); /** diff --git a/src/plugin/actions/action.ts b/src/plugin/actions/action.ts index b2ef3686..6adb853e 100644 --- a/src/plugin/actions/action.ts +++ b/src/plugin/actions/action.ts @@ -1,22 +1,48 @@ import type streamDeck from "../"; -import type { ActionIdentifier, DidReceiveSettings, SetImage, SetTitle } from "../../api"; +import type { Coordinates, DidReceiveSettings, SetImage, SetTitle } from "../../api"; import type { JsonObject, JsonValue } from "../../common/json"; import type { KeyOf } from "../../common/utils"; import { connection } from "../connection"; -import { ActionContext } from "./context"; +import type { Device } from "../devices"; import type { SingletonAction } from "./singleton-action"; /** * Provides a contextualized instance of an {@link Action}, allowing for direct communication with the Stream Deck. * @template T The type of settings associated with the action. */ -export class Action extends ActionContext { +export class Action implements ActionContext { + /** + * The action context. + */ + readonly #context: ActionContext; + /** * Initializes a new instance of the {@see Action} class. - * @param source Source of the action. + * @param context Action context. + */ + constructor(context: ActionContext) { + this.#context = context; + } + + /** + * @inheritdoc + */ + public get device(): Device { + return this.#context.device; + } + + /** + * @inheritdoc + */ + public get id(): string { + return this.#context.id; + } + + /** + * @inheritdoc */ - constructor(source: ActionIdentifier) { - super(source); + public get manifestId(): string { + return this.#context.manifestId; } /** @@ -79,3 +105,37 @@ export type ImageOptions = Omit, "image">; * Options that define how to render a title associated with an action. */ export type TitleOptions = Omit, "title">; + +/** + * Provides context information for an instance of an action. + */ +export type ActionContext = { + /** + * Stream Deck device the action is positioned on. + * @returns Stream Deck device. + */ + get device(): Device; + + /** + * Action instance identifier. + * @returns Identifier. + */ + get id(): string; + + /** + * Manifest identifier (UUID) for this action type. + * @returns Manifest identifier. + */ + get manifestId(): string; +}; + +/** + * Provides context information for an instance of an action, with coordinates. + */ +export type CoordinatedActionContext = ActionContext & { + /** + * Coordinates of the action, on the Stream Deck device. + * @returns Coordinates. + */ + get coordinates(): Readonly; +}; diff --git a/src/plugin/actions/context.ts b/src/plugin/actions/context.ts deleted file mode 100644 index 6963e3ec..00000000 --- a/src/plugin/actions/context.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ActionIdentifier } from "../../api"; - -/** - * Provides context for an action. - */ -export class ActionContext { - /** - * Unique identifier of the instance of the action; this can be used to update the action on the Stream Deck, e.g. its title, settings, etc. - */ - public readonly id: string; - - /** - * Unique identifier (UUID) of the action as defined within the plugin's manifest's actions collection. - */ - public readonly manifestId: string; - - /** - * Initializes a new instance of the {@see ActionContext} class. - * @param source Source of the context. - */ - constructor(source: ActionIdentifier) { - this.id = source.context; - this.manifestId = source.action; - } -} diff --git a/src/plugin/actions/dial.ts b/src/plugin/actions/dial.ts index 89d60037..f2671532 100644 --- a/src/plugin/actions/dial.ts +++ b/src/plugin/actions/dial.ts @@ -1,20 +1,33 @@ -import type { ActionIdentifier, FeedbackPayload, SetTriggerDescription } from "../../api"; +import type { Coordinates, FeedbackPayload, SetTriggerDescription } from "../../api"; import type { JsonObject } from "../../common/json"; import type { KeyOf } from "../../common/utils"; import { connection } from "../connection"; -import { Action, type ImageOptions, type TitleOptions } from "./action"; +import { Action, type CoordinatedActionContext, type ImageOptions, type TitleOptions } from "./action"; /** * Provides a contextualized instance of a dial action. * @template T The type of settings associated with the action. */ -export class DialAction extends Action { +export class DialAction extends Action implements CoordinatedActionContext { + /** + * The action context. + */ + readonly #context: CoordinatedActionContext; + /** * Initializes a new instance of the {@see DialAction} class. - * @param source Source of the action. + * @param context Action context. + */ + constructor(context: CoordinatedActionContext) { + super(context); + this.#context = context; + } + + /** + * @inheritdoc */ - constructor(source: ActionIdentifier) { - super(source); + public get coordinates(): Coordinates { + return this.#context.coordinates; } /** diff --git a/src/plugin/actions/key.ts b/src/plugin/actions/key.ts index 9192b0fb..31031091 100644 --- a/src/plugin/actions/key.ts +++ b/src/plugin/actions/key.ts @@ -1,19 +1,32 @@ -import type { ActionIdentifier, State } from "../../api"; +import type { Coordinates, State } from "../../api"; import type { JsonObject } from "../../common/json"; import { connection } from "../connection"; -import { Action, type ImageOptions, type TitleOptions } from "./action"; +import { Action, type CoordinatedActionContext, type ImageOptions, type TitleOptions } from "./action"; /** * Provides a contextualized instance of a key action. * @template T The type of settings associated with the action. */ -export class KeyAction extends Action { +export class KeyAction extends Action implements CoordinatedActionContext { + /** + * The action context. + */ + readonly #context: CoordinatedActionContext; + /** * Initializes a new instance of the {@see KeyAction} class. - * @param source Source of the action. + * @param context Action context. + */ + constructor(context: CoordinatedActionContext) { + super(context); + this.#context = context; + } + + /** + * @inheritdoc */ - constructor(source: ActionIdentifier) { - super(source); + public get coordinates(): Coordinates { + return this.#context.coordinates; } /** diff --git a/src/plugin/actions/multi.ts b/src/plugin/actions/multi.ts index 329928aa..14ed3ff2 100644 --- a/src/plugin/actions/multi.ts +++ b/src/plugin/actions/multi.ts @@ -1,7 +1,7 @@ -import type { ActionIdentifier, State } from "../../api"; +import type { State } from "../../api"; import type { JsonObject } from "../../common/json"; import { connection } from "../connection"; -import { Action } from "./action"; +import { Action, type ActionContext } from "./action"; /** * Provides a contextualized instance of a key action, within a multi-action. @@ -10,10 +10,10 @@ import { Action } from "./action"; export class KeyInMultiAction extends Action { /** * Initializes a new instance of the {@see KeyMultiAction} class. - * @param source Source of the action. + * @param context Action context. */ - constructor(source: ActionIdentifier) { - super(source); + constructor(context: ActionContext) { + super(context); } /** diff --git a/src/plugin/store.ts b/src/plugin/store.ts index d6cf3947..da7bfb71 100644 --- a/src/plugin/store.ts +++ b/src/plugin/store.ts @@ -1,32 +1,38 @@ -import { ActionIdentifier } from "../api"; +import { type WillAppear, type WillDisappear } from "../api"; import { Enumerable } from "../common/enumerable"; +import type { JsonObject } from "../common/json"; +import type { ActionContext } from "./actions/action"; import { DialAction } from "./actions/dial"; import { KeyAction } from "./actions/key"; import { KeyInMultiAction } from "./actions/multi"; import { connection } from "./connection"; import type { Device } from "./devices"; -const actions = new Map(); +const actions = new Map(); const devices = new Map(); +const keyOfAction = (ev: WillAppear | WillDisappear) => `${ev.action}_${ev.device}_${ev.context}`; + // Add actions appearing. connection.prependListener("willAppear", (ev) => { - const context: ActionIdentifier = { - action: ev.action, - context: ev.context + const key = keyOfAction(ev); + const context: ActionContext = { + id: ev.context, + manifestId: ev.action, + device: devices.get(ev.device)! }; if (ev.payload.controller === "Encoder") { - actions.set(context, new DialAction(context)); + actions.set(key, new DialAction({ ...context, coordinates: Object.freeze(ev.payload.coordinates) })); } else if (ev.payload.isInMultiAction) { - actions.set(context, new KeyInMultiAction(context)); + actions.set(key, new KeyInMultiAction(context)); } else { - actions.set(context, new KeyAction(context)); + actions.set(key, new KeyAction({ ...context, coordinates: Object.freeze(ev.payload.coordinates) })); } }); // Remove actions disappearing. -connection.prependListener("willDisappear", (ev) => actions.delete(ev)); +connection.prependListener("willDisappear", (ev) => actions.delete(keyOfAction(ev))); // Add the devices based on the registration parameters. connection.once("connected", (info) => { @@ -59,6 +65,6 @@ connection.on("deviceDidDisconnect", ({ device: id }) => { }); export default { - actions: Enumerable.from(actions), - devices: Enumerable.from(devices) + actions: new Enumerable(actions), + devices: new Enumerable(devices) }; From 62756b860f383b9a01bd5b93512d04405e15f3fb Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sat, 14 Sep 2024 21:16:02 +0100 Subject: [PATCH 07/29] refactor: update devices to inherit Enumerable --- src/plugin/devices.ts | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/src/plugin/devices.ts b/src/plugin/devices.ts index 23be020f..82526d8c 100644 --- a/src/plugin/devices.ts +++ b/src/plugin/devices.ts @@ -1,5 +1,6 @@ import { type DeviceInfo } from "../api/device"; import type { IDisposable } from "../common/disposable"; +import { Enumerable } from "../common/enumerable"; import { connection } from "./connection"; import { DeviceDidConnectEvent, DeviceDidDisconnectEvent, DeviceEvent } from "./events"; import store from "./store"; @@ -7,29 +8,12 @@ import store from "./store"; /** * Collection of tracked Stream Deck devices. */ -class DeviceCollection { +class DeviceCollection extends Enumerable> { /** - * Gets the number of Stream Deck devices currently being tracked. - * @returns The device count. + * Initializes a new instance of the {@link DeviceCollection} class. */ - public get length(): number { - return store.devices.length; - } - - /** - * Gets the iterator capable of iterating the collection of Stream Deck devices. - * @returns Collection of Stream Deck devices - */ - public [Symbol.iterator](): IterableIterator> { - return store.devices[Symbol.iterator](); - } - - /** - * Iterates over each {@link Device} and invokes the {@link callback} function. - * @param callback Function to invoke for each {@link Device}. - */ - public forEach(callback: (device: Readonly) => void): void { - store.devices.forEach((value) => callback(value)); + constructor() { + super(store.devices); } /** From 7609ea9c78fe88677dc65ee7dfd1b9105c3bf668 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sat, 14 Sep 2024 23:07:37 +0100 Subject: [PATCH 08/29] style: fix linting --- src/common/enumerable.ts | 12 ++++++------ src/plugin/actions/__tests__/dial.test.ts | 6 +++--- src/plugin/store.ts | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/common/enumerable.ts b/src/common/enumerable.ts index 7327e688..cc122a36 100644 --- a/src/common/enumerable.ts +++ b/src/common/enumerable.ts @@ -17,16 +17,16 @@ export class Enumerable { * @param source Source that contains the items. * @returns The enumerable. */ - public constructor(source: Enumerable | Map | Set | T[]) { + constructor(source: Enumerable | Map | Set | T[]) { if (source instanceof Enumerable) { this.#items = source.#items; this.#length = source.#length; } else if (Array.isArray(source)) { - this.#items = () => source; - this.#length = () => source.length; + this.#items = (): Iterable => source; + this.#length = (): number => source.length; } else { - this.#items = () => source.values(); - this.#length = () => source.size; + this.#items = (): IterableIterator => source.values(); + this.#length = (): number => source.size; } } @@ -40,7 +40,7 @@ export class Enumerable { /** * Gets the iterator for the enumerable. - * @returns The iterator. + * @yields The items. */ public *[Symbol.iterator](): IterableIterator { for (const item of this.#items()) { diff --git a/src/plugin/actions/__tests__/dial.test.ts b/src/plugin/actions/__tests__/dial.test.ts index f3c8520d..57e9269b 100644 --- a/src/plugin/actions/__tests__/dial.test.ts +++ b/src/plugin/actions/__tests__/dial.test.ts @@ -7,9 +7,9 @@ jest.mock("../../logging"); jest.mock("../../manifest"); jest.mock("../../connection"); -describe("Action", () => { +describe("DialAction", () => { /** - * Asserts the constructor of {@link Dial} sets the context. + * Asserts the constructor of {@link DialAction} sets the context. */ it("constructor sets context", () => { // Arrange. @@ -37,7 +37,7 @@ describe("Action", () => { }); /** - * Asserts the inheritance of {@link KeyAction}. + * Asserts the inheritance of {@link DialAction}. */ it("inherits shared methods", () => { // Arrange, act. diff --git a/src/plugin/store.ts b/src/plugin/store.ts index da7bfb71..dd7f2346 100644 --- a/src/plugin/store.ts +++ b/src/plugin/store.ts @@ -11,7 +11,7 @@ import type { Device } from "./devices"; const actions = new Map(); const devices = new Map(); -const keyOfAction = (ev: WillAppear | WillDisappear) => `${ev.action}_${ev.device}_${ev.context}`; +const keyOfAction = (ev: WillAppear | WillDisappear): string => `${ev.action}_${ev.device}_${ev.context}`; // Add actions appearing. connection.prependListener("willAppear", (ev) => { From b5951f8b8e2fb94e8e3c04ea3cb8b4dd9df170e9 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sun, 15 Sep 2024 00:23:47 +0100 Subject: [PATCH 09/29] feat: track visible actions on devices --- .../__tests__/index.test.ts} | 38 ++--- src/plugin/devices/device.ts | 135 ++++++++++++++++++ src/plugin/{devices.ts => devices/index.ts} | 69 ++++----- src/plugin/store.ts | 70 --------- 4 files changed, 171 insertions(+), 141 deletions(-) rename src/plugin/{__tests__/devices.test.ts => devices/__tests__/index.test.ts} (91%) create mode 100644 src/plugin/devices/device.ts rename src/plugin/{devices.ts => devices/index.ts} (55%) delete mode 100644 src/plugin/store.ts diff --git a/src/plugin/__tests__/devices.test.ts b/src/plugin/devices/__tests__/index.test.ts similarity index 91% rename from src/plugin/__tests__/devices.test.ts rename to src/plugin/devices/__tests__/index.test.ts index 3ad3a0f0..d3e5e91f 100644 --- a/src/plugin/__tests__/devices.test.ts +++ b/src/plugin/devices/__tests__/index.test.ts @@ -1,7 +1,7 @@ -import type { DeviceDidConnectEvent, DeviceDidDisconnectEvent } from ".."; -import { DeviceType, type DeviceDidConnect, type DeviceDidDisconnect } from "../../api"; -import { type connection as Connection } from "../connection"; -import { type Device, type DeviceCollection } from "../devices"; +import { Device, type DeviceCollection } from "../"; +import type { DeviceDidConnectEvent, DeviceDidDisconnectEvent } from "../.."; +import { DeviceType, type DeviceDidConnect, type DeviceDidDisconnect } from "../../../api"; +import { type connection as Connection } from "../../connection"; jest.mock("../connection"); jest.mock("../logging"); @@ -42,20 +42,11 @@ describe("devices", () => { // Assert. expect(listener).toHaveBeenCalledTimes(2); - expect(listener).toHaveBeenNthCalledWith<[Device]>(1, { - id: connection.registrationParameters.info.devices[0].id, - isConnected: false, - name: connection.registrationParameters.info.devices[0].name, - size: connection.registrationParameters.info.devices[0].size, - type: connection.registrationParameters.info.devices[0].type - }); - expect(listener).toHaveBeenNthCalledWith<[Device]>(2, { - id: ev.device, - isConnected: true, - name: ev.deviceInfo.name, - size: ev.deviceInfo.size, - type: ev.deviceInfo.type - }); + expect(listener).toHaveBeenNthCalledWith<[Device]>( + 1, + new Device(connection.registrationParameters.info.devices[0].id, connection.registrationParameters.info.devices[0], false) + ); + expect(listener).toHaveBeenNthCalledWith<[Device]>(2, new Device(ev.device, ev.deviceInfo, true)); }); /** @@ -292,11 +283,7 @@ describe("devices", () => { // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[DeviceDidConnectEvent]>({ - device: { - ...ev.deviceInfo, - id: ev.device, - isConnected: true - }, + device: new Device(ev.device, ev.deviceInfo, true), type: "deviceDidConnect" }); @@ -328,10 +315,7 @@ describe("devices", () => { // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[DeviceDidDisconnectEvent]>({ - device: { - ...connection.registrationParameters.info.devices[0], - isConnected: false - }, + device: new Device(connection.registrationParameters.info.devices[0].id, connection.registrationParameters.info.devices[0], false), type: "deviceDidDisconnect" }); diff --git a/src/plugin/devices/device.ts b/src/plugin/devices/device.ts new file mode 100644 index 00000000..e7384033 --- /dev/null +++ b/src/plugin/devices/device.ts @@ -0,0 +1,135 @@ +import type { JsonObject } from ".."; +import type { DeviceInfo, DeviceType, Size, WillAppear } from "../../api"; +import { Enumerable } from "../../common/enumerable"; +import type { ActionContext } from "../actions/action"; +import { DialAction } from "../actions/dial"; +import { KeyAction } from "../actions/key"; +import { KeyInMultiAction } from "../actions/multi"; +import { connection } from "../connection"; + +/** + * Provides information about a device. + */ +export class Device { + /** + * Private backing field for {@link Device.actions}. + */ + #actions: Map = new Map(); + + /** + * Private backing field for {@link Device.isConnected}. + */ + #isConnected: boolean = false; + + /** + * Actions currently visible on the device. + */ + public readonly actions: Enumerable; + + /** + * Unique identifier of the device. + */ + public readonly id: string; + + /** + * Name of the device, as specified by the user in the Stream Deck application. + */ + public readonly name: string; + + /** + * Number of action slots, excluding dials / touchscreens, available to the device. + */ + public readonly size: Size; + + /** + * Type of the device that was connected, e.g. Stream Deck +, Stream Deck Pedal, etc. See {@link DeviceType}. + */ + public readonly type: DeviceType; + + /** + * Initializes a new instance of the {@link Device} class. + * @param id Device identifier. + * @param info Information about the device. + * @param isConnected Determines whether the device is connected. + */ + constructor(id: string, info: DeviceInfo, isConnected: boolean) { + this.actions = new Enumerable(this.#actions); + this.id = id; + this.#isConnected = isConnected; + this.name = info.name; + this.size = info.size; + this.type = info.type; + + // Monitor the devices connection status. + connection.prependListener("deviceDidConnect", (ev) => { + if (ev.device === this.id) { + this.#isConnected = true; + } + }); + + connection.prependListener("deviceDidDisconnect", (ev) => { + if (ev.device === this.id) { + this.#isConnected = false; + } + }); + + // Track the actions currently visible on the device. + connection.prependListener("willAppear", (ev) => { + if (ev.device === this.id) { + this.#addAction(ev); + } + }); + + connection.prependListener("willDisappear", (ev) => { + if (ev.device === this.id) { + this.#actions.delete(ev.context); + } + }); + } + + /** + * Determines whether the device is currently connected. + * @returns `true` when the device is connected; otherwise `false`. + */ + public get isConnected(): boolean { + return this.#isConnected; + } + + /** + * Adds the specified action to the underlying collection of visible actions for the device. + * @param ev The action's appearance event. + */ + #addAction(ev: WillAppear): void { + const context: ActionContext = { + device: this, + id: ev.context, + manifestId: ev.action + }; + + // Dial. + if (ev.payload.controller === "Encoder") { + this.#actions.set( + ev.context, + new DialAction({ + ...context, + coordinates: Object.freeze(ev.payload.coordinates) + }) + ); + return; + } + + // Key + if (!ev.payload.isInMultiAction) { + this.#actions.set( + ev.context, + new KeyAction({ + ...context, + coordinates: Object.freeze(ev.payload.coordinates) + }) + ); + } + + // Multi-action key + this.#actions.set(ev.context, new KeyInMultiAction(context)); + } +} diff --git a/src/plugin/devices.ts b/src/plugin/devices/index.ts similarity index 55% rename from src/plugin/devices.ts rename to src/plugin/devices/index.ts index 82526d8c..4e7afda9 100644 --- a/src/plugin/devices.ts +++ b/src/plugin/devices/index.ts @@ -1,19 +1,32 @@ -import { type DeviceInfo } from "../api/device"; -import type { IDisposable } from "../common/disposable"; -import { Enumerable } from "../common/enumerable"; -import { connection } from "./connection"; -import { DeviceDidConnectEvent, DeviceDidDisconnectEvent, DeviceEvent } from "./events"; -import store from "./store"; +import { Enumerable } from ".."; +import type { IDisposable } from "../../common/disposable"; +import { connection } from "../connection"; +import { DeviceDidConnectEvent, DeviceDidDisconnectEvent, DeviceEvent } from "../events"; +import { Device } from "./device"; + +const __devices = new Map(); /** * Collection of tracked Stream Deck devices. */ -class DeviceCollection extends Enumerable> { +class DeviceCollection extends Enumerable { /** * Initializes a new instance of the {@link DeviceCollection} class. */ constructor() { - super(store.devices); + super(__devices); + + // Add the devices based on the registration parameters. + connection.once("connected", (info) => { + info.devices.forEach((dev) => __devices.set(dev.id, new Device(dev.id, dev, false))); + }); + + // Add new devices. + connection.on("deviceDidConnect", ({ device: id, deviceInfo }) => { + if (!__devices.get(id)) { + __devices.set(id, new Device(id, deviceInfo, true)); + } + }); } /** @@ -22,7 +35,7 @@ class DeviceCollection extends Enumerable> { * @returns The Stream Deck device information; otherwise `undefined` if a device with the {@link deviceId} does not exist. */ public getDeviceById(deviceId: string): Device | undefined { - return store.devices.find((d) => d.id === deviceId); + return __devices.get(deviceId); } /** @@ -31,14 +44,7 @@ class DeviceCollection extends Enumerable> { * @returns A disposable that, when disposed, removes the listener. */ public onDeviceDidConnect(listener: (ev: DeviceDidConnectEvent) => void): IDisposable { - return connection.disposableOn("deviceDidConnect", (ev) => - listener( - new DeviceEvent(ev, { - ...ev.deviceInfo, - ...{ id: ev.device, isConnected: true } - }) - ) - ); + return connection.disposableOn("deviceDidConnect", (ev) => listener(new DeviceEvent(ev, this.getDeviceById(ev.device)!))); } /** @@ -47,14 +53,7 @@ class DeviceCollection extends Enumerable> { * @returns A disposable that, when disposed, removes the listener. */ public onDeviceDidDisconnect(listener: (ev: DeviceDidDisconnectEvent) => void): IDisposable { - return connection.disposableOn("deviceDidDisconnect", (ev) => - listener( - new DeviceEvent(ev, { - ...this.getDeviceById(ev.device), - ...{ id: ev.device, isConnected: false } - }) - ) - ); + return connection.disposableOn("deviceDidDisconnect", (ev) => listener(new DeviceEvent(ev, this.getDeviceById(ev.device)!))); } } @@ -63,22 +62,4 @@ class DeviceCollection extends Enumerable> { */ export const devices = new DeviceCollection(); -/** - * Collection of tracked Stream Deck devices. - */ -export { type DeviceCollection }; - -/** - * Provides information about a device. - */ -export type Device = Partial & { - /** - * Unique identifier of the device. - */ - id: string; - - /** - * Determines whether the device is currently connected. - */ - isConnected: boolean; -}; +export { Device, type DeviceCollection }; diff --git a/src/plugin/store.ts b/src/plugin/store.ts deleted file mode 100644 index dd7f2346..00000000 --- a/src/plugin/store.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { type WillAppear, type WillDisappear } from "../api"; -import { Enumerable } from "../common/enumerable"; -import type { JsonObject } from "../common/json"; -import type { ActionContext } from "./actions/action"; -import { DialAction } from "./actions/dial"; -import { KeyAction } from "./actions/key"; -import { KeyInMultiAction } from "./actions/multi"; -import { connection } from "./connection"; -import type { Device } from "./devices"; - -const actions = new Map(); -const devices = new Map(); - -const keyOfAction = (ev: WillAppear | WillDisappear): string => `${ev.action}_${ev.device}_${ev.context}`; - -// Add actions appearing. -connection.prependListener("willAppear", (ev) => { - const key = keyOfAction(ev); - const context: ActionContext = { - id: ev.context, - manifestId: ev.action, - device: devices.get(ev.device)! - }; - - if (ev.payload.controller === "Encoder") { - actions.set(key, new DialAction({ ...context, coordinates: Object.freeze(ev.payload.coordinates) })); - } else if (ev.payload.isInMultiAction) { - actions.set(key, new KeyInMultiAction(context)); - } else { - actions.set(key, new KeyAction({ ...context, coordinates: Object.freeze(ev.payload.coordinates) })); - } -}); - -// Remove actions disappearing. -connection.prependListener("willDisappear", (ev) => actions.delete(keyOfAction(ev))); - -// Add the devices based on the registration parameters. -connection.once("connected", (info) => { - info.devices.forEach((dev) => { - devices.set(dev.id, { - ...dev, - isConnected: false - }); - }); -}); - -// Set newly connected devices. -connection.on("deviceDidConnect", ({ device: id, deviceInfo }) => { - devices.set( - id, - Object.assign(devices.get(id) || {}, { - id, - isConnected: true, - ...deviceInfo - }) - ); -}); - -// Updated disconnected devices. -connection.on("deviceDidDisconnect", ({ device: id }) => { - const device = devices.get(id); - if (device !== undefined) { - device.isConnected = false; - } -}); - -export default { - actions: new Enumerable(actions), - devices: new Enumerable(devices) -}; From 180bfa0453f58fcf216af2fd17a66af8ced8b319 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sun, 15 Sep 2024 01:22:41 +0100 Subject: [PATCH 10/29] feat: update events to use Action instance --- src/plugin/actions/index.ts | 94 +++++++++++++++++++++-------- src/plugin/devices/device.ts | 9 +++ src/plugin/devices/index.ts | 2 +- src/plugin/events/index.ts | 44 ++++++++++---- src/plugin/settings.ts | 8 ++- src/plugin/ui/controller.ts | 28 +++++++-- src/plugin/ui/property-inspector.ts | 16 ++--- src/plugin/ui/router.ts | 6 +- 8 files changed, 151 insertions(+), 56 deletions(-) diff --git a/src/plugin/actions/index.ts b/src/plugin/actions/index.ts index 880c5a9c..ac6dcb17 100644 --- a/src/plugin/actions/index.ts +++ b/src/plugin/actions/index.ts @@ -3,6 +3,7 @@ import type { IDisposable } from "../../common/disposable"; import { ActionEvent } from "../../common/events"; import type { JsonObject } from "../../common/json"; import { connection } from "../connection"; +import { devices } from "../devices"; import { DialDownEvent, DialRotateEvent, @@ -17,23 +18,14 @@ import { import { getManifest } from "../manifest"; import { onDidReceiveSettings } from "../settings"; import { ui } from "../ui"; -import { Action } from "./action"; +import { Action, type ActionContext } from "./action"; +import { DialAction } from "./dial"; +import { KeyAction } from "./key"; +import { KeyInMultiAction } from "./multi"; import type { SingletonAction } from "./singleton-action"; const manifest = getManifest(); -/** - * Creates an {@link Action} controller capable of interacting with Stream Deck. - * @param id The instance identifier of the action to control; identifiers are supplied as part of events emitted by this client, and are accessible via {@link Action.id}. - * @returns The {@link Action} controller. - */ -export function createController(id: string): Omit, "manifestId"> { - return new Action({ - action: "", - context: id - }); -} - /** * Occurs when the user presses a dial (Stream Deck +). See also {@link onDialUp}. * @@ -43,7 +35,12 @@ export function createController(id: string): * @returns A disposable that, when disposed, removes the listener. */ export function onDialDown(listener: (ev: DialDownEvent) => void): IDisposable { - return connection.disposableOn("dialDown", (ev: DialDown) => listener(new ActionEvent, Action>(new Action(ev), ev))); + return connection.disposableOn("dialDown", (ev: DialDown) => { + const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + if (action && action instanceof DialAction) { + listener(new ActionEvent(action, ev)); + } + }); } /** @@ -53,7 +50,12 @@ export function onDialDown(listener: (ev: Dia * @returns A disposable that, when disposed, removes the listener. */ export function onDialRotate(listener: (ev: DialRotateEvent) => void): IDisposable { - return connection.disposableOn("dialRotate", (ev: DialRotate) => listener(new ActionEvent, Action>(new Action(ev), ev))); + return connection.disposableOn("dialRotate", (ev: DialRotate) => { + const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + if (action && action instanceof DialAction) { + listener(new ActionEvent(action, ev)); + } + }); } /** @@ -65,7 +67,12 @@ export function onDialRotate(listener: (ev: D * @returns A disposable that, when disposed, removes the listener. */ export function onDialUp(listener: (ev: DialUpEvent) => void): IDisposable { - return connection.disposableOn("dialUp", (ev: DialUp) => listener(new ActionEvent, Action>(new Action(ev), ev))); + return connection.disposableOn("dialUp", (ev: DialUp) => { + const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + if (action && action instanceof DialAction) { + listener(new ActionEvent(action, ev)); + } + }); } /** @@ -77,7 +84,12 @@ export function onDialUp(listener: (ev: DialU * @returns A disposable that, when disposed, removes the listener. */ export function onKeyDown(listener: (ev: KeyDownEvent) => void): IDisposable { - return connection.disposableOn("keyDown", (ev: KeyDown) => listener(new ActionEvent, Action>(new Action(ev), ev))); + return connection.disposableOn("keyDown", (ev: KeyDown) => { + const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + if (action && (action instanceof KeyAction || action instanceof KeyInMultiAction)) { + listener(new ActionEvent(action, ev)); + } + }); } /** @@ -89,7 +101,12 @@ export function onKeyDown(listener: (ev: KeyD * @returns A disposable that, when disposed, removes the listener. */ export function onKeyUp(listener: (ev: KeyUpEvent) => void): IDisposable { - return connection.disposableOn("keyUp", (ev: KeyUp) => listener(new ActionEvent, Action>(new Action(ev), ev))); + return connection.disposableOn("keyUp", (ev: KeyUp) => { + const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + if (action && (action instanceof KeyAction || action instanceof KeyInMultiAction)) { + listener(new ActionEvent(action, ev)); + } + }); } /** @@ -99,9 +116,12 @@ export function onKeyUp(listener: (ev: KeyUpE * @returns A disposable that, when disposed, removes the listener. */ export function onTitleParametersDidChange(listener: (ev: TitleParametersDidChangeEvent) => void): IDisposable { - return connection.disposableOn("titleParametersDidChange", (ev: TitleParametersDidChange) => - listener(new ActionEvent, Action>(new Action(ev), ev)) - ); + return connection.disposableOn("titleParametersDidChange", (ev: TitleParametersDidChange) => { + const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + if (action && action instanceof KeyAction) { + listener(new ActionEvent(action, ev)); + } + }); } /** @@ -111,7 +131,12 @@ export function onTitleParametersDidChange(li * @returns A disposable that, when disposed, removes the listener. */ export function onTouchTap(listener: (ev: TouchTapEvent) => void): IDisposable { - return connection.disposableOn("touchTap", (ev: TouchTap) => listener(new ActionEvent, Action>(new Action(ev), ev))); + return connection.disposableOn("touchTap", (ev: TouchTap) => { + const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + if (action && action instanceof DialAction) { + listener(new ActionEvent(action, ev)); + } + }); } /** @@ -122,7 +147,12 @@ export function onTouchTap(listener: (ev: Tou * @returns A disposable that, when disposed, removes the listener. */ export function onWillAppear(listener: (ev: WillAppearEvent) => void): IDisposable { - return connection.disposableOn("willAppear", (ev: WillAppear) => listener(new ActionEvent, Action>(new Action(ev), ev))); + return connection.disposableOn("willAppear", (ev: WillAppear) => { + const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + if (action) { + listener(new ActionEvent(action, ev)); + } + }); } /** @@ -133,7 +163,21 @@ export function onWillAppear(listener: (ev: W * @returns A disposable that, when disposed, removes the listener. */ export function onWillDisappear(listener: (ev: WillDisappearEvent) => void): IDisposable { - return connection.disposableOn("willDisappear", (ev: WillDisappear) => listener(new ActionEvent, Action>(new Action(ev), ev))); + return connection.disposableOn("willDisappear", (ev: WillDisappear) => { + const device = devices.getDeviceById(ev.device); + if (device) { + listener( + new ActionEvent( + { + device, + id: ev.context, + manifestId: ev.action + }, + ev + ) + ); + } + }); } /** @@ -199,5 +243,5 @@ type RoutingEvent = { /** * The {@link Action} the event is associated with. */ - action: Action; + action: Action | ActionContext; }; diff --git a/src/plugin/devices/device.ts b/src/plugin/devices/device.ts index e7384033..408c1f7a 100644 --- a/src/plugin/devices/device.ts +++ b/src/plugin/devices/device.ts @@ -95,6 +95,15 @@ export class Device { return this.#isConnected; } + /** + * Gets the visible action on this device with the specified {@link id}. + * @param deviceId Identifier of the action to find. + * @returns The visible action; otherwise `undefined`. + */ + public getActionById(id: string): DialAction | KeyAction | KeyInMultiAction | undefined { + return this.#actions.get(id); + } + /** * Adds the specified action to the underlying collection of visible actions for the device. * @param ev The action's appearance event. diff --git a/src/plugin/devices/index.ts b/src/plugin/devices/index.ts index 4e7afda9..f1baa01f 100644 --- a/src/plugin/devices/index.ts +++ b/src/plugin/devices/index.ts @@ -1,5 +1,5 @@ -import { Enumerable } from ".."; import type { IDisposable } from "../../common/disposable"; +import { Enumerable } from "../../common/enumerable"; import { connection } from "../connection"; import { DeviceDidConnectEvent, DeviceDidDisconnectEvent, DeviceEvent } from "../events"; import { Device } from "./device"; diff --git a/src/plugin/events/index.ts b/src/plugin/events/index.ts index ad1136cb..f4583f44 100644 --- a/src/plugin/events/index.ts +++ b/src/plugin/events/index.ts @@ -19,7 +19,10 @@ import type { } from "../../api"; import { ActionWithoutPayloadEvent, Event, type ActionEvent } from "../../common/events"; import type { JsonObject } from "../../common/json"; -import type { Action } from "../actions/action"; +import type { ActionContext } from "../actions/action"; +import type { DialAction } from "../actions/dial"; +import type { KeyAction } from "../actions/key"; +import type { KeyInMultiAction } from "../actions/multi"; import type { Device } from "../devices"; import { ApplicationEvent } from "./application-event"; import { DeviceEvent } from "./device-event"; @@ -52,47 +55,59 @@ export type DeviceDidDisconnectEvent = DeviceEvent; /** * Event information received from Stream Deck when a dial is pressed down. */ -export type DialDownEvent = ActionEvent, Action>; +export type DialDownEvent = ActionEvent, DialAction>; /** * Event information received from Stream Deck when a dial is rotated. */ -export type DialRotateEvent = ActionEvent, Action>; +export type DialRotateEvent = ActionEvent, DialAction>; /** * Event information received from Stream Deck when a pressed dial is released. */ -export type DialUpEvent = ActionEvent, Action>; +export type DialUpEvent = ActionEvent, DialAction>; /** * Event information received from Stream Deck when the plugin receives settings. */ -export type DidReceiveSettingsEvent = ActionEvent, Action>; +export type DidReceiveSettingsEvent = ActionEvent< + DidReceiveSettings, + DialAction | KeyAction | KeyInMultiAction +>; /** * Event information received from Stream Deck when a key is pressed down. */ -export type KeyDownEvent = ActionEvent, Action>; +export type KeyDownEvent = ActionEvent, KeyAction | KeyInMultiAction>; /** * Event information received from Stream Deck when a pressed key is release. */ -export type KeyUpEvent = ActionEvent, Action>; +export type KeyUpEvent = ActionEvent, KeyAction | KeyInMultiAction>; /** * Event information received from Stream Deck when the property inspector appears. */ -export type PropertyInspectorDidAppearEvent = ActionWithoutPayloadEvent>; +export type PropertyInspectorDidAppearEvent = ActionWithoutPayloadEvent< + PropertyInspectorDidAppear, + DialAction | KeyAction | KeyInMultiAction +>; /** * Event information received from Stream Deck when the property inspector disappears. */ -export type PropertyInspectorDidDisappearEvent = ActionWithoutPayloadEvent>; +export type PropertyInspectorDidDisappearEvent = ActionWithoutPayloadEvent< + PropertyInspectorDidDisappear, + DialAction | KeyAction | KeyInMultiAction +>; /** * Event information received from Stream Deck when the title, or title parameters, change. */ -export type TitleParametersDidChangeEvent = ActionEvent, Action>; +export type TitleParametersDidChangeEvent = ActionEvent< + TitleParametersDidChange, + DialAction | KeyAction +>; /** * Event information receives from Streak Deck when the system wakes from sleep. @@ -102,14 +117,17 @@ export type SystemDidWakeUpEvent = Event; /** * Event information received from Stream Deck when the touchscreen is touched. */ -export type TouchTapEvent = ActionEvent, Action>; +export type TouchTapEvent = ActionEvent, DialAction>; /** * Event information received from Stream Deck when an action appears on the canvas. */ -export type WillAppearEvent = ActionEvent, Action>; +export type WillAppearEvent = ActionEvent< + WillAppear, + DialAction | KeyAction | KeyInMultiAction +>; /** * Event information received from Stream Deck when an action disappears from the canvas. */ -export type WillDisappearEvent = ActionEvent, Action>; +export type WillDisappearEvent = ActionEvent, ActionContext>; diff --git a/src/plugin/settings.ts b/src/plugin/settings.ts index 41503fe6..0db2dd36 100644 --- a/src/plugin/settings.ts +++ b/src/plugin/settings.ts @@ -4,6 +4,7 @@ import { ActionEvent } from "../common/events"; import type { JsonObject } from "../common/json"; import { Action } from "./actions/action"; import { connection } from "./connection"; +import { devices } from "./devices"; import { DidReceiveGlobalSettingsEvent, DidReceiveSettingsEvent } from "./events"; /** @@ -38,7 +39,12 @@ export function onDidReceiveGlobalSettings(li * @returns A disposable that, when disposed, removes the listener. */ export function onDidReceiveSettings(listener: (ev: DidReceiveSettingsEvent) => void): IDisposable { - return connection.disposableOn("didReceiveSettings", (ev: DidReceiveSettings) => listener(new ActionEvent, Action>(new Action(ev), ev))); + return connection.disposableOn("didReceiveSettings", (ev: DidReceiveSettings) => { + const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + if (action) { + listener(new ActionEvent(action, ev)); + } + }); } /** diff --git a/src/plugin/ui/controller.ts b/src/plugin/ui/controller.ts index 96634015..21d5cf12 100644 --- a/src/plugin/ui/controller.ts +++ b/src/plugin/ui/controller.ts @@ -1,10 +1,11 @@ import type streamDeck from "../"; -import type { DidReceivePropertyInspectorMessage, PropertyInspectorDidAppear, PropertyInspectorDidDisappear } from "../../api"; +import type { DidReceivePropertyInspectorMessage } from "../../api"; import type { IDisposable } from "../../common/disposable"; import type { JsonObject, JsonValue } from "../../common/json"; import { PUBLIC_PATH_PREFIX, type RouteConfiguration } from "../../common/messaging"; import { Action } from "../actions/action"; import { connection } from "../connection"; +import { devices } from "../devices"; import { ActionWithoutPayloadEvent, SendToPluginEvent, type PropertyInspectorDidAppearEvent, type PropertyInspectorDidDisappearEvent } from "../events"; import { type MessageHandler } from "./message"; import { type PropertyInspector } from "./property-inspector"; @@ -29,7 +30,12 @@ class UIController { * @returns A disposable that, when disposed, removes the listener. */ public onDidAppear(listener: (ev: PropertyInspectorDidAppearEvent) => void): IDisposable { - return connection.disposableOn("propertyInspectorDidAppear", (ev) => listener(new ActionWithoutPayloadEvent>(new Action(ev), ev))); + return connection.disposableOn("propertyInspectorDidAppear", (ev) => { + const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + if (action) { + listener(new ActionWithoutPayloadEvent(action, ev)); + } + }); } /** @@ -39,9 +45,12 @@ class UIController { * @returns A disposable that, when disposed, removes the listener. */ public onDidDisappear(listener: (ev: PropertyInspectorDidDisappearEvent) => void): IDisposable { - return connection.disposableOn("propertyInspectorDidDisappear", (ev) => - listener(new ActionWithoutPayloadEvent>(new Action(ev), ev)) - ); + return connection.disposableOn("propertyInspectorDidDisappear", (ev) => { + const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + if (action) { + listener(new ActionWithoutPayloadEvent(action, ev)); + } + }); } /** @@ -57,7 +66,14 @@ class UIController { listener: (ev: SendToPluginEvent) => void ): IDisposable { return router.disposableOn("unhandledMessage", (ev) => { - listener(new SendToPluginEvent(new Action(ev), ev as DidReceivePropertyInspectorMessage)); + // Send to plugin doesn't include the device. + for (const device of devices) { + const action = device.getActionById(ev.context); + if (action) { + listener(new SendToPluginEvent(action, ev as DidReceivePropertyInspectorMessage)); + return; + } + } }); } diff --git a/src/plugin/ui/property-inspector.ts b/src/plugin/ui/property-inspector.ts index be53a202..06584f37 100644 --- a/src/plugin/ui/property-inspector.ts +++ b/src/plugin/ui/property-inspector.ts @@ -3,18 +3,21 @@ import type { ActionIdentifier, DeviceIdentifier } from "../../api"; import type { JsonValue } from "../../common/json"; import { PUBLIC_PATH_PREFIX, type MessageGateway, type MessageRequestOptions, type MessageResponse } from "../../common/messaging"; import type { Action } from "../actions/action"; -import { ActionContext } from "../actions/context"; +import type { DialAction } from "../actions/dial"; +import type { KeyAction } from "../actions/key"; +import type { KeyInMultiAction } from "../actions/multi"; import type { SingletonAction } from "../actions/singleton-action"; import { connection } from "../connection"; +import { devices } from "../devices"; /** * Property inspector providing information about its context, and functions for sending and fetching messages. */ -export class PropertyInspector extends ActionContext implements Pick, "fetch"> { +export class PropertyInspector implements Pick, "fetch"> { /** - * Unique identifier of the Stream Deck device this property inspector is associated with. + * Action associated with the property inspector */ - public readonly deviceId: string; + public readonly action: DialAction | KeyAction | KeyInMultiAction; /** * Initializes a new instance of the {@link PropertyInspector} class. @@ -25,8 +28,7 @@ export class PropertyInspector extends ActionContext implements Pick, source: ActionIdentifier & DeviceIdentifier ) { - super(source); - this.deviceId = source.device; + this.action = devices.getDeviceById(source.device)!.getActionById(source.context)!; } /** @@ -91,7 +93,7 @@ export class PropertyInspector extends ActionContext implements Pick { return connection.send({ event: "sendToPropertyInspector", - context: this.id, + context: this.action.id, payload }); } diff --git a/src/plugin/ui/router.ts b/src/plugin/ui/router.ts index 256dc2e0..c79617ed 100644 --- a/src/plugin/ui/router.ts +++ b/src/plugin/ui/router.ts @@ -25,7 +25,7 @@ const router = new MessageGateway( if (current) { await connection.send({ event: "sendToPropertyInspector", - context: current.id, + context: current.action.id, payload }); @@ -34,7 +34,7 @@ const router = new MessageGateway( return false; }, - (source) => new Action(source) + (source) => current!.action ); /** @@ -43,7 +43,7 @@ const router = new MessageGateway( * @returns `true` when the event is related to the current property inspector. */ function isCurrent(ev: PropertyInspectorDidAppear | PropertyInspectorDidDisappear): boolean { - return current?.id === ev.context && current.manifestId === ev.action && current.deviceId === ev.device; + return current?.action.id === ev.context && current.action.manifestId === ev.action && current.action.device.id === ev.device; } /* From 9d4d30657f71aa93e45d5c967e5952963a9c14f0 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sun, 15 Sep 2024 01:22:52 +0100 Subject: [PATCH 11/29] fix: action type --- src/plugin/devices/device.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/plugin/devices/device.ts b/src/plugin/devices/device.ts index 408c1f7a..ece6824f 100644 --- a/src/plugin/devices/device.ts +++ b/src/plugin/devices/device.ts @@ -124,6 +124,7 @@ export class Device { coordinates: Object.freeze(ev.payload.coordinates) }) ); + return; } @@ -136,6 +137,8 @@ export class Device { coordinates: Object.freeze(ev.payload.coordinates) }) ); + + return; } // Multi-action key From e93680aace5150701d267e2eab6932857b1583cd Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sun, 15 Sep 2024 15:18:45 +0100 Subject: [PATCH 12/29] feat: simplify action store --- src/plugin/actions/index.ts | 17 +++-- src/plugin/actions/store.ts | 95 +++++++++++++++++++++++ src/plugin/devices/device.ts | 114 ++++++++-------------------- src/plugin/devices/index.ts | 6 +- src/plugin/settings.ts | 4 +- src/plugin/ui/controller.ts | 16 ++-- src/plugin/ui/property-inspector.ts | 4 +- 7 files changed, 149 insertions(+), 107 deletions(-) create mode 100644 src/plugin/actions/store.ts diff --git a/src/plugin/actions/index.ts b/src/plugin/actions/index.ts index ac6dcb17..3a4d322b 100644 --- a/src/plugin/actions/index.ts +++ b/src/plugin/actions/index.ts @@ -23,6 +23,7 @@ import { DialAction } from "./dial"; import { KeyAction } from "./key"; import { KeyInMultiAction } from "./multi"; import type { SingletonAction } from "./singleton-action"; +import { actionStore } from "./store"; const manifest = getManifest(); @@ -36,7 +37,7 @@ const manifest = getManifest(); */ export function onDialDown(listener: (ev: DialDownEvent) => void): IDisposable { return connection.disposableOn("dialDown", (ev: DialDown) => { - const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + const action = actionStore.getActionById(ev.context); if (action && action instanceof DialAction) { listener(new ActionEvent(action, ev)); } @@ -51,7 +52,7 @@ export function onDialDown(listener: (ev: Dia */ export function onDialRotate(listener: (ev: DialRotateEvent) => void): IDisposable { return connection.disposableOn("dialRotate", (ev: DialRotate) => { - const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + const action = actionStore.getActionById(ev.context); if (action && action instanceof DialAction) { listener(new ActionEvent(action, ev)); } @@ -68,7 +69,7 @@ export function onDialRotate(listener: (ev: D */ export function onDialUp(listener: (ev: DialUpEvent) => void): IDisposable { return connection.disposableOn("dialUp", (ev: DialUp) => { - const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + const action = actionStore.getActionById(ev.context); if (action && action instanceof DialAction) { listener(new ActionEvent(action, ev)); } @@ -85,7 +86,7 @@ export function onDialUp(listener: (ev: DialU */ export function onKeyDown(listener: (ev: KeyDownEvent) => void): IDisposable { return connection.disposableOn("keyDown", (ev: KeyDown) => { - const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + const action = actionStore.getActionById(ev.context); if (action && (action instanceof KeyAction || action instanceof KeyInMultiAction)) { listener(new ActionEvent(action, ev)); } @@ -102,7 +103,7 @@ export function onKeyDown(listener: (ev: KeyD */ export function onKeyUp(listener: (ev: KeyUpEvent) => void): IDisposable { return connection.disposableOn("keyUp", (ev: KeyUp) => { - const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + const action = actionStore.getActionById(ev.context); if (action && (action instanceof KeyAction || action instanceof KeyInMultiAction)) { listener(new ActionEvent(action, ev)); } @@ -117,7 +118,7 @@ export function onKeyUp(listener: (ev: KeyUpE */ export function onTitleParametersDidChange(listener: (ev: TitleParametersDidChangeEvent) => void): IDisposable { return connection.disposableOn("titleParametersDidChange", (ev: TitleParametersDidChange) => { - const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + const action = actionStore.getActionById(ev.context); if (action && action instanceof KeyAction) { listener(new ActionEvent(action, ev)); } @@ -132,7 +133,7 @@ export function onTitleParametersDidChange(li */ export function onTouchTap(listener: (ev: TouchTapEvent) => void): IDisposable { return connection.disposableOn("touchTap", (ev: TouchTap) => { - const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + const action = actionStore.getActionById(ev.context); if (action && action instanceof DialAction) { listener(new ActionEvent(action, ev)); } @@ -148,7 +149,7 @@ export function onTouchTap(listener: (ev: Tou */ export function onWillAppear(listener: (ev: WillAppearEvent) => void): IDisposable { return connection.disposableOn("willAppear", (ev: WillAppear) => { - const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + const action = actionStore.getActionById(ev.context); if (action) { listener(new ActionEvent(action, ev)); } diff --git a/src/plugin/actions/store.ts b/src/plugin/actions/store.ts new file mode 100644 index 00000000..c7292f05 --- /dev/null +++ b/src/plugin/actions/store.ts @@ -0,0 +1,95 @@ +import type { WillAppear } from "../../api"; +import { Enumerable } from "../../common/enumerable"; +import type { JsonObject } from "../../common/json"; +import { connection } from "../connection"; +import type { DeviceCollection } from "../devices"; +import { type ActionContext } from "./action"; +import { DialAction } from "./dial"; +import { KeyAction } from "./key"; +import { KeyInMultiAction } from "./multi"; + +const __actions = new Map(); +let __devices: DeviceCollection | undefined; + +// Adds the action to the store. +connection.prependListener("willAppear", (ev) => { + if (__devices === undefined) { + throw new Error("Action store has not been initialized"); + } + + const context: ActionContext = { + device: __devices.getDeviceById(ev.device)!, + id: ev.context, + manifestId: ev.action + }; + + __actions.set(ev.context, create(ev, context)); +}); + +// Remove the action from the store. +connection.prependListener("willDisappear", (ev) => __actions.delete(ev.context)); + +/** + * Creates a new action from the event information, using the context. + * @param ev Source appearance event. + * @param context Context of the action. + * @returns The new action. + */ +function create(ev: WillAppear, context: ActionContext): DialAction | KeyAction | KeyInMultiAction { + // Dial. + if (ev.payload.controller === "Encoder") { + return new DialAction({ + ...context, + coordinates: Object.freeze(ev.payload.coordinates) + }); + } + + // Multi-action key + if (ev.payload.isInMultiAction) { + return new KeyInMultiAction(context); + } + + // Key action. + return new KeyAction({ + ...context, + coordinates: Object.freeze(ev.payload.coordinates) + }); +} + +/** + * Initializes the action store, allowing for actions to be associated with devices. + * @param devices Collection of devices. + */ +export function initializeStore(devices: DeviceCollection): void { + if (__devices !== undefined) { + throw new Error("Action store has already been initialized"); + } + + __devices = devices; +} + +/** + * Provides a store of visible actions. + */ +export class ActionStore extends Enumerable { + /** + * Initializes a new instance of the {@link ActionStore} class. + */ + constructor() { + super(__actions); + } + + /** + * Gets the action with the specified identifier. + * @param id Identifier of action to search for. + * @returns The action, when present; otherwise `undefined`. + */ + public getActionById(id: string): DialAction | KeyAction | KeyInMultiAction | undefined { + return __actions.get(id); + } +} + +/** + * Action store containing visible actions. + */ +export const actionStore = new ActionStore(); diff --git a/src/plugin/devices/device.ts b/src/plugin/devices/device.ts index ece6824f..66c4e5a3 100644 --- a/src/plugin/devices/device.ts +++ b/src/plugin/devices/device.ts @@ -1,51 +1,29 @@ -import type { JsonObject } from ".."; -import type { DeviceInfo, DeviceType, Size, WillAppear } from "../../api"; -import { Enumerable } from "../../common/enumerable"; -import type { ActionContext } from "../actions/action"; +import type { DeviceInfo, DeviceType, Size } from "../../api"; import { DialAction } from "../actions/dial"; import { KeyAction } from "../actions/key"; import { KeyInMultiAction } from "../actions/multi"; +import { actionStore } from "../actions/store"; import { connection } from "../connection"; /** * Provides information about a device. */ export class Device { - /** - * Private backing field for {@link Device.actions}. - */ - #actions: Map = new Map(); - /** * Private backing field for {@link Device.isConnected}. */ #isConnected: boolean = false; /** - * Actions currently visible on the device. + * Private backing field for the device's information. */ - public readonly actions: Enumerable; + #info: DeviceInfo; /** * Unique identifier of the device. */ public readonly id: string; - /** - * Name of the device, as specified by the user in the Stream Deck application. - */ - public readonly name: string; - - /** - * Number of action slots, excluding dials / touchscreens, available to the device. - */ - public readonly size: Size; - - /** - * Type of the device that was connected, e.g. Stream Deck +, Stream Deck Pedal, etc. See {@link DeviceType}. - */ - public readonly type: DeviceType; - /** * Initializes a new instance of the {@link Device} class. * @param id Device identifier. @@ -53,38 +31,32 @@ export class Device { * @param isConnected Determines whether the device is connected. */ constructor(id: string, info: DeviceInfo, isConnected: boolean) { - this.actions = new Enumerable(this.#actions); this.id = id; + this.#info = info; this.#isConnected = isConnected; - this.name = info.name; - this.size = info.size; - this.type = info.type; - // Monitor the devices connection status. + // Set connected. connection.prependListener("deviceDidConnect", (ev) => { if (ev.device === this.id) { + this.#info = ev.deviceInfo; this.#isConnected = true; } }); + // Set disconnected. connection.prependListener("deviceDidDisconnect", (ev) => { if (ev.device === this.id) { this.#isConnected = false; } }); + } - // Track the actions currently visible on the device. - connection.prependListener("willAppear", (ev) => { - if (ev.device === this.id) { - this.#addAction(ev); - } - }); - - connection.prependListener("willDisappear", (ev) => { - if (ev.device === this.id) { - this.#actions.delete(ev.context); - } - }); + /** + * Actions currently visible on the device. + * @returns Collection of visible actions. + */ + public get actions(): IterableIterator { + return actionStore.filter((a) => a.device.id === this.id); } /** @@ -96,52 +68,26 @@ export class Device { } /** - * Gets the visible action on this device with the specified {@link id}. - * @param deviceId Identifier of the action to find. - * @returns The visible action; otherwise `undefined`. + * Name of the device, as specified by the user in the Stream Deck application. + * @returns Name of the device. */ - public getActionById(id: string): DialAction | KeyAction | KeyInMultiAction | undefined { - return this.#actions.get(id); + public get name(): string { + return this.#info.name; } /** - * Adds the specified action to the underlying collection of visible actions for the device. - * @param ev The action's appearance event. + * Number of action slots, excluding dials / touchscreens, available to the device. + * @returns Size of the device. */ - #addAction(ev: WillAppear): void { - const context: ActionContext = { - device: this, - id: ev.context, - manifestId: ev.action - }; - - // Dial. - if (ev.payload.controller === "Encoder") { - this.#actions.set( - ev.context, - new DialAction({ - ...context, - coordinates: Object.freeze(ev.payload.coordinates) - }) - ); - - return; - } - - // Key - if (!ev.payload.isInMultiAction) { - this.#actions.set( - ev.context, - new KeyAction({ - ...context, - coordinates: Object.freeze(ev.payload.coordinates) - }) - ); - - return; - } + public get size(): Size { + return this.#info.size; + } - // Multi-action key - this.#actions.set(ev.context, new KeyInMultiAction(context)); + /** + * Type of the device that was connected, e.g. Stream Deck +, Stream Deck Pedal, etc. See {@link DeviceType}. + * @returns Type of the device. + */ + public get type(): DeviceType { + return this.#info.type; } } diff --git a/src/plugin/devices/index.ts b/src/plugin/devices/index.ts index f1baa01f..da6fab3a 100644 --- a/src/plugin/devices/index.ts +++ b/src/plugin/devices/index.ts @@ -1,5 +1,6 @@ import type { IDisposable } from "../../common/disposable"; import { Enumerable } from "../../common/enumerable"; +import { initializeStore } from "../actions/store"; import { connection } from "../connection"; import { DeviceDidConnectEvent, DeviceDidDisconnectEvent, DeviceEvent } from "../events"; import { Device } from "./device"; @@ -16,7 +17,7 @@ class DeviceCollection extends Enumerable { constructor() { super(__devices); - // Add the devices based on the registration parameters. + // Add the devices from registration parameters. connection.once("connected", (info) => { info.devices.forEach((dev) => __devices.set(dev.id, new Device(dev.id, dev, false))); }); @@ -62,4 +63,7 @@ class DeviceCollection extends Enumerable { */ export const devices = new DeviceCollection(); +// Initializes the action store. +initializeStore(devices); + export { Device, type DeviceCollection }; diff --git a/src/plugin/settings.ts b/src/plugin/settings.ts index 0db2dd36..4660da83 100644 --- a/src/plugin/settings.ts +++ b/src/plugin/settings.ts @@ -3,8 +3,8 @@ import type { IDisposable } from "../common/disposable"; import { ActionEvent } from "../common/events"; import type { JsonObject } from "../common/json"; import { Action } from "./actions/action"; +import { actionStore } from "./actions/store"; import { connection } from "./connection"; -import { devices } from "./devices"; import { DidReceiveGlobalSettingsEvent, DidReceiveSettingsEvent } from "./events"; /** @@ -40,7 +40,7 @@ export function onDidReceiveGlobalSettings(li */ export function onDidReceiveSettings(listener: (ev: DidReceiveSettingsEvent) => void): IDisposable { return connection.disposableOn("didReceiveSettings", (ev: DidReceiveSettings) => { - const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + const action = actionStore.getActionById(ev.context); if (action) { listener(new ActionEvent(action, ev)); } diff --git a/src/plugin/ui/controller.ts b/src/plugin/ui/controller.ts index 21d5cf12..451ba9b3 100644 --- a/src/plugin/ui/controller.ts +++ b/src/plugin/ui/controller.ts @@ -4,8 +4,8 @@ import type { IDisposable } from "../../common/disposable"; import type { JsonObject, JsonValue } from "../../common/json"; import { PUBLIC_PATH_PREFIX, type RouteConfiguration } from "../../common/messaging"; import { Action } from "../actions/action"; +import { actionStore } from "../actions/store"; import { connection } from "../connection"; -import { devices } from "../devices"; import { ActionWithoutPayloadEvent, SendToPluginEvent, type PropertyInspectorDidAppearEvent, type PropertyInspectorDidDisappearEvent } from "../events"; import { type MessageHandler } from "./message"; import { type PropertyInspector } from "./property-inspector"; @@ -31,7 +31,7 @@ class UIController { */ public onDidAppear(listener: (ev: PropertyInspectorDidAppearEvent) => void): IDisposable { return connection.disposableOn("propertyInspectorDidAppear", (ev) => { - const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + const action = actionStore.getActionById(ev.context); if (action) { listener(new ActionWithoutPayloadEvent(action, ev)); } @@ -46,7 +46,7 @@ class UIController { */ public onDidDisappear(listener: (ev: PropertyInspectorDidDisappearEvent) => void): IDisposable { return connection.disposableOn("propertyInspectorDidDisappear", (ev) => { - const action = devices.getDeviceById(ev.device)?.getActionById(ev.context); + const action = actionStore.getActionById(ev.context); if (action) { listener(new ActionWithoutPayloadEvent(action, ev)); } @@ -66,13 +66,9 @@ class UIController { listener: (ev: SendToPluginEvent) => void ): IDisposable { return router.disposableOn("unhandledMessage", (ev) => { - // Send to plugin doesn't include the device. - for (const device of devices) { - const action = device.getActionById(ev.context); - if (action) { - listener(new SendToPluginEvent(action, ev as DidReceivePropertyInspectorMessage)); - return; - } + const action = actionStore.getActionById(ev.context); + if (action) { + listener(new SendToPluginEvent(action, ev as DidReceivePropertyInspectorMessage)); } }); } diff --git a/src/plugin/ui/property-inspector.ts b/src/plugin/ui/property-inspector.ts index 06584f37..1e41db39 100644 --- a/src/plugin/ui/property-inspector.ts +++ b/src/plugin/ui/property-inspector.ts @@ -7,8 +7,8 @@ import type { DialAction } from "../actions/dial"; import type { KeyAction } from "../actions/key"; import type { KeyInMultiAction } from "../actions/multi"; import type { SingletonAction } from "../actions/singleton-action"; +import { actionStore } from "../actions/store"; import { connection } from "../connection"; -import { devices } from "../devices"; /** * Property inspector providing information about its context, and functions for sending and fetching messages. @@ -28,7 +28,7 @@ export class PropertyInspector implements Pick, "fetch"> private readonly router: MessageGateway, source: ActionIdentifier & DeviceIdentifier ) { - this.action = devices.getDeviceById(source.device)!.getActionById(source.context)!; + this.action = actionStore.getActionById(source.context)!; } /** From 6185074fd6256862169726c45b930eeff3275448 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sun, 15 Sep 2024 16:01:20 +0100 Subject: [PATCH 13/29] feat: add type-checking helpers --- src/plugin/actions/__tests__/multi.test.ts | 26 +++++++------- src/plugin/actions/action.ts | 40 +++++++++++++++++++++- src/plugin/actions/dial.ts | 9 ++++- src/plugin/actions/index.ts | 17 ++++----- src/plugin/actions/key.ts | 9 ++++- src/plugin/actions/multi.ts | 11 ++++-- src/plugin/actions/store.ts | 12 +++---- src/plugin/devices/device.ts | 4 +-- src/plugin/events/index.ts | 14 ++++---- src/plugin/index.ts | 2 +- src/plugin/ui/property-inspector.ts | 4 +-- 11 files changed, 102 insertions(+), 46 deletions(-) diff --git a/src/plugin/actions/__tests__/multi.test.ts b/src/plugin/actions/__tests__/multi.test.ts index bd0c9cfe..0f291969 100644 --- a/src/plugin/actions/__tests__/multi.test.ts +++ b/src/plugin/actions/__tests__/multi.test.ts @@ -1,7 +1,7 @@ import { type SetState } from "../../../api"; import { connection } from "../../connection"; import { Action, type ActionContext } from "../action"; -import { KeyInMultiAction } from "../multi"; +import { MultiActionKey } from "../multi"; jest.mock("../../logging"); jest.mock("../../manifest"); @@ -9,7 +9,7 @@ jest.mock("../../connection"); describe("KeyMultiAction", () => { /** - * Asserts the constructor of {@link KeyInMultiAction} sets the {@link KeyInMultiAction.manifestId} and {@link KeyInMultiAction.id}. + * Asserts the constructor of {@link MultiActionKey} sets the {@link MultiActionKey.manifestId} and {@link MultiActionKey.id}. */ it("constructor sets manifestId and id", () => { // Arrange. @@ -23,20 +23,20 @@ describe("KeyMultiAction", () => { }; // Act. - const keyInMultiAction = new KeyInMultiAction(context); + const multiActionKey = new MultiActionKey(context); // Assert. - expect(keyInMultiAction.device).toBe(context.device); - expect(keyInMultiAction.id).toBe(context.id); - expect(keyInMultiAction.manifestId).toBe(context.manifestId); + expect(multiActionKey.device).toBe(context.device); + expect(multiActionKey.id).toBe(context.id); + expect(multiActionKey.manifestId).toBe(context.manifestId); }); /** - * Asserts the inheritance of {@link KeyInMultiAction}. + * Asserts the inheritance of {@link MultiActionKey}. */ it("inherits shared methods", () => { // Arrange, act. - const keyInMultiAction = new KeyInMultiAction({ + const multiActionKey = new MultiActionKey({ device: { id: "DEV123", isConnected: false @@ -46,11 +46,11 @@ describe("KeyMultiAction", () => { }); // Assert. - expect(keyInMultiAction).toBeInstanceOf(Action); + expect(multiActionKey).toBeInstanceOf(Action); }); describe("sending", () => { - const keyInMultiAction = new KeyInMultiAction({ + const multiActionKey = new MultiActionKey({ device: { id: "DEV123", isConnected: false @@ -60,16 +60,16 @@ describe("KeyMultiAction", () => { }); /** - * Asserts {@link KeyInMultiAction.setState} forwards the command to the {@link connection}. + * Asserts {@link MultiActionKey.setState} forwards the command to the {@link connection}. */ it("setState", async () => { // Arrange, act. - await keyInMultiAction.setState(1); + await multiActionKey.setState(1); // Assert. expect(connection.send).toHaveBeenCalledTimes(1); expect(connection.send).toHaveBeenCalledWith<[SetState]>({ - context: keyInMultiAction.id, + context: multiActionKey.id, event: "setState", payload: { state: 1 diff --git a/src/plugin/actions/action.ts b/src/plugin/actions/action.ts index 6adb853e..621fd8e4 100644 --- a/src/plugin/actions/action.ts +++ b/src/plugin/actions/action.ts @@ -4,13 +4,16 @@ import type { JsonObject, JsonValue } from "../../common/json"; import type { KeyOf } from "../../common/utils"; import { connection } from "../connection"; import type { Device } from "../devices"; +import type { DialAction } from "./dial"; +import type { KeyAction } from "./key"; +import type { MultiActionKey } from "./multi"; import type { SingletonAction } from "./singleton-action"; /** * Provides a contextualized instance of an {@link Action}, allowing for direct communication with the Stream Deck. * @template T The type of settings associated with the action. */ -export class Action implements ActionContext { +export abstract class Action implements ActionContext { /** * The action context. */ @@ -45,6 +48,12 @@ export class Action implements ActionContext return this.#context.manifestId; } + /** + * Underlying type of the action. + * @returns The type. + */ + protected abstract get type(): ActionType; + /** * Gets the settings associated this action instance. * @template U The type of settings associated with the action. @@ -67,6 +76,30 @@ export class Action implements ActionContext }); } + /** + * Determines whether this instance is a dial action. + * @returns `true` when this instance is a dial; otherwise `false`. + */ + public isDial(): this is DialAction { + return this.type === "Dial"; + } + + /** + * Determines whether this instance is a key action. + * @returns `true` when this instance is a key; otherwise `false`. + */ + public isKey(): this is KeyAction { + return this.type === "Key"; + } + + /** + * Determines whether this instance is a multi-action key. + * @returns `true` when this instance is a multi-action key; otherwise `false`. + */ + public isMultiActionKey(): this is MultiActionKey { + return this.type === "MultiActionKey"; + } + /** * Sends the {@link payload} to the property inspector. The plugin can also receive information from the property inspector via {@link streamDeck.ui.onSendToPlugin} and {@link SingletonAction.onSendToPlugin} * allowing for bi-directional communication. @@ -106,6 +139,11 @@ export type ImageOptions = Omit, "image">; */ export type TitleOptions = Omit, "title">; +/** + * Action type, for example dial or key. + */ +export type ActionType = "Dial" | "Key" | "MultiActionKey"; + /** * Provides context information for an instance of an action. */ diff --git a/src/plugin/actions/dial.ts b/src/plugin/actions/dial.ts index f2671532..f37c6fd2 100644 --- a/src/plugin/actions/dial.ts +++ b/src/plugin/actions/dial.ts @@ -2,7 +2,7 @@ import type { Coordinates, FeedbackPayload, SetTriggerDescription } from "../../ import type { JsonObject } from "../../common/json"; import type { KeyOf } from "../../common/utils"; import { connection } from "../connection"; -import { Action, type CoordinatedActionContext, type ImageOptions, type TitleOptions } from "./action"; +import { Action, type ActionType, type CoordinatedActionContext, type ImageOptions, type TitleOptions } from "./action"; /** * Provides a contextualized instance of a dial action. @@ -30,6 +30,13 @@ export class DialAction extends Action imp return this.#context.coordinates; } + /** + * @inheritdoc + */ + protected override get type(): ActionType { + return "Dial"; + } + /** * Sets the feedback for the current layout associated with this action instance, allowing for the visual items to be updated. Layouts are a powerful way to provide dynamic information * to users, and can be assigned in the manifest, or dynamically via {@link Action.setFeedbackLayout}. diff --git a/src/plugin/actions/index.ts b/src/plugin/actions/index.ts index 3a4d322b..44dae02d 100644 --- a/src/plugin/actions/index.ts +++ b/src/plugin/actions/index.ts @@ -19,9 +19,6 @@ import { getManifest } from "../manifest"; import { onDidReceiveSettings } from "../settings"; import { ui } from "../ui"; import { Action, type ActionContext } from "./action"; -import { DialAction } from "./dial"; -import { KeyAction } from "./key"; -import { KeyInMultiAction } from "./multi"; import type { SingletonAction } from "./singleton-action"; import { actionStore } from "./store"; @@ -38,7 +35,7 @@ const manifest = getManifest(); export function onDialDown(listener: (ev: DialDownEvent) => void): IDisposable { return connection.disposableOn("dialDown", (ev: DialDown) => { const action = actionStore.getActionById(ev.context); - if (action && action instanceof DialAction) { + if (action?.isDial()) { listener(new ActionEvent(action, ev)); } }); @@ -53,7 +50,7 @@ export function onDialDown(listener: (ev: Dia export function onDialRotate(listener: (ev: DialRotateEvent) => void): IDisposable { return connection.disposableOn("dialRotate", (ev: DialRotate) => { const action = actionStore.getActionById(ev.context); - if (action && action instanceof DialAction) { + if (action?.isDial()) { listener(new ActionEvent(action, ev)); } }); @@ -70,7 +67,7 @@ export function onDialRotate(listener: (ev: D export function onDialUp(listener: (ev: DialUpEvent) => void): IDisposable { return connection.disposableOn("dialUp", (ev: DialUp) => { const action = actionStore.getActionById(ev.context); - if (action && action instanceof DialAction) { + if (action?.isDial()) { listener(new ActionEvent(action, ev)); } }); @@ -87,7 +84,7 @@ export function onDialUp(listener: (ev: DialU export function onKeyDown(listener: (ev: KeyDownEvent) => void): IDisposable { return connection.disposableOn("keyDown", (ev: KeyDown) => { const action = actionStore.getActionById(ev.context); - if (action && (action instanceof KeyAction || action instanceof KeyInMultiAction)) { + if (action?.isKey() || action?.isMultiActionKey()) { listener(new ActionEvent(action, ev)); } }); @@ -104,7 +101,7 @@ export function onKeyDown(listener: (ev: KeyD export function onKeyUp(listener: (ev: KeyUpEvent) => void): IDisposable { return connection.disposableOn("keyUp", (ev: KeyUp) => { const action = actionStore.getActionById(ev.context); - if (action && (action instanceof KeyAction || action instanceof KeyInMultiAction)) { + if (action?.isKey() || action?.isMultiActionKey()) { listener(new ActionEvent(action, ev)); } }); @@ -119,7 +116,7 @@ export function onKeyUp(listener: (ev: KeyUpE export function onTitleParametersDidChange(listener: (ev: TitleParametersDidChangeEvent) => void): IDisposable { return connection.disposableOn("titleParametersDidChange", (ev: TitleParametersDidChange) => { const action = actionStore.getActionById(ev.context); - if (action && action instanceof KeyAction) { + if (action?.isKey()) { listener(new ActionEvent(action, ev)); } }); @@ -134,7 +131,7 @@ export function onTitleParametersDidChange(li export function onTouchTap(listener: (ev: TouchTapEvent) => void): IDisposable { return connection.disposableOn("touchTap", (ev: TouchTap) => { const action = actionStore.getActionById(ev.context); - if (action && action instanceof DialAction) { + if (action?.isDial()) { listener(new ActionEvent(action, ev)); } }); diff --git a/src/plugin/actions/key.ts b/src/plugin/actions/key.ts index 31031091..ab0261f5 100644 --- a/src/plugin/actions/key.ts +++ b/src/plugin/actions/key.ts @@ -1,7 +1,7 @@ import type { Coordinates, State } from "../../api"; import type { JsonObject } from "../../common/json"; import { connection } from "../connection"; -import { Action, type CoordinatedActionContext, type ImageOptions, type TitleOptions } from "./action"; +import { Action, type ActionType, type CoordinatedActionContext, type ImageOptions, type TitleOptions } from "./action"; /** * Provides a contextualized instance of a key action. @@ -29,6 +29,13 @@ export class KeyAction extends Action impl return this.#context.coordinates; } + /** + * @inheritdoc + */ + protected override get type(): ActionType { + return "Key"; + } + /** * Sets the {@link image} to be display for this action instance. * diff --git a/src/plugin/actions/multi.ts b/src/plugin/actions/multi.ts index 14ed3ff2..7d4c3ad2 100644 --- a/src/plugin/actions/multi.ts +++ b/src/plugin/actions/multi.ts @@ -1,13 +1,13 @@ import type { State } from "../../api"; import type { JsonObject } from "../../common/json"; import { connection } from "../connection"; -import { Action, type ActionContext } from "./action"; +import { Action, type ActionContext, type ActionType } from "./action"; /** * Provides a contextualized instance of a key action, within a multi-action. * @template T The type of settings associated with the action. */ -export class KeyInMultiAction extends Action { +export class MultiActionKey extends Action { /** * Initializes a new instance of the {@see KeyMultiAction} class. * @param context Action context. @@ -16,6 +16,13 @@ export class KeyInMultiAction extends Action< super(context); } + /** + * @inheritdoc + */ + protected override get type(): ActionType { + return "MultiActionKey"; + } + /** * Sets the current {@link state} of this action instance; only applies to actions that have multiple states defined within the manifest. * @param state State to set; this be either 0, or 1. diff --git a/src/plugin/actions/store.ts b/src/plugin/actions/store.ts index c7292f05..7f1716f3 100644 --- a/src/plugin/actions/store.ts +++ b/src/plugin/actions/store.ts @@ -6,9 +6,9 @@ import type { DeviceCollection } from "../devices"; import { type ActionContext } from "./action"; import { DialAction } from "./dial"; import { KeyAction } from "./key"; -import { KeyInMultiAction } from "./multi"; +import { MultiActionKey } from "./multi"; -const __actions = new Map(); +const __actions = new Map(); let __devices: DeviceCollection | undefined; // Adds the action to the store. @@ -35,7 +35,7 @@ connection.prependListener("willDisappear", (ev) => __actions.delete(ev.context) * @param context Context of the action. * @returns The new action. */ -function create(ev: WillAppear, context: ActionContext): DialAction | KeyAction | KeyInMultiAction { +function create(ev: WillAppear, context: ActionContext): DialAction | KeyAction | MultiActionKey { // Dial. if (ev.payload.controller === "Encoder") { return new DialAction({ @@ -46,7 +46,7 @@ function create(ev: WillAppear, context: ActionContext): DialAction // Multi-action key if (ev.payload.isInMultiAction) { - return new KeyInMultiAction(context); + return new MultiActionKey(context); } // Key action. @@ -71,7 +71,7 @@ export function initializeStore(devices: DeviceCollection): void { /** * Provides a store of visible actions. */ -export class ActionStore extends Enumerable { +export class ActionStore extends Enumerable { /** * Initializes a new instance of the {@link ActionStore} class. */ @@ -84,7 +84,7 @@ export class ActionStore extends Enumerable { + public get actions(): IterableIterator { return actionStore.filter((a) => a.device.id === this.id); } diff --git a/src/plugin/events/index.ts b/src/plugin/events/index.ts index f4583f44..4f92f892 100644 --- a/src/plugin/events/index.ts +++ b/src/plugin/events/index.ts @@ -22,7 +22,7 @@ import type { JsonObject } from "../../common/json"; import type { ActionContext } from "../actions/action"; import type { DialAction } from "../actions/dial"; import type { KeyAction } from "../actions/key"; -import type { KeyInMultiAction } from "../actions/multi"; +import type { MultiActionKey } from "../actions/multi"; import type { Device } from "../devices"; import { ApplicationEvent } from "./application-event"; import { DeviceEvent } from "./device-event"; @@ -72,25 +72,25 @@ export type DialUpEvent = ActionEvent */ export type DidReceiveSettingsEvent = ActionEvent< DidReceiveSettings, - DialAction | KeyAction | KeyInMultiAction + DialAction | KeyAction | MultiActionKey >; /** * Event information received from Stream Deck when a key is pressed down. */ -export type KeyDownEvent = ActionEvent, KeyAction | KeyInMultiAction>; +export type KeyDownEvent = ActionEvent, KeyAction | MultiActionKey>; /** * Event information received from Stream Deck when a pressed key is release. */ -export type KeyUpEvent = ActionEvent, KeyAction | KeyInMultiAction>; +export type KeyUpEvent = ActionEvent, KeyAction | MultiActionKey>; /** * Event information received from Stream Deck when the property inspector appears. */ export type PropertyInspectorDidAppearEvent = ActionWithoutPayloadEvent< PropertyInspectorDidAppear, - DialAction | KeyAction | KeyInMultiAction + DialAction | KeyAction | MultiActionKey >; /** @@ -98,7 +98,7 @@ export type PropertyInspectorDidAppearEvent = ActionWithoutPayloadEvent< PropertyInspectorDidDisappear, - DialAction | KeyAction | KeyInMultiAction + DialAction | KeyAction | MultiActionKey >; /** @@ -124,7 +124,7 @@ export type TouchTapEvent = ActionEve */ export type WillAppearEvent = ActionEvent< WillAppear, - DialAction | KeyAction | KeyInMultiAction + DialAction | KeyAction | MultiActionKey >; /** diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 54462f22..ff3a2769 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -40,7 +40,7 @@ export { Action, ImageOptions, TitleOptions } from "./actions/action"; export { action } from "./actions/decorators"; export { DialAction, TriggerDescriptionOptions } from "./actions/dial"; export { KeyAction } from "./actions/key"; -export { KeyInMultiAction } from "./actions/multi"; +export { MultiActionKey } from "./actions/multi"; export { SingletonAction } from "./actions/singleton-action"; export { type Device } from "./devices"; export * from "./events"; diff --git a/src/plugin/ui/property-inspector.ts b/src/plugin/ui/property-inspector.ts index 1e41db39..201812b5 100644 --- a/src/plugin/ui/property-inspector.ts +++ b/src/plugin/ui/property-inspector.ts @@ -5,7 +5,7 @@ import { PUBLIC_PATH_PREFIX, type MessageGateway, type MessageRequestOptions, ty import type { Action } from "../actions/action"; import type { DialAction } from "../actions/dial"; import type { KeyAction } from "../actions/key"; -import type { KeyInMultiAction } from "../actions/multi"; +import type { MultiActionKey } from "../actions/multi"; import type { SingletonAction } from "../actions/singleton-action"; import { actionStore } from "../actions/store"; import { connection } from "../connection"; @@ -17,7 +17,7 @@ export class PropertyInspector implements Pick, "fetch"> /** * Action associated with the property inspector */ - public readonly action: DialAction | KeyAction | KeyInMultiAction; + public readonly action: DialAction | KeyAction | MultiActionKey; /** * Initializes a new instance of the {@link PropertyInspector} class. From 597cd54effd7b6f40d692da34acf336861ee44a1 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sun, 15 Sep 2024 17:12:52 +0100 Subject: [PATCH 14/29] test: fix tests --- src/plugin/__tests__/settings.test.ts | 10 +- src/plugin/actions/__mocks__/store.ts | 36 ++++ src/plugin/actions/__tests__/index.test.ts | 181 +++++++++++---------- src/plugin/devices/device.ts | 6 +- src/plugin/ui/__tests__/controller.test.ts | 17 +- src/plugin/ui/router.ts | 2 +- 6 files changed, 148 insertions(+), 104 deletions(-) create mode 100644 src/plugin/actions/__mocks__/store.ts diff --git a/src/plugin/__tests__/settings.test.ts b/src/plugin/__tests__/settings.test.ts index 2d558821..9d402bda 100644 --- a/src/plugin/__tests__/settings.test.ts +++ b/src/plugin/__tests__/settings.test.ts @@ -1,6 +1,7 @@ -import type { DidReceiveGlobalSettings, DidReceiveSettings, GetGlobalSettings, SetGlobalSettings } from "../../api"; +import { type DidReceiveGlobalSettings, type DidReceiveSettings, type GetGlobalSettings, type SetGlobalSettings } from "../../api"; import { type Settings } from "../../api/__mocks__/events"; -import { Action } from "../actions/action"; + +import { actionStore } from "../actions/store"; import { connection } from "../connection"; import type { DidReceiveGlobalSettingsEvent, DidReceiveSettingsEvent } from "../events"; import { getGlobalSettings, onDidReceiveGlobalSettings, onDidReceiveSettings, setGlobalSettings } from "../settings"; @@ -8,6 +9,7 @@ import { getGlobalSettings, onDidReceiveGlobalSettings, onDidReceiveSettings, se jest.mock("../connection"); jest.mock("../logging"); jest.mock("../manifest"); +jest.mock("../actions/store"); describe("settings", () => { describe("sending", () => { @@ -110,7 +112,7 @@ describe("settings", () => { const listener = jest.fn(); const ev = { action: "com.elgato.test.one", - context: "context123", + context: "key123", device: "device123", event: "didReceiveSettings", payload: { @@ -133,7 +135,7 @@ describe("settings", () => { // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[DidReceiveSettingsEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context)!, deviceId: ev.device, payload: ev.payload, type: "didReceiveSettings" diff --git a/src/plugin/actions/__mocks__/store.ts b/src/plugin/actions/__mocks__/store.ts new file mode 100644 index 00000000..885034a4 --- /dev/null +++ b/src/plugin/actions/__mocks__/store.ts @@ -0,0 +1,36 @@ +import { DialAction } from "../dial"; +import { KeyAction } from "../key"; + +const key = new KeyAction({ + id: "key123", + manifestId: "com.elgato.test.action", + coordinates: { + column: 1, + row: 1 + }, + device: undefined! +}); + +const dial = new DialAction({ + id: "dial123", + manifestId: "com.elgato.test.action", + coordinates: { + column: 1, + row: 1 + }, + device: undefined! +}); + +export const actionStore = { + getActionById: jest.fn().mockImplementation((id) => { + if (id === key.id) { + return key; + } else if (id === dial.id) { + return dial; + } + + return undefined; + }) +}; + +export const initializeStore = jest.fn(); diff --git a/src/plugin/actions/__tests__/index.test.ts b/src/plugin/actions/__tests__/index.test.ts index 0afc9205..f68ba850 100644 --- a/src/plugin/actions/__tests__/index.test.ts +++ b/src/plugin/actions/__tests__/index.test.ts @@ -1,31 +1,22 @@ +import { onDialDown, onDialRotate, onDialUp, onKeyDown, onKeyUp, onTitleParametersDidChange, onTouchTap, onWillAppear, onWillDisappear, registerAction } from ".."; import { - createController, - onDialDown, - onDialRotate, - onDialUp, - onKeyDown, - onKeyUp, - onTitleParametersDidChange, - onTouchTap, - onWillAppear, - onWillDisappear, - registerAction -} from ".."; -import type { - DialDownEvent, - DialRotateEvent, - DialUpEvent, - DidReceiveSettingsEvent, - KeyDownEvent, - KeyUpEvent, - PropertyInspectorDidAppearEvent, - PropertyInspectorDidDisappearEvent, - SendToPluginEvent, - SingletonAction, - TitleParametersDidChangeEvent, - TouchTapEvent, - WillAppearEvent, - WillDisappearEvent + DeviceType, + type DialAction, + type DialDownEvent, + type DialRotateEvent, + type DialUpEvent, + type DidReceiveSettingsEvent, + type KeyAction, + type KeyDownEvent, + type KeyUpEvent, + type PropertyInspectorDidAppearEvent, + type PropertyInspectorDidDisappearEvent, + type SendToPluginEvent, + type SingletonAction, + type TitleParametersDidChangeEvent, + type TouchTapEvent, + type WillAppearEvent, + type WillDisappearEvent } from "../.."; import type { DialDown, @@ -44,29 +35,35 @@ import type { } from "../../../api"; import { Settings } from "../../../api/__mocks__/events"; import { connection } from "../../connection"; +import { devices } from "../../devices"; +import { Device } from "../../devices/device"; import { getManifest } from "../../manifest"; import type { onDidReceiveSettings } from "../../settings"; import type { UIController } from "../../ui"; -import { Action } from "../action"; +import { actionStore } from "../store"; +jest.mock("../store"); +jest.mock("../../devices"); jest.mock("../../connection"); jest.mock("../../logging"); jest.mock("../../manifest"); describe("actions", () => { - /** - * Asserts {@link createController} initializes a new action. - */ - it("creates controllers", () => { - // Arrange, act. - const action = createController("abc123"); - - // Assert. - expect(action).not.toBeUndefined(); - expect(action).toBeInstanceOf(Action); - expect(action.id).toBe("abc123"); - // @ts-expect-error: manifestId is omitted, and should be an empty string. - expect(action.manifestId).toBe(""); + const device = new Device( + "device123", + { + name: "Device 1", + size: { + columns: 5, + rows: 3 + }, + type: DeviceType.StreamDeck + }, + false + ); + + beforeAll(() => { + jest.spyOn(devices, "getDeviceById").mockReturnValue(device); }); describe("event emitters", () => { @@ -78,7 +75,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: "com.elgato.test.one", - context: "context123", + context: "dial123", // Mocked in actionStore device: "device123", event: "dialDown", payload: { @@ -100,7 +97,7 @@ describe("actions", () => { // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[DialDownEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as DialAction, deviceId: ev.device, payload: ev.payload, type: "dialDown" @@ -122,7 +119,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: "com.elgato.test.one", - context: "context123", + context: "dial123", // Mocked in actionStore device: "device123", event: "dialRotate", payload: { @@ -146,7 +143,7 @@ describe("actions", () => { // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[DialRotateEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as DialAction, deviceId: ev.device, payload: ev.payload, type: "dialRotate" @@ -168,7 +165,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: "com.elgato.test.one", - context: "context123", + context: "dial123", // Mocked in actionStore device: "device123", event: "dialUp", payload: { @@ -190,7 +187,7 @@ describe("actions", () => { // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[DialUpEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as DialAction, deviceId: ev.device, payload: ev.payload, type: "dialUp" @@ -212,7 +209,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: "com.elgato.test.one", - context: "context123", + context: "key123", // Mocked in actionStore device: "device123", event: "keyDown", payload: { @@ -235,7 +232,7 @@ describe("actions", () => { // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[KeyDownEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as KeyAction, deviceId: ev.device, payload: ev.payload, type: "keyDown" @@ -257,7 +254,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: "com.elgato.test.one", - context: "context123", + context: "key123", // Mocked in actionStore device: "device123", event: "keyUp", payload: { @@ -280,7 +277,7 @@ describe("actions", () => { // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[KeyUpEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as KeyAction, deviceId: ev.device, payload: ev.payload, type: "keyUp" @@ -302,7 +299,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: "com.elgato.test.one", - context: "context123", + context: "key123", // Mocked in actionStore device: "device123", event: "titleParametersDidChange", payload: { @@ -334,7 +331,7 @@ describe("actions", () => { // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[TitleParametersDidChangeEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as KeyAction, deviceId: ev.device, payload: ev.payload, type: "titleParametersDidChange" @@ -356,7 +353,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: "com.elgato.test.one", - context: "context123", + context: "dial123", // Mocked in actionStore device: "device123", event: "touchTap", payload: { @@ -380,7 +377,7 @@ describe("actions", () => { // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[TouchTapEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as DialAction, deviceId: ev.device, payload: ev.payload, type: "touchTap" @@ -402,7 +399,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: "com.elgato.test.one", - context: "context123", + context: "key123", // Mocked in actionStore. device: "device123", event: "willAppear", payload: { @@ -425,7 +422,7 @@ describe("actions", () => { // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[WillAppearEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as KeyAction, deviceId: ev.device, payload: ev.payload, type: "willAppear" @@ -442,7 +439,7 @@ describe("actions", () => { /** * Asserts {@link onWillDisappear} is invoked when `willDisappear` is emitted. */ - it("receives onWillAppear", () => { + it("receives onWillDisappear", () => { // Arrange. const listener = jest.fn(); const ev = { @@ -466,11 +463,14 @@ describe("actions", () => { // Act (emit). const disposable = onWillDisappear(listener); connection.emit("willDisappear", ev); - // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[WillDisappearEvent]>({ - action: new Action(ev), + action: { + device, + id: ev.context, + manifestId: ev.action + }, deviceId: ev.device, payload: ev.payload, type: "willDisappear" @@ -543,10 +543,10 @@ describe("actions", () => { */ it("routes onDialDown", () => { // Arrange. - const listener = jest.fn(); + const listener = jest.fn().mockImplementation(() => console.log("Hello from the other side")); const ev = { action: manifestId, - context: "context123", + context: "dial123", // Mocked in actionStore. device: "device123", event: "dialDown", payload: { @@ -567,12 +567,13 @@ describe("actions", () => { onDialDown: listener }); + console.log("Foo"); connection.emit("dialDown", ev); // Assert. expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[DialDownEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as DialAction, deviceId: ev.device, payload: ev.payload, type: "dialDown" @@ -587,7 +588,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: manifestId, - context: "context123", + context: "dial123", // Mocked in actionStore device: "device123", event: "dialRotate", payload: { @@ -615,7 +616,7 @@ describe("actions", () => { // Assert. expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[DialRotateEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as DialAction, deviceId: ev.device, payload: ev.payload, type: "dialRotate" @@ -630,7 +631,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: manifestId, - context: "context123", + context: "dial123", // Mocked in actionStore device: "device123", event: "dialUp", payload: { @@ -656,7 +657,7 @@ describe("actions", () => { // Assert. expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[DialUpEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as DialAction, deviceId: ev.device, payload: ev.payload, type: "dialUp" @@ -671,7 +672,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: manifestId, - context: "context123", + context: "key123", // Mocked in actionStore event: "sendToPlugin", payload: { name: "Hello world" @@ -689,7 +690,7 @@ describe("actions", () => { // Assert. expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[SendToPluginEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as KeyAction, payload: { name: "Hello world" }, @@ -705,7 +706,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: manifestId, - context: "context123", + context: "key123", // Mocked in actionStore device: "device123", event: "didReceiveSettings", payload: { @@ -732,7 +733,7 @@ describe("actions", () => { // Assert. expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[DidReceiveSettingsEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as KeyAction, deviceId: ev.device, payload: ev.payload, type: "didReceiveSettings" @@ -747,7 +748,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: manifestId, - context: "context123", + context: "key123", // Mocked in actionStore device: "device123", event: "keyDown", payload: { @@ -774,7 +775,7 @@ describe("actions", () => { // Assert. expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[KeyDownEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as KeyAction, deviceId: ev.device, payload: ev.payload, type: "keyDown" @@ -789,7 +790,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: manifestId, - context: "context123", + context: "key123", // Mocked in actionStore device: "device123", event: "keyUp", payload: { @@ -816,7 +817,7 @@ describe("actions", () => { // Assert. expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[KeyUpEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as KeyAction, deviceId: ev.device, payload: ev.payload, type: "keyUp" @@ -831,7 +832,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: manifestId, - context: "context123", + context: "key123", // Mocked in actionStore device: "device123", event: "propertyInspectorDidAppear" } satisfies PropertyInspectorDidAppear; @@ -847,7 +848,7 @@ describe("actions", () => { // Assert. expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[PropertyInspectorDidAppearEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as KeyAction, deviceId: ev.device, type: "propertyInspectorDidAppear" }); @@ -861,7 +862,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: manifestId, - context: "context123", + context: "key123", // Mocked in actionStore device: "device123", event: "propertyInspectorDidDisappear" } satisfies PropertyInspectorDidDisappear; @@ -877,7 +878,7 @@ describe("actions", () => { // Assert. expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[PropertyInspectorDidDisappearEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as KeyAction, deviceId: ev.device, type: "propertyInspectorDidDisappear" }); @@ -891,7 +892,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: manifestId, - context: "context123", + context: "key123", // Mocked in actionStore device: "device123", event: "titleParametersDidChange", payload: { @@ -927,7 +928,7 @@ describe("actions", () => { // Assert. expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[TitleParametersDidChangeEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as KeyAction, deviceId: ev.device, payload: ev.payload, type: "titleParametersDidChange" @@ -942,7 +943,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: manifestId, - context: "context123", + context: "dial123", // Mocked in actionStore device: "device123", event: "touchTap", payload: { @@ -970,7 +971,7 @@ describe("actions", () => { // Assert. expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[TouchTapEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as DialAction, deviceId: ev.device, payload: ev.payload, type: "touchTap" @@ -985,7 +986,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: manifestId, - context: "context123", + context: "key123", // Mocked in actionStore device: "device123", event: "willAppear", payload: { @@ -1012,7 +1013,7 @@ describe("actions", () => { // Assert. expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[WillAppearEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context) as KeyAction, deviceId: ev.device, payload: ev.payload, type: "willAppear" @@ -1022,12 +1023,12 @@ describe("actions", () => { /** * Asserts {@link onWillDisappear} is routed to the action when `willDisappear` is emitted. */ - it("routes onWillAppear", () => { + it("routes onWillDisappear", () => { // Arrange. const listener = jest.fn(); const ev = { action: manifestId, - context: "context123", + context: "key123", // Mocked in actionStore device: "device123", event: "willDisappear", payload: { @@ -1054,7 +1055,11 @@ describe("actions", () => { // Assert. expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[WillDisappearEvent]>({ - action: new Action(ev), + action: { + device, + id: ev.context, + manifestId: ev.action + }, deviceId: ev.device, payload: ev.payload, type: "willDisappear" diff --git a/src/plugin/devices/device.ts b/src/plugin/devices/device.ts index 96815e73..e40e5a0f 100644 --- a/src/plugin/devices/device.ts +++ b/src/plugin/devices/device.ts @@ -1,7 +1,7 @@ import type { DeviceInfo, DeviceType, Size } from "../../api"; -import { DialAction } from "../actions/dial"; -import { KeyAction } from "../actions/key"; -import { MultiActionKey } from "../actions/multi"; +import type { DialAction } from "../actions/dial"; +import type { KeyAction } from "../actions/key"; +import type { MultiActionKey } from "../actions/multi"; import { actionStore } from "../actions/store"; import { connection } from "../connection"; diff --git a/src/plugin/ui/__tests__/controller.test.ts b/src/plugin/ui/__tests__/controller.test.ts index e5bb42a3..ab5847b2 100644 --- a/src/plugin/ui/__tests__/controller.test.ts +++ b/src/plugin/ui/__tests__/controller.test.ts @@ -1,6 +1,6 @@ import type { DidReceivePropertyInspectorMessage, PropertyInspectorDidAppear, PropertyInspectorDidDisappear } from "../../../api"; import { Settings } from "../../../api/__mocks__/events"; -import { Action } from "../../actions/action"; +import { actionStore } from "../../actions/store"; import { connection } from "../../connection"; import { PropertyInspectorDidAppearEvent, SendToPluginEvent, type PropertyInspectorDidDisappearEvent } from "../../events"; import { ui } from "../controller"; @@ -8,6 +8,7 @@ import { PropertyInspector } from "../property-inspector"; import * as RouterModule from "../router"; jest.mock("../router"); +jest.mock("../../actions/store"); jest.mock("../../connection"); jest.mock("../../logging"); jest.mock("../../manifest"); @@ -20,7 +21,7 @@ describe("UIController", () => { // Arrange. const pi = new PropertyInspector(RouterModule.router, { action: "com.elgato.test.one", - context: "abc123", + context: "key123", // Mocked in actionStore device: "dev123" }); @@ -42,7 +43,7 @@ describe("UIController", () => { const listener = jest.fn(); const ev = { action: "com.elgato.test.one", - context: "context123", + context: "key123", // Mocked in actionStore. device: "device123", event: "propertyInspectorDidAppear" } satisfies PropertyInspectorDidAppear; @@ -54,7 +55,7 @@ describe("UIController", () => { // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[PropertyInspectorDidAppearEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context)!, deviceId: ev.device, type: "propertyInspectorDidAppear" }); @@ -75,7 +76,7 @@ describe("UIController", () => { const listener = jest.fn(); const ev = { action: "com.elgato.test.one", - context: "context123", + context: "key123", // Mocked in actionStore. device: "device123", event: "propertyInspectorDidDisappear" } satisfies PropertyInspectorDidDisappear; @@ -87,7 +88,7 @@ describe("UIController", () => { // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[PropertyInspectorDidDisappearEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context)!, deviceId: ev.device, type: "propertyInspectorDidDisappear" }); @@ -108,7 +109,7 @@ describe("UIController", () => { const listener = jest.fn(); const ev = { action: "com.elgato.test.one", - context: "context123", + context: "key123", // Mocked in actionStore. event: "sendToPlugin", payload: { name: "Hello world" @@ -122,7 +123,7 @@ describe("UIController", () => { // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[SendToPluginEvent]>({ - action: new Action(ev), + action: actionStore.getActionById(ev.context)!, payload: { name: "Hello world" }, diff --git a/src/plugin/ui/router.ts b/src/plugin/ui/router.ts index c79617ed..110f0ef2 100644 --- a/src/plugin/ui/router.ts +++ b/src/plugin/ui/router.ts @@ -43,7 +43,7 @@ const router = new MessageGateway( * @returns `true` when the event is related to the current property inspector. */ function isCurrent(ev: PropertyInspectorDidAppear | PropertyInspectorDidDisappear): boolean { - return current?.action.id === ev.context && current.action.manifestId === ev.action && current.action.device.id === ev.device; + return current?.action.id === ev.context; } /* From 20e921092229013f190e994bfca75794503c4538 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sun, 15 Sep 2024 17:18:03 +0100 Subject: [PATCH 15/29] test: fix tests --- src/plugin/devices/__tests__/index.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/plugin/devices/__tests__/index.test.ts b/src/plugin/devices/__tests__/index.test.ts index d3e5e91f..17c455ea 100644 --- a/src/plugin/devices/__tests__/index.test.ts +++ b/src/plugin/devices/__tests__/index.test.ts @@ -3,9 +3,10 @@ import type { DeviceDidConnectEvent, DeviceDidDisconnectEvent } from "../.."; import { DeviceType, type DeviceDidConnect, type DeviceDidDisconnect } from "../../../api"; import { type connection as Connection } from "../../connection"; -jest.mock("../connection"); -jest.mock("../logging"); -jest.mock("../manifest"); +jest.mock("../../actions/store"); +jest.mock("../../connection"); +jest.mock("../../logging"); +jest.mock("../../manifest"); describe("devices", () => { let connection!: typeof Connection; @@ -13,8 +14,8 @@ describe("devices", () => { beforeEach(async () => { jest.resetModules(); - ({ connection } = await require("../connection")); - ({ devices } = await require("../devices")); + ({ connection } = await require("../../connection")); + ({ devices } = await require("../")); }); /** From f5008b4a8dd36d7deb252a4dbf73d29ec9bbbc19 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sun, 15 Sep 2024 17:56:49 +0100 Subject: [PATCH 16/29] test: fix tests --- src/plugin/actions/__tests__/index.test.ts | 3 +- .../ui/__tests__/property-inspector.test.ts | 20 ++-- src/plugin/ui/__tests__/route.test.ts | 24 ++-- src/plugin/ui/__tests__/router.test.ts | 103 ++++++++++++------ src/plugin/ui/router.ts | 2 +- 5 files changed, 97 insertions(+), 55 deletions(-) diff --git a/src/plugin/actions/__tests__/index.test.ts b/src/plugin/actions/__tests__/index.test.ts index f68ba850..fd080100 100644 --- a/src/plugin/actions/__tests__/index.test.ts +++ b/src/plugin/actions/__tests__/index.test.ts @@ -543,7 +543,7 @@ describe("actions", () => { */ it("routes onDialDown", () => { // Arrange. - const listener = jest.fn().mockImplementation(() => console.log("Hello from the other side")); + const listener = jest.fn(); const ev = { action: manifestId, context: "dial123", // Mocked in actionStore. @@ -567,7 +567,6 @@ describe("actions", () => { onDialDown: listener }); - console.log("Foo"); connection.emit("dialDown", ev); // Assert. diff --git a/src/plugin/ui/__tests__/property-inspector.test.ts b/src/plugin/ui/__tests__/property-inspector.test.ts index d807c2bf..d0c283d0 100644 --- a/src/plugin/ui/__tests__/property-inspector.test.ts +++ b/src/plugin/ui/__tests__/property-inspector.test.ts @@ -1,10 +1,12 @@ import type { SendToPropertyInspector } from "../../../api"; import type { JsonValue } from "../../../common/json"; import type { MessageRequestOptions } from "../../../common/messaging"; +import { actionStore } from "../../actions/store"; import { connection } from "../../connection"; import { PropertyInspector } from "../property-inspector"; import { router } from "../router"; +jest.mock("../../actions/store"); jest.mock("../../connection"); jest.mock("../../logging"); jest.mock("../../manifest"); @@ -16,15 +18,13 @@ describe("PropertyInspector", () => { it("initializes context", () => { // Arrange, act. const pi = new PropertyInspector(router, { - action: "com.elgato.test.one", - context: "abc123", + action: "com.elgato.test.action", + context: "key123", // Mocked in actionStore. device: "dev123" }); // Assert. - expect(pi.deviceId).toBe("dev123"); - expect(pi.id).toBe("abc123"); - expect(pi.manifestId).toBe("com.elgato.test.one"); + expect(pi.action).toBe(actionStore.getActionById("key123")); }); describe("fetch", () => { @@ -35,7 +35,7 @@ describe("PropertyInspector", () => { // Arrange. const spyOnFetch = jest.spyOn(router, "fetch"); const pi = new PropertyInspector(router, { - action: "com.elgato.test.one", + action: "com.elgato.test.action", context: "abc123", device: "dev123" }); @@ -55,7 +55,7 @@ describe("PropertyInspector", () => { // Arrange. const spyOnFetch = jest.spyOn(router, "fetch"); const pi = new PropertyInspector(router, { - action: "com.elgato.test.one", + action: "com.elgato.test.action", context: "abc123", device: "dev123" }); @@ -86,8 +86,8 @@ describe("PropertyInspector", () => { // Arrange. const spyOnSend = jest.spyOn(connection, "send"); const pi = new PropertyInspector(router, { - action: "com.elgato.test.one", - context: "abc123", + action: "com.elgato.test.action", + context: "key123", // Mocked in actionStore. device: "dev123" }); @@ -97,7 +97,7 @@ describe("PropertyInspector", () => { // Assert. expect(spyOnSend).toBeCalledTimes(1); expect(spyOnSend).toHaveBeenLastCalledWith<[SendToPropertyInspector]>({ - context: "abc123", + context: "key123", event: "sendToPropertyInspector", payload: { message: "Hello world" diff --git a/src/plugin/ui/__tests__/route.test.ts b/src/plugin/ui/__tests__/route.test.ts index 6ff7c32a..25beb2ec 100644 --- a/src/plugin/ui/__tests__/route.test.ts +++ b/src/plugin/ui/__tests__/route.test.ts @@ -1,11 +1,13 @@ -import { Action, action, type JsonObject, type MessageRequest } from "../.."; +import { action, type JsonObject, type MessageRequest } from "../.."; import type { PluginCommand, SendToPropertyInspector } from "../../../api"; import { MessageGateway, MessageResponder } from "../../../common/messaging"; import { PromiseCompletionSource } from "../../../common/promises"; import { SingletonAction } from "../../actions/singleton-action"; +import { actionStore } from "../../actions/store"; import { connection } from "../../connection"; import { route } from "../route"; +jest.mock("../../actions/store"); jest.mock("../../connection"); jest.mock("../../logging"); jest.mock("../../manifest"); @@ -15,8 +17,8 @@ describe("route", () => { describe("current PI has routes", () => { const ev = { - action: "com.elgato.test.one", - context: "abc123" + action: "com.elgato.test.action", + context: "key123" }; beforeEach(() => initialize(ev.action)); @@ -40,7 +42,7 @@ describe("route", () => { expect(action.spyOnGetCharacters).toHaveBeenCalledTimes(1); expect(action.spyOnGetCharacters).toHaveBeenLastCalledWith<[MessageRequest, MessageResponder]>( { - action: new Action(ev), + action: actionStore.getActionById(ev.context)!, path: "public:/characters", unidirectional: false, body: { @@ -74,7 +76,7 @@ describe("route", () => { expect(action.spyOnGetCharactersSync).toHaveBeenCalledTimes(1); expect(action.spyOnGetCharactersSync).toHaveBeenLastCalledWith<[MessageRequest, MessageResponder]>( { - action: new Action(ev), + action: actionStore.getActionById(ev.context)!, path: "public:/characters-sync", unidirectional: false, body: { @@ -101,7 +103,7 @@ describe("route", () => { expect(action.spyOnSave).toHaveBeenCalledTimes(1); expect(action.spyOnSave).toHaveBeenLastCalledWith<[MessageRequest, MessageResponder]>( { - action: new Action(ev), + action: actionStore.getActionById(ev.context)!, path: "public:/save", unidirectional: false, body: undefined @@ -173,10 +175,12 @@ describe("route", () => { * @param action Action type of the current property inspector. */ function initialize(action: string): void { + const context = "key123"; // Mocked in actionStore. + // Set the current property inspector associated with the plugin router. connection.emit("propertyInspectorDidAppear", { action, - context: "abc123", + context, device: "dev123", event: "propertyInspectorDidAppear" }); @@ -186,7 +190,7 @@ describe("route", () => { (payload) => { connection.emit("sendToPlugin", { action, - context: "abc123", + context, event: "sendToPlugin", payload }); @@ -200,7 +204,7 @@ describe("route", () => { if (cmd.event === "sendToPropertyInspector") { piRouter.process({ action, - context: "abc123", + context, event: "sendToPropertyInspector", payload: (cmd as SendToPropertyInspector).payload }); @@ -214,7 +218,7 @@ describe("route", () => { /** * Mock action with routes. */ -@action({ UUID: "com.elgato.test.one" }) +@action({ UUID: "com.elgato.test.action" }) class ActionWithRoutes extends SingletonAction { public spyOnGetCharacters = jest.fn(); public spyOnGetCharactersSync = jest.fn(); diff --git a/src/plugin/ui/__tests__/router.test.ts b/src/plugin/ui/__tests__/router.test.ts index e620486c..bbe67f5e 100644 --- a/src/plugin/ui/__tests__/router.test.ts +++ b/src/plugin/ui/__tests__/router.test.ts @@ -1,12 +1,15 @@ -import { Action, MessageRequest, type MessageRequestOptions } from "../.."; +import { DeviceType, KeyAction, MessageRequest, type MessageRequestOptions } from "../.."; import type { DidReceivePropertyInspectorMessage, SendToPropertyInspector } from "../../../api"; import type { RawMessageRequest } from "../../../common/messaging/message"; import { MessageResponder } from "../../../common/messaging/responder"; import { PromiseCompletionSource } from "../../../common/promises"; +import { actionStore } from "../../actions/store"; import { connection } from "../../connection"; +import { Device } from "../../devices/device"; import { PropertyInspector } from "../property-inspector"; import { getCurrentUI, router } from "../router"; +jest.mock("../../actions/store"); jest.mock("../../connection"); jest.mock("../../logging"); jest.mock("../../manifest"); @@ -31,7 +34,7 @@ describe("current UI", () => { // Arrange. connection.emit("propertyInspectorDidAppear", { action: "com.elgato.test.one", - context: "abc123", + context: "key123", // Mocked in actionStore. device: "dev123", event: "propertyInspectorDidAppear" }); @@ -42,9 +45,7 @@ describe("current UI", () => { // Assert. expect(current).toBeInstanceOf(PropertyInspector); expect(current).not.toBeUndefined(); - expect(current?.deviceId).toBe("dev123"); - expect(current?.id).toBe("abc123"); - expect(current?.manifestId).toBe("com.elgato.test.one"); + expect(current?.action).toBe(actionStore.getActionById("key123")); }); /** @@ -61,7 +62,7 @@ describe("current UI", () => { connection.emit("propertyInspectorDidAppear", { action: "com.elgato.test.one", - context: "abc123", + context: "key123", // Mocked in actionStore. device: "dev123", event: "propertyInspectorDidAppear" }); @@ -72,9 +73,7 @@ describe("current UI", () => { // Assert. expect(current).toBeInstanceOf(PropertyInspector); expect(current).not.toBeUndefined(); - expect(current?.deviceId).toBe("dev123"); - expect(current?.id).toBe("abc123"); - expect(current?.manifestId).toBe("com.elgato.test.one"); + expect(current?.action).toBe(actionStore.getActionById("key123")); }); /** @@ -82,10 +81,11 @@ describe("current UI", () => { */ it("clears matching PI", () => { // Arrange. + const action = actionStore.getActionById("key123")!; const context = { - action: "com.elgato.test.one", - context: "abc123", - device: "dev123" + action: action.manifestId, + context: action.id, + device: undefined! }; connection.emit("propertyInspectorDidAppear", { @@ -111,10 +111,11 @@ describe("current UI", () => { */ it("does not clear matching PI with debounce", () => { // Arrange. + const action = actionStore.getActionById("key123")!; const context = { - action: "com.elgato.test.one", - context: "abc123", - device: "dev123" + action: action.manifestId, + context: action.id, + device: undefined! }; connection.emit("propertyInspectorDidAppear", { @@ -153,7 +154,7 @@ describe("current UI", () => { // Arrange. connection.emit("propertyInspectorDidAppear", { action: "com.elgato.test.one", - context: "abc123", + context: "key123", // Mocked in actionStore. device: "dev123", event: "propertyInspectorDidAppear" }); @@ -161,7 +162,7 @@ describe("current UI", () => { expect(getCurrentUI()).not.toBeUndefined(); connection.emit("propertyInspectorDidDisappear", { action: "com.elgato.test.one", - context: "__other__", + context: "dial123", // Mocked in actionStore device: "dev123", event: "propertyInspectorDidDisappear" }); @@ -181,7 +182,7 @@ describe("current UI", () => { const spyOnFetch = jest.spyOn(router, "fetch"); connection.emit("propertyInspectorDidAppear", { action: "com.elgato.test.one", - context: "abc123", + context: "key123", // Mocked in actionStore. device: "dev123", event: "propertyInspectorDidAppear" }); @@ -213,7 +214,7 @@ describe("router", () => { const spyOnProcess = jest.spyOn(router, "process"); const ev = { action: "com.elgato.test.one", - context: "abc123", + context: "key123", // Mocked in actionStore. event: "sendToPlugin", payload: { __type: "request", @@ -240,7 +241,7 @@ describe("router", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[MessageRequest, MessageResponder]>( { - action: new Action(ev), + action: actionStore.getActionById("key123")!, path: "/test", unidirectional: false, body: { @@ -259,7 +260,32 @@ describe("router", () => { describe("outbound messages", () => { describe("with ui", () => { - beforeAll(() => jest.useFakeTimers()); + const action = new KeyAction({ + id: "com.elgato.test.action", + manifestId: "com.elgato.test.action", + device: new Device( + "device123", + { + name: "Device One", + size: { + columns: 5, + rows: 3 + }, + type: DeviceType.StreamDeck + }, + false + ), + coordinates: { + column: 0, + row: 0 + } + }); + + beforeAll(() => { + jest.useFakeTimers(); + jest.spyOn(actionStore, "getActionById").mockReturnValue(action); + }); + afterAll(() => jest.useRealTimers()); /** @@ -269,9 +295,9 @@ describe("router", () => { // Arrange. const spyOnSend = jest.spyOn(connection, "send"); connection.emit("propertyInspectorDidAppear", { - action: "com.elgato.test.one", - context: "proxy-outbound-message-with-path-and-body", - device: "dev123", + action: action.manifestId, + context: action.id, + device: action.device.id, event: "propertyInspectorDidAppear" }); @@ -283,7 +309,7 @@ describe("router", () => { // Assert. expect(spyOnSend).toHaveBeenCalledTimes(1); expect(spyOnSend).toHaveBeenCalledWith<[SendToPropertyInspector]>({ - context: "proxy-outbound-message-with-path-and-body", + context: action.id, event: "sendToPropertyInspector", payload: { __type: "request", @@ -304,9 +330,9 @@ describe("router", () => { // Arrange. const spyOnSend = jest.spyOn(connection, "send"); connection.emit("propertyInspectorDidAppear", { - action: "com.elgato.test.one", - context: "proxy-outbound-message-with-path-and-body", - device: "dev123", + action: action.manifestId, + context: action.id, + device: action.device.id, event: "propertyInspectorDidAppear" }); @@ -317,13 +343,14 @@ describe("router", () => { timeout: 1000, unidirectional: true }); + jest.runAllTimers(); await req; // Assert. expect(spyOnSend).toHaveBeenCalledTimes(1); expect(spyOnSend).toHaveBeenCalledWith<[SendToPropertyInspector]>({ - context: "proxy-outbound-message-with-path-and-body", + context: action.id, event: "sendToPropertyInspector", payload: { __type: "request", @@ -343,10 +370,22 @@ describe("router", () => { */ test("without ui", async () => { // Arrange. + const action = new KeyAction({ + manifestId: "com.elgato.test.one", + id: "proxy-outbound-message-without-ui", + coordinates: { + column: 0, + row: 0 + }, + device: undefined! + }); + + jest.spyOn(actionStore, "getActionById").mockReturnValue(action); + const ev = { - action: "com.elgato.test.one", - context: "proxy-outbound-message-without-ui", - device: "dev123" + action: action.manifestId, + context: action.id, + device: undefined! }; connection.emit("propertyInspectorDidAppear", { diff --git a/src/plugin/ui/router.ts b/src/plugin/ui/router.ts index 110f0ef2..f3885c6d 100644 --- a/src/plugin/ui/router.ts +++ b/src/plugin/ui/router.ts @@ -43,7 +43,7 @@ const router = new MessageGateway( * @returns `true` when the event is related to the current property inspector. */ function isCurrent(ev: PropertyInspectorDidAppear | PropertyInspectorDidDisappear): boolean { - return current?.action.id === ev.context; + return current?.action?.id === ev.context && current?.action?.manifestId === ev.action && current?.action?.device?.id === ev.device; } /* From bf4cd7e104640fde720bcb45b677043bf9f34a7e Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sun, 15 Sep 2024 20:10:08 +0100 Subject: [PATCH 17/29] test: fix tests --- src/plugin/__mocks__/manifest.ts | 12 +++- src/plugin/actions/__mocks__/store.ts | 4 +- src/plugin/actions/__tests__/index.test.ts | 60 +++++++++---------- .../ui/__tests__/property-inspector.test.ts | 8 +-- src/plugin/ui/__tests__/route.test.ts | 18 +++--- src/plugin/ui/__tests__/router.test.ts | 4 +- src/plugin/ui/router.ts | 3 +- src/ui/__tests__/settings.test.ts | 2 +- 8 files changed, 61 insertions(+), 50 deletions(-) diff --git a/src/plugin/__mocks__/manifest.ts b/src/plugin/__mocks__/manifest.ts index a97146ed..ec4600d1 100644 --- a/src/plugin/__mocks__/manifest.ts +++ b/src/plugin/__mocks__/manifest.ts @@ -9,7 +9,17 @@ export const manifest: Manifest = { Actions: [ { Name: "Action One", - UUID: "com.elgato.test.action", + UUID: "com.elgato.test.key", + Icon: "imgs/actions/one", + States: [ + { + Image: "imgs/actions/state" + } + ] + }, + { + Name: "Action Two", + UUID: "com.elgato.test.dial", Icon: "imgs/actions/one", States: [ { diff --git a/src/plugin/actions/__mocks__/store.ts b/src/plugin/actions/__mocks__/store.ts index 885034a4..585899d0 100644 --- a/src/plugin/actions/__mocks__/store.ts +++ b/src/plugin/actions/__mocks__/store.ts @@ -3,7 +3,7 @@ import { KeyAction } from "../key"; const key = new KeyAction({ id: "key123", - manifestId: "com.elgato.test.action", + manifestId: "com.elgato.test.key", coordinates: { column: 1, row: 1 @@ -13,7 +13,7 @@ const key = new KeyAction({ const dial = new DialAction({ id: "dial123", - manifestId: "com.elgato.test.action", + manifestId: "com.elgato.test.dial", coordinates: { column: 1, row: 1 diff --git a/src/plugin/actions/__tests__/index.test.ts b/src/plugin/actions/__tests__/index.test.ts index fd080100..ee36ac7c 100644 --- a/src/plugin/actions/__tests__/index.test.ts +++ b/src/plugin/actions/__tests__/index.test.ts @@ -37,7 +37,6 @@ import { Settings } from "../../../api/__mocks__/events"; import { connection } from "../../connection"; import { devices } from "../../devices"; import { Device } from "../../devices/device"; -import { getManifest } from "../../manifest"; import type { onDidReceiveSettings } from "../../settings"; import type { UIController } from "../../ui"; import { actionStore } from "../store"; @@ -486,8 +485,9 @@ describe("actions", () => { }); describe("registering an action", () => { - const manifestId = getManifest().Actions[0].UUID; - // afterEach(() => jest.clearAllMocks()); + const keyManifestId = "com.elgato.test.key"; + const dialManifestId = "com.elgato.test.dial"; + /** * Asserts {@link registerAction} validates the manifest identifier is not undefined. */ @@ -527,7 +527,7 @@ describe("actions", () => { const spyOnPrependOnceListener = jest.spyOn(connection, "prependOnceListener"); // Act. - registerAction({ manifestId }); + registerAction({ manifestId: keyManifestId }); // Assert. expect(spyOnAddListener).not.toHaveBeenCalled(); @@ -545,7 +545,7 @@ describe("actions", () => { // Arrange. const listener = jest.fn(); const ev = { - action: manifestId, + action: dialManifestId, context: "dial123", // Mocked in actionStore. device: "device123", event: "dialDown", @@ -563,7 +563,7 @@ describe("actions", () => { // Act. registerAction({ - manifestId, + manifestId: ev.action, onDialDown: listener }); @@ -586,7 +586,7 @@ describe("actions", () => { // Arrange. const listener = jest.fn(); const ev = { - action: manifestId, + action: dialManifestId, context: "dial123", // Mocked in actionStore device: "device123", event: "dialRotate", @@ -606,7 +606,7 @@ describe("actions", () => { // Act. registerAction({ - manifestId, + manifestId: ev.action, onDialRotate: listener }); @@ -629,7 +629,7 @@ describe("actions", () => { // Arrange. const listener = jest.fn(); const ev = { - action: manifestId, + action: dialManifestId, context: "dial123", // Mocked in actionStore device: "device123", event: "dialUp", @@ -647,7 +647,7 @@ describe("actions", () => { // Act. registerAction({ - manifestId, + manifestId: ev.action, onDialUp: listener }); @@ -670,7 +670,7 @@ describe("actions", () => { // Arrange const listener = jest.fn(); const ev = { - action: manifestId, + action: keyManifestId, context: "key123", // Mocked in actionStore event: "sendToPlugin", payload: { @@ -680,7 +680,7 @@ describe("actions", () => { // Act. registerAction({ - manifestId, + manifestId: ev.action, onSendToPlugin: listener }); @@ -704,7 +704,7 @@ describe("actions", () => { // Arrange const listener = jest.fn(); const ev = { - action: manifestId, + action: keyManifestId, context: "key123", // Mocked in actionStore device: "device123", event: "didReceiveSettings", @@ -723,7 +723,7 @@ describe("actions", () => { // Act. registerAction({ - manifestId, + manifestId: ev.action, onDidReceiveSettings: listener }); @@ -746,7 +746,7 @@ describe("actions", () => { // Arrange. const listener = jest.fn(); const ev = { - action: manifestId, + action: keyManifestId, context: "key123", // Mocked in actionStore device: "device123", event: "keyDown", @@ -765,7 +765,7 @@ describe("actions", () => { // Act. registerAction({ - manifestId, + manifestId: ev.action, onKeyDown: listener }); @@ -788,7 +788,7 @@ describe("actions", () => { // Arrange. const listener = jest.fn(); const ev = { - action: manifestId, + action: keyManifestId, context: "key123", // Mocked in actionStore device: "device123", event: "keyUp", @@ -807,7 +807,7 @@ describe("actions", () => { // Act. registerAction({ - manifestId, + manifestId: ev.action, onKeyUp: listener }); @@ -830,7 +830,7 @@ describe("actions", () => { // Arrange const listener = jest.fn(); const ev = { - action: manifestId, + action: keyManifestId, context: "key123", // Mocked in actionStore device: "device123", event: "propertyInspectorDidAppear" @@ -838,7 +838,7 @@ describe("actions", () => { // Act. registerAction({ - manifestId, + manifestId: ev.action, onPropertyInspectorDidAppear: listener }); @@ -860,7 +860,7 @@ describe("actions", () => { // Arrange const listener = jest.fn(); const ev = { - action: manifestId, + action: keyManifestId, context: "key123", // Mocked in actionStore device: "device123", event: "propertyInspectorDidDisappear" @@ -868,7 +868,7 @@ describe("actions", () => { // Act (emit). registerAction({ - manifestId, + manifestId: ev.action, onPropertyInspectorDidDisappear: listener }); @@ -890,7 +890,7 @@ describe("actions", () => { // Arrange. const listener = jest.fn(); const ev = { - action: manifestId, + action: keyManifestId, context: "key123", // Mocked in actionStore device: "device123", event: "titleParametersDidChange", @@ -918,7 +918,7 @@ describe("actions", () => { // Act. registerAction({ - manifestId, + manifestId: ev.action, onTitleParametersDidChange: listener }); @@ -941,7 +941,7 @@ describe("actions", () => { // Arrange. const listener = jest.fn(); const ev = { - action: manifestId, + action: dialManifestId, context: "dial123", // Mocked in actionStore device: "device123", event: "touchTap", @@ -961,7 +961,7 @@ describe("actions", () => { // Act. registerAction({ - manifestId, + manifestId: ev.action, onTouchTap: listener }); @@ -984,7 +984,7 @@ describe("actions", () => { // Arrange. const listener = jest.fn(); const ev = { - action: manifestId, + action: keyManifestId, context: "key123", // Mocked in actionStore device: "device123", event: "willAppear", @@ -1003,7 +1003,7 @@ describe("actions", () => { // Act. registerAction({ - manifestId, + manifestId: ev.action, onWillAppear: listener }); @@ -1026,7 +1026,7 @@ describe("actions", () => { // Arrange. const listener = jest.fn(); const ev = { - action: manifestId, + action: keyManifestId, context: "key123", // Mocked in actionStore device: "device123", event: "willDisappear", @@ -1045,7 +1045,7 @@ describe("actions", () => { // Act. registerAction({ - manifestId, + manifestId: ev.action, onWillDisappear: listener }); diff --git a/src/plugin/ui/__tests__/property-inspector.test.ts b/src/plugin/ui/__tests__/property-inspector.test.ts index d0c283d0..07e16dd0 100644 --- a/src/plugin/ui/__tests__/property-inspector.test.ts +++ b/src/plugin/ui/__tests__/property-inspector.test.ts @@ -18,7 +18,7 @@ describe("PropertyInspector", () => { it("initializes context", () => { // Arrange, act. const pi = new PropertyInspector(router, { - action: "com.elgato.test.action", + action: "com.elgato.test.key", context: "key123", // Mocked in actionStore. device: "dev123" }); @@ -35,7 +35,7 @@ describe("PropertyInspector", () => { // Arrange. const spyOnFetch = jest.spyOn(router, "fetch"); const pi = new PropertyInspector(router, { - action: "com.elgato.test.action", + action: "com.elgato.test.key", context: "abc123", device: "dev123" }); @@ -55,7 +55,7 @@ describe("PropertyInspector", () => { // Arrange. const spyOnFetch = jest.spyOn(router, "fetch"); const pi = new PropertyInspector(router, { - action: "com.elgato.test.action", + action: "com.elgato.test.key", context: "abc123", device: "dev123" }); @@ -86,7 +86,7 @@ describe("PropertyInspector", () => { // Arrange. const spyOnSend = jest.spyOn(connection, "send"); const pi = new PropertyInspector(router, { - action: "com.elgato.test.action", + action: "com.elgato.test.key", context: "key123", // Mocked in actionStore. device: "dev123" }); diff --git a/src/plugin/ui/__tests__/route.test.ts b/src/plugin/ui/__tests__/route.test.ts index 25beb2ec..6aae6c18 100644 --- a/src/plugin/ui/__tests__/route.test.ts +++ b/src/plugin/ui/__tests__/route.test.ts @@ -17,11 +17,11 @@ describe("route", () => { describe("current PI has routes", () => { const ev = { - action: "com.elgato.test.action", + action: "com.elgato.test.key", context: "key123" }; - beforeEach(() => initialize(ev.action)); + beforeEach(() => initialize(ev.context)); /** * Asserts {@link route} with an asynchronous result. @@ -118,7 +118,7 @@ describe("route", () => { }); describe("current PI does not have routes", () => { - beforeEach(() => initialize("com.other")); + beforeEach(() => initialize("dial123")); // This resolves a different manifestId to the sample class below /** * Asserts {@link route} with an asynchronous result. @@ -174,12 +174,12 @@ describe("route", () => { * Initializes the "current property inspector" for the specific action type. * @param action Action type of the current property inspector. */ - function initialize(action: string): void { - const context = "key123"; // Mocked in actionStore. + function initialize(context: string): void { + const action = actionStore.getActionById(context)!; // Set the current property inspector associated with the plugin router. connection.emit("propertyInspectorDidAppear", { - action, + action: action?.manifestId, context, device: "dev123", event: "propertyInspectorDidAppear" @@ -189,7 +189,7 @@ describe("route", () => { piRouter = new MessageGateway( (payload) => { connection.emit("sendToPlugin", { - action, + action: action.manifestId, context, event: "sendToPlugin", payload @@ -203,7 +203,7 @@ describe("route", () => { jest.spyOn(connection, "send").mockImplementation((cmd: PluginCommand) => { if (cmd.event === "sendToPropertyInspector") { piRouter.process({ - action, + action: action.manifestId, context, event: "sendToPropertyInspector", payload: (cmd as SendToPropertyInspector).payload @@ -218,7 +218,7 @@ describe("route", () => { /** * Mock action with routes. */ -@action({ UUID: "com.elgato.test.action" }) +@action({ UUID: "com.elgato.test.key" }) class ActionWithRoutes extends SingletonAction { public spyOnGetCharacters = jest.fn(); public spyOnGetCharactersSync = jest.fn(); diff --git a/src/plugin/ui/__tests__/router.test.ts b/src/plugin/ui/__tests__/router.test.ts index bbe67f5e..cbafa2c7 100644 --- a/src/plugin/ui/__tests__/router.test.ts +++ b/src/plugin/ui/__tests__/router.test.ts @@ -261,8 +261,8 @@ describe("router", () => { describe("outbound messages", () => { describe("with ui", () => { const action = new KeyAction({ - id: "com.elgato.test.action", - manifestId: "com.elgato.test.action", + id: "key123", + manifestId: "com.elgato.test.key", device: new Device( "device123", { diff --git a/src/plugin/ui/router.ts b/src/plugin/ui/router.ts index f3885c6d..0b3eb809 100644 --- a/src/plugin/ui/router.ts +++ b/src/plugin/ui/router.ts @@ -2,6 +2,7 @@ import type { PropertyInspectorDidAppear, PropertyInspectorDidDisappear } from " import type { JsonValue } from "../../common/json"; import { MessageGateway } from "../../common/messaging"; import { Action } from "../actions/action"; +import { actionStore } from "../actions/store"; import { connection } from "../connection"; import { PropertyInspector } from "./property-inspector"; @@ -34,7 +35,7 @@ const router = new MessageGateway( return false; }, - (source) => current!.action + (source) => actionStore.getActionById(source.context)! ); /** diff --git a/src/ui/__tests__/settings.test.ts b/src/ui/__tests__/settings.test.ts index 7e4e2140..12615c20 100644 --- a/src/ui/__tests__/settings.test.ts +++ b/src/ui/__tests__/settings.test.ts @@ -151,7 +151,7 @@ describe("settings", () => { const listener = jest.fn(); const ev: DidReceiveSettings = { event: "didReceiveSettings", - action: "com.elgato.test.action", + action: "com.elgato.test.key", context: "action123", device: "dev123", payload: { From 72406b29482db7f47b9d379ee8e199705b8d5d5b Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sun, 15 Sep 2024 20:10:54 +0100 Subject: [PATCH 18/29] style: linting --- src/plugin/ui/__tests__/route.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugin/ui/__tests__/route.test.ts b/src/plugin/ui/__tests__/route.test.ts index 6aae6c18..23b01d3d 100644 --- a/src/plugin/ui/__tests__/route.test.ts +++ b/src/plugin/ui/__tests__/route.test.ts @@ -172,7 +172,7 @@ describe("route", () => { /** * Initializes the "current property inspector" for the specific action type. - * @param action Action type of the current property inspector. + * @param context Action context (i.e. the action's instance identifier). */ function initialize(context: string): void { const action = actionStore.getActionById(context)!; From a32e8bab6964cb761ac31b7f0e309019f2d453a7 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sun, 15 Sep 2024 20:53:08 +0100 Subject: [PATCH 19/29] refactor: update actions to be a service, allowing for it to be iterated over --- src/plugin/__tests__/index.test.ts | 4 +- src/plugin/actions/__mocks__/store.ts | 8 + src/plugin/actions/__tests__/action.test.ts | 94 ++--- src/plugin/actions/__tests__/dial.test.ts | 32 +- src/plugin/actions/__tests__/index.test.ts | 52 +-- src/plugin/actions/__tests__/key.test.ts | 32 +- src/plugin/actions/__tests__/multi.test.ts | 36 +- src/plugin/actions/index.ts | 398 ++++++++++---------- src/plugin/index.ts | 6 +- 9 files changed, 329 insertions(+), 333 deletions(-) diff --git a/src/plugin/__tests__/index.test.ts b/src/plugin/__tests__/index.test.ts index 82ed0d42..16fc4e27 100644 --- a/src/plugin/__tests__/index.test.ts +++ b/src/plugin/__tests__/index.test.ts @@ -28,7 +28,7 @@ describe("index", () => { */ it("exports namespaces", async () => { // Arrange. - const actions = await require("../actions"); + const { actionService } = await require("../actions"); const { devices } = await require("../devices"); const { getManifest } = await require("../manifest"); const profiles = await require("../profiles"); @@ -37,7 +37,7 @@ describe("index", () => { const { ui } = await require("../ui"); // Act, assert. - expect(streamDeck.actions).toBe(actions); + expect(streamDeck.actions).toBe(actionService); expect(streamDeck.devices).toBe(devices); expect(streamDeck.manifest).toBe(getManifest()); expect(streamDeck.profiles).toBe(profiles); diff --git a/src/plugin/actions/__mocks__/store.ts b/src/plugin/actions/__mocks__/store.ts index 585899d0..c8ce0bab 100644 --- a/src/plugin/actions/__mocks__/store.ts +++ b/src/plugin/actions/__mocks__/store.ts @@ -1,6 +1,8 @@ import { DialAction } from "../dial"; import { KeyAction } from "../key"; +const { ActionStore, initializeStore: __initializeStore } = jest.requireActual("../store"); + const key = new KeyAction({ id: "key123", manifestId: "com.elgato.test.key", @@ -33,4 +35,10 @@ export const actionStore = { }) }; +// @ts-expect-error Underlying store is not used, but still registers on the connection. +__initializeStore({ + getDeviceById: jest.fn() +}); + export const initializeStore = jest.fn(); +export { ActionStore }; diff --git a/src/plugin/actions/__tests__/action.test.ts b/src/plugin/actions/__tests__/action.test.ts index ed9e5001..fb042481 100644 --- a/src/plugin/actions/__tests__/action.test.ts +++ b/src/plugin/actions/__tests__/action.test.ts @@ -1,31 +1,45 @@ -import { type GetSettings, type SendToPropertyInspector, type SetSettings } from "../../../api"; +import { DeviceType, type GetSettings, type SendToPropertyInspector, type SetSettings } from "../../../api"; import { Settings } from "../../../api/__mocks__/events"; import { connection } from "../../connection"; +import { Device } from "../../devices/device"; import { Action, type ActionContext } from "../action"; +import { MultiActionKey } from "../multi"; jest.mock("../../logging"); jest.mock("../../manifest"); jest.mock("../../connection"); describe("Action", () => { + // Mock device. + const device = new Device( + "dev123", + { + name: "Device One", + size: { + columns: 5, + rows: 3 + }, + type: DeviceType.StreamDeck + }, + false + ); + /** - * Asserts the constructor of {@link Action} sets the {@link Action.manifestId} and {@link Action.id}. + * Asserts the constructor of {@link Action} sets the context. */ - it("constructor sets manifestId and id", () => { + it("constructor sets context", () => { // Arrange. const context: ActionContext = { - device: { - id: "DEV123", - isConnected: false - }, + device, id: "ABC123", manifestId: "com.elgato.test.one" }; // Act. - const action = new Action(context); + const action = new MultiActionKey(context); // Assert. + expect(action).toBeInstanceOf(Action); expect(action.device).toBe(context.device); expect(action.id).toBe(context.id); expect(action.manifestId).toBe(context.manifestId); @@ -36,11 +50,8 @@ describe("Action", () => { */ it("getSettings", async () => { // Arrange. - const action = new Action({ - device: { - id: "DEV123", - isConnected: false - }, + const action = new MultiActionKey({ + device, id: "ABC123", manifestId: "com.elgato.test.one" }); @@ -102,62 +113,9 @@ describe("Action", () => { }); }); - // describe("type checking", () => { - // /** - // * Asserts {@link Action.isDial}. - // */ - // it("can be dial", () => { - // // Arrange. - // const action = new DialAction({ - // action: "com.elgato.test.one", - // context: "ABC123" - // }); - - // // Act, assert. - // expect(action.isDial()).toBe(true); - // expect(action.isKey()).toBe(false); - // expect(action.isKeyInMultiAction()).toBe(false); - // }); - - // /** - // * Asserts {@link Action.isKey}. - // */ - // it("can be key", () => { - // // Arrange. - // const action = new KeyAction({ - // action: "com.elgato.test.one", - // context: "ABC123" - // }); - - // // Act, assert. - // expect(action.isDial()).toBe(false); - // expect(action.isKey()).toBe(true); - // expect(action.isKeyInMultiAction()).toBe(false); - // }); - - // /** - // * Asserts {@link Action.isKeyInMultiAction}. - // */ - // it("can be key in multi-action", () => { - // // Arrange. - // const action = new KeyInMultiAction({ - // action: "com.elgato.test.one", - // context: "ABC123" - // }); - - // // Act, assert. - // expect(action.isDial()).toBe(false); - // expect(action.isKey()).toBe(false); - // expect(action.isKeyInMultiAction()).toBe(true); - // }); - // }); - describe("sending", () => { - const action = new Action({ - device: { - id: "DEV123", - isConnected: false - }, + const action = new MultiActionKey({ + device, id: "ABC123", manifestId: "com.elgato.test.one" }); diff --git a/src/plugin/actions/__tests__/dial.test.ts b/src/plugin/actions/__tests__/dial.test.ts index 57e9269b..6f3c27b1 100644 --- a/src/plugin/actions/__tests__/dial.test.ts +++ b/src/plugin/actions/__tests__/dial.test.ts @@ -1,5 +1,6 @@ -import { Target, type SetFeedback, type SetFeedbackLayout, type SetImage, type SetTitle, type SetTriggerDescription, type ShowAlert } from "../../../api"; +import { DeviceType, Target, type SetFeedback, type SetFeedbackLayout, type SetImage, type SetTitle, type SetTriggerDescription, type ShowAlert } from "../../../api"; import { connection } from "../../connection"; +import { Device } from "../../devices"; import { Action, type CoordinatedActionContext } from "../action"; import { DialAction } from "../dial"; @@ -8,16 +9,27 @@ jest.mock("../../manifest"); jest.mock("../../connection"); describe("DialAction", () => { + // Mock device. + const device = new Device( + "dev123", + { + name: "Device One", + size: { + columns: 5, + rows: 3 + }, + type: DeviceType.StreamDeck + }, + false + ); + /** * Asserts the constructor of {@link DialAction} sets the context. */ it("constructor sets context", () => { // Arrange. const source: CoordinatedActionContext = { - device: { - id: "DEV123", - isConnected: false - }, + device, id: "ABC123", manifestId: "com.elgato.test.one", coordinates: { @@ -42,10 +54,7 @@ describe("DialAction", () => { it("inherits shared methods", () => { // Arrange, act. const dialAction = new DialAction({ - device: { - id: "DEV123", - isConnected: false - }, + device, id: "ABC123", manifestId: "com.elgato.test.one", coordinates: { @@ -60,10 +69,7 @@ describe("DialAction", () => { describe("sending", () => { const dialAction = new DialAction({ - device: { - id: "DEV123", - isConnected: false - }, + device, id: "ABC123", manifestId: "com.elgato.test.one", coordinates: { diff --git a/src/plugin/actions/__tests__/index.test.ts b/src/plugin/actions/__tests__/index.test.ts index ee36ac7c..c6fcfe63 100644 --- a/src/plugin/actions/__tests__/index.test.ts +++ b/src/plugin/actions/__tests__/index.test.ts @@ -1,4 +1,4 @@ -import { onDialDown, onDialRotate, onDialUp, onKeyDown, onKeyUp, onTitleParametersDidChange, onTouchTap, onWillAppear, onWillDisappear, registerAction } from ".."; +import { actionService } from ".."; import { DeviceType, type DialAction, @@ -90,7 +90,7 @@ describe("actions", () => { } satisfies DialDown; // Act (emit). - const disposable = onDialDown(listener); + const disposable = actionService.onDialDown(listener); connection.emit("dialDown", ev); // Assert (emit). @@ -136,7 +136,7 @@ describe("actions", () => { } satisfies DialRotate; // Act (emit). - const disposable = onDialRotate(listener); + const disposable = actionService.onDialRotate(listener); connection.emit("dialRotate", ev); // Assert (emit). @@ -180,7 +180,7 @@ describe("actions", () => { } satisfies DialUp; // Act (emit). - const disposable = onDialUp(listener); + const disposable = actionService.onDialUp(listener); connection.emit("dialUp", ev); // Assert (emit). @@ -225,7 +225,7 @@ describe("actions", () => { } satisfies KeyDown; // Act (emit). - const disposable = onKeyDown(listener); + const disposable = actionService.onKeyDown(listener); connection.emit("keyDown", ev); // Assert (emit). @@ -270,7 +270,7 @@ describe("actions", () => { } satisfies KeyUp; // Act (emit). - const disposable = onKeyUp(listener); + const disposable = actionService.onKeyUp(listener); connection.emit("keyUp", ev); // Assert (emit). @@ -324,7 +324,7 @@ describe("actions", () => { } satisfies TitleParametersDidChange; // Act (emit). - const disposable = onTitleParametersDidChange(listener); + const disposable = actionService.onTitleParametersDidChange(listener); connection.emit("titleParametersDidChange", ev); // Assert (emit). @@ -370,7 +370,7 @@ describe("actions", () => { } satisfies TouchTap; // Act (emit). - const disposable = onTouchTap(listener); + const disposable = actionService.onTouchTap(listener); connection.emit("touchTap", ev); // Assert (emit). @@ -415,7 +415,7 @@ describe("actions", () => { } satisfies WillAppear; // Act (emit). - const disposable = onWillAppear(listener); + const disposable = actionService.onWillAppear(listener); connection.emit("willAppear", ev); // Assert (emit). @@ -460,7 +460,7 @@ describe("actions", () => { } satisfies WillDisappear; // Act (emit). - const disposable = onWillDisappear(listener); + const disposable = actionService.onWillDisappear(listener); connection.emit("willDisappear", ev); // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); @@ -498,7 +498,7 @@ describe("actions", () => { }; // Act, assert. - expect(() => registerAction(action)).toThrow("The action's manifestId cannot be undefined."); + expect(() => actionService.registerAction(action)).toThrow("The action's manifestId cannot be undefined."); }); /** @@ -511,7 +511,7 @@ describe("actions", () => { }; // Act, assert. - expect(() => registerAction(action)).toThrow("com.elgato.action-service.__one"); + expect(() => actionService.registerAction(action)).toThrow("com.elgato.action-service.__one"); }); /** @@ -527,7 +527,7 @@ describe("actions", () => { const spyOnPrependOnceListener = jest.spyOn(connection, "prependOnceListener"); // Act. - registerAction({ manifestId: keyManifestId }); + actionService.registerAction({ manifestId: keyManifestId }); // Assert. expect(spyOnAddListener).not.toHaveBeenCalled(); @@ -562,7 +562,7 @@ describe("actions", () => { } satisfies DialDown; // Act. - registerAction({ + actionService.registerAction({ manifestId: ev.action, onDialDown: listener }); @@ -605,7 +605,7 @@ describe("actions", () => { } satisfies DialRotate; // Act. - registerAction({ + actionService.registerAction({ manifestId: ev.action, onDialRotate: listener }); @@ -646,7 +646,7 @@ describe("actions", () => { } satisfies DialUp; // Act. - registerAction({ + actionService.registerAction({ manifestId: ev.action, onDialUp: listener }); @@ -679,7 +679,7 @@ describe("actions", () => { } satisfies DidReceivePropertyInspectorMessage; // Act. - registerAction({ + actionService.registerAction({ manifestId: ev.action, onSendToPlugin: listener }); @@ -722,7 +722,7 @@ describe("actions", () => { } satisfies DidReceiveSettings; // Act. - registerAction({ + actionService.registerAction({ manifestId: ev.action, onDidReceiveSettings: listener }); @@ -764,7 +764,7 @@ describe("actions", () => { } satisfies KeyDown; // Act. - registerAction({ + actionService.registerAction({ manifestId: ev.action, onKeyDown: listener }); @@ -806,7 +806,7 @@ describe("actions", () => { } satisfies KeyUp; // Act. - registerAction({ + actionService.registerAction({ manifestId: ev.action, onKeyUp: listener }); @@ -837,7 +837,7 @@ describe("actions", () => { } satisfies PropertyInspectorDidAppear; // Act. - registerAction({ + actionService.registerAction({ manifestId: ev.action, onPropertyInspectorDidAppear: listener }); @@ -867,7 +867,7 @@ describe("actions", () => { } satisfies PropertyInspectorDidDisappear; // Act (emit). - registerAction({ + actionService.registerAction({ manifestId: ev.action, onPropertyInspectorDidDisappear: listener }); @@ -917,7 +917,7 @@ describe("actions", () => { } satisfies TitleParametersDidChange; // Act. - registerAction({ + actionService.registerAction({ manifestId: ev.action, onTitleParametersDidChange: listener }); @@ -960,7 +960,7 @@ describe("actions", () => { } satisfies TouchTap; // Act. - registerAction({ + actionService.registerAction({ manifestId: ev.action, onTouchTap: listener }); @@ -1002,7 +1002,7 @@ describe("actions", () => { } satisfies WillAppear; // Act. - registerAction({ + actionService.registerAction({ manifestId: ev.action, onWillAppear: listener }); @@ -1044,7 +1044,7 @@ describe("actions", () => { } satisfies WillDisappear; // Act. - registerAction({ + actionService.registerAction({ manifestId: ev.action, onWillDisappear: listener }); diff --git a/src/plugin/actions/__tests__/key.test.ts b/src/plugin/actions/__tests__/key.test.ts index 774765d9..bd37ce3e 100644 --- a/src/plugin/actions/__tests__/key.test.ts +++ b/src/plugin/actions/__tests__/key.test.ts @@ -1,5 +1,6 @@ -import { Target, type SetImage, type SetState, type SetTitle, type ShowAlert, type ShowOk } from "../../../api"; +import { DeviceType, Target, type SetImage, type SetState, type SetTitle, type ShowAlert, type ShowOk } from "../../../api"; import { connection } from "../../connection"; +import { Device } from "../../devices"; import { Action, type CoordinatedActionContext } from "../action"; import { KeyAction } from "../key"; @@ -8,16 +9,27 @@ jest.mock("../../manifest"); jest.mock("../../connection"); describe("KeyAction", () => { + // Mock device. + const device = new Device( + "dev123", + { + name: "Device One", + size: { + columns: 5, + rows: 3 + }, + type: DeviceType.StreamDeck + }, + false + ); + /** * Asserts the constructor of {@link KeyAction} sets the context. */ it("constructor sets context", () => { // Arrange. const context: CoordinatedActionContext = { - device: { - id: "DEV123", - isConnected: false - }, + device, id: "ABC123", manifestId: "com.elgato.test.one", coordinates: { @@ -42,10 +54,7 @@ describe("KeyAction", () => { it("inherits shared methods", () => { // Arrange, act. const keyAction = new KeyAction({ - device: { - id: "DEV123", - isConnected: false - }, + device, id: "ABC123", manifestId: "com.elgato.test.one", coordinates: { @@ -60,10 +69,7 @@ describe("KeyAction", () => { describe("sending", () => { const keyAction = new KeyAction({ - device: { - id: "DEV123", - isConnected: false - }, + device, id: "ABC123", manifestId: "com.elgato.test.one", coordinates: { diff --git a/src/plugin/actions/__tests__/multi.test.ts b/src/plugin/actions/__tests__/multi.test.ts index 0f291969..82c6a7d8 100644 --- a/src/plugin/actions/__tests__/multi.test.ts +++ b/src/plugin/actions/__tests__/multi.test.ts @@ -1,5 +1,6 @@ -import { type SetState } from "../../../api"; +import { DeviceType, type SetState } from "../../../api"; import { connection } from "../../connection"; +import { Device } from "../../devices/device"; import { Action, type ActionContext } from "../action"; import { MultiActionKey } from "../multi"; @@ -8,16 +9,27 @@ jest.mock("../../manifest"); jest.mock("../../connection"); describe("KeyMultiAction", () => { + // Mock device. + const device = new Device( + "dev123", + { + name: "Device One", + size: { + columns: 5, + rows: 3 + }, + type: DeviceType.StreamDeck + }, + false + ); + /** - * Asserts the constructor of {@link MultiActionKey} sets the {@link MultiActionKey.manifestId} and {@link MultiActionKey.id}. + * Asserts the constructor of {@link MultiActionKey} sets the context. */ - it("constructor sets manifestId and id", () => { + it("constructor sets context", () => { // Arrange. const context: ActionContext = { - device: { - id: "DEV123", - isConnected: false - }, + device, id: "ABC123", manifestId: "com.elgato.test.one" }; @@ -37,10 +49,7 @@ describe("KeyMultiAction", () => { it("inherits shared methods", () => { // Arrange, act. const multiActionKey = new MultiActionKey({ - device: { - id: "DEV123", - isConnected: false - }, + device, id: "ABC123", manifestId: "com.elgato.test.one" }); @@ -51,10 +60,7 @@ describe("KeyMultiAction", () => { describe("sending", () => { const multiActionKey = new MultiActionKey({ - device: { - id: "DEV123", - isConnected: false - }, + device, id: "ABC123", manifestId: "com.elgato.test.one" }); diff --git a/src/plugin/actions/index.ts b/src/plugin/actions/index.ts index 44dae02d..2c22dedb 100644 --- a/src/plugin/actions/index.ts +++ b/src/plugin/actions/index.ts @@ -20,220 +20,232 @@ import { onDidReceiveSettings } from "../settings"; import { ui } from "../ui"; import { Action, type ActionContext } from "./action"; import type { SingletonAction } from "./singleton-action"; -import { actionStore } from "./store"; +import { ActionStore, actionStore } from "./store"; const manifest = getManifest(); /** - * Occurs when the user presses a dial (Stream Deck +). See also {@link onDialUp}. - * - * NB: For other action types see {@link onKeyDown}. - * @template T The type of settings associated with the action. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. + * Provides functions, and information, for interacting with Stream Deck actions. */ -export function onDialDown(listener: (ev: DialDownEvent) => void): IDisposable { - return connection.disposableOn("dialDown", (ev: DialDown) => { - const action = actionStore.getActionById(ev.context); - if (action?.isDial()) { - listener(new ActionEvent(action, ev)); - } - }); -} - -/** - * Occurs when the user rotates a dial (Stream Deck +). - * @template T The type of settings associated with the action. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. - */ -export function onDialRotate(listener: (ev: DialRotateEvent) => void): IDisposable { - return connection.disposableOn("dialRotate", (ev: DialRotate) => { - const action = actionStore.getActionById(ev.context); - if (action?.isDial()) { - listener(new ActionEvent(action, ev)); - } - }); -} - -/** - * Occurs when the user releases a pressed dial (Stream Deck +). See also {@link onDialDown}. - * - * NB: For other action types see {@link onKeyUp}. - * @template T The type of settings associated with the action. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. - */ -export function onDialUp(listener: (ev: DialUpEvent) => void): IDisposable { - return connection.disposableOn("dialUp", (ev: DialUp) => { - const action = actionStore.getActionById(ev.context); - if (action?.isDial()) { - listener(new ActionEvent(action, ev)); - } - }); -} +class ActionService extends ActionStore { + /** + * Occurs when the user presses a dial (Stream Deck +). See also {@link onDialUp}. + * + * NB: For other action types see {@link onKeyDown}. + * @template T The type of settings associated with the action. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onDialDown(listener: (ev: DialDownEvent) => void): IDisposable { + return connection.disposableOn("dialDown", (ev: DialDown) => { + const action = actionStore.getActionById(ev.context); + if (action?.isDial()) { + listener(new ActionEvent(action, ev)); + } + }); + } -/** - * Occurs when the user presses a action down. See also {@link onKeyUp}. - * - * NB: For dials / touchscreens see {@link onDialDown}. - * @template T The type of settings associated with the action. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. - */ -export function onKeyDown(listener: (ev: KeyDownEvent) => void): IDisposable { - return connection.disposableOn("keyDown", (ev: KeyDown) => { - const action = actionStore.getActionById(ev.context); - if (action?.isKey() || action?.isMultiActionKey()) { - listener(new ActionEvent(action, ev)); - } - }); -} + /** + * Occurs when the user rotates a dial (Stream Deck +). + * @template T The type of settings associated with the action. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onDialRotate(listener: (ev: DialRotateEvent) => void): IDisposable { + return connection.disposableOn("dialRotate", (ev: DialRotate) => { + const action = actionStore.getActionById(ev.context); + if (action?.isDial()) { + listener(new ActionEvent(action, ev)); + } + }); + } -/** - * Occurs when the user releases a pressed action. See also {@link onKeyDown}. - * - * NB: For dials / touchscreens see {@link onDialUp}. - * @template T The type of settings associated with the action. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. - */ -export function onKeyUp(listener: (ev: KeyUpEvent) => void): IDisposable { - return connection.disposableOn("keyUp", (ev: KeyUp) => { - const action = actionStore.getActionById(ev.context); - if (action?.isKey() || action?.isMultiActionKey()) { - listener(new ActionEvent(action, ev)); - } - }); -} + /** + * Occurs when the user releases a pressed dial (Stream Deck +). See also {@link onDialDown}. + * + * NB: For other action types see {@link onKeyUp}. + * @template T The type of settings associated with the action. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onDialUp(listener: (ev: DialUpEvent) => void): IDisposable { + return connection.disposableOn("dialUp", (ev: DialUp) => { + const action = actionStore.getActionById(ev.context); + if (action?.isDial()) { + listener(new ActionEvent(action, ev)); + } + }); + } -/** - * Occurs when the user updates an action's title settings in the Stream Deck application. See also {@link Action.setTitle}. - * @template T The type of settings associated with the action. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. - */ -export function onTitleParametersDidChange(listener: (ev: TitleParametersDidChangeEvent) => void): IDisposable { - return connection.disposableOn("titleParametersDidChange", (ev: TitleParametersDidChange) => { - const action = actionStore.getActionById(ev.context); - if (action?.isKey()) { - listener(new ActionEvent(action, ev)); - } - }); -} + /** + * Occurs when the user presses a action down. See also {@link onKeyUp}. + * + * NB: For dials / touchscreens see {@link onDialDown}. + * @template T The type of settings associated with the action. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onKeyDown(listener: (ev: KeyDownEvent) => void): IDisposable { + return connection.disposableOn("keyDown", (ev: KeyDown) => { + const action = actionStore.getActionById(ev.context); + if (action?.isKey() || action?.isMultiActionKey()) { + listener(new ActionEvent(action, ev)); + } + }); + } -/** - * Occurs when the user taps the touchscreen (Stream Deck +). - * @template T The type of settings associated with the action. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. - */ -export function onTouchTap(listener: (ev: TouchTapEvent) => void): IDisposable { - return connection.disposableOn("touchTap", (ev: TouchTap) => { - const action = actionStore.getActionById(ev.context); - if (action?.isDial()) { - listener(new ActionEvent(action, ev)); - } - }); -} + /** + * Occurs when the user releases a pressed action. See also {@link onKeyDown}. + * + * NB: For dials / touchscreens see {@link onDialUp}. + * @template T The type of settings associated with the action. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onKeyUp(listener: (ev: KeyUpEvent) => void): IDisposable { + return connection.disposableOn("keyUp", (ev: KeyUp) => { + const action = actionStore.getActionById(ev.context); + if (action?.isKey() || action?.isMultiActionKey()) { + listener(new ActionEvent(action, ev)); + } + }); + } -/** - * Occurs when an action appears on the Stream Deck due to the user navigating to another page, profile, folder, etc. This also occurs during startup if the action is on the "front - * page". An action refers to _all_ types of actions, e.g. keys, dials, - * @template T The type of settings associated with the action. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. - */ -export function onWillAppear(listener: (ev: WillAppearEvent) => void): IDisposable { - return connection.disposableOn("willAppear", (ev: WillAppear) => { - const action = actionStore.getActionById(ev.context); - if (action) { - listener(new ActionEvent(action, ev)); - } - }); -} + /** + * Occurs when the user updates an action's title settings in the Stream Deck application. See also {@link Action.setTitle}. + * @template T The type of settings associated with the action. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onTitleParametersDidChange(listener: (ev: TitleParametersDidChangeEvent) => void): IDisposable { + return connection.disposableOn("titleParametersDidChange", (ev: TitleParametersDidChange) => { + const action = actionStore.getActionById(ev.context); + if (action?.isKey()) { + listener(new ActionEvent(action, ev)); + } + }); + } -/** - * Occurs when an action disappears from the Stream Deck due to the user navigating to another page, profile, folder, etc. An action refers to _all_ types of actions, e.g. keys, - * dials, touchscreens, pedals, etc. - * @template T The type of settings associated with the action. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. - */ -export function onWillDisappear(listener: (ev: WillDisappearEvent) => void): IDisposable { - return connection.disposableOn("willDisappear", (ev: WillDisappear) => { - const device = devices.getDeviceById(ev.device); - if (device) { - listener( - new ActionEvent( - { - device, - id: ev.context, - manifestId: ev.action - }, - ev - ) - ); - } - }); -} + /** + * Occurs when the user taps the touchscreen (Stream Deck +). + * @template T The type of settings associated with the action. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onTouchTap(listener: (ev: TouchTapEvent) => void): IDisposable { + return connection.disposableOn("touchTap", (ev: TouchTap) => { + const action = actionStore.getActionById(ev.context); + if (action?.isDial()) { + listener(new ActionEvent(action, ev)); + } + }); + } -/** - * Registers the action with the Stream Deck, routing all events associated with the {@link SingletonAction.manifestId} to the specified {@link action}. - * @param action The action to register. - * @example - * ï¼ action({ UUID: "com.elgato.test.action" }) - * class MyCustomAction extends SingletonAction { - * export function onKeyDown(ev: KeyDownEvent) { - * // Do some awesome thing. - * } - * } - * - * streamDeck.actions.registerAction(new MyCustomAction()); - */ -export function registerAction, TSettings extends JsonObject = JsonObject>(action: TAction): void { - if (action.manifestId === undefined) { - throw new Error("The action's manifestId cannot be undefined."); + /** + * Occurs when an action appears on the Stream Deck due to the user navigating to another page, profile, folder, etc. This also occurs during startup if the action is on the "front + * page". An action refers to _all_ types of actions, e.g. keys, dials, + * @template T The type of settings associated with the action. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onWillAppear(listener: (ev: WillAppearEvent) => void): IDisposable { + return connection.disposableOn("willAppear", (ev: WillAppear) => { + const action = actionStore.getActionById(ev.context); + if (action) { + listener(new ActionEvent(action, ev)); + } + }); } - if (!manifest.Actions.some((a) => a.UUID === action.manifestId)) { - throw new Error(`The action's manifestId was not found within the manifest: ${action.manifestId}`); + /** + * Occurs when an action disappears from the Stream Deck due to the user navigating to another page, profile, folder, etc. An action refers to _all_ types of actions, e.g. keys, + * dials, touchscreens, pedals, etc. + * @template T The type of settings associated with the action. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onWillDisappear(listener: (ev: WillDisappearEvent) => void): IDisposable { + return connection.disposableOn("willDisappear", (ev: WillDisappear) => { + const device = devices.getDeviceById(ev.device); + if (device) { + listener( + new ActionEvent( + { + device, + id: ev.context, + manifestId: ev.action + }, + ev + ) + ); + } + }); } - // Routes an event to the action, when the applicable listener is defined on the action. - const { manifestId } = action; - const route = >( - fn: (listener: (ev: TEventArgs) => void) => IDisposable, - listener: ((ev: TEventArgs) => Promise | void) | undefined - ): void => { - const boundedListener = listener?.bind(action); - if (boundedListener === undefined) { - return; + /** + * Registers the action with the Stream Deck, routing all events associated with the {@link SingletonAction.manifestId} to the specified {@link action}. + * @param action The action to register. + * @example + * ï¼ action({ UUID: "com.elgato.test.action" }) + * class MyCustomAction extends SingletonAction { + * export function onKeyDown(ev: KeyDownEvent) { + * // Do some awesome thing. + * } + * } + * + * streamDeck.actions.registerAction(new MyCustomAction()); + */ + public registerAction, TSettings extends JsonObject = JsonObject>(action: TAction): void { + if (action.manifestId === undefined) { + throw new Error("The action's manifestId cannot be undefined."); + } + + if (!manifest.Actions.some((a) => a.UUID === action.manifestId)) { + throw new Error(`The action's manifestId was not found within the manifest: ${action.manifestId}`); } - fn.bind(action)(async (ev) => { - if (ev.action.manifestId == manifestId) { - await boundedListener(ev); + // Routes an event to the action, when the applicable listener is defined on the action. + const { manifestId } = action; + const route = >( + fn: (listener: (ev: TEventArgs) => void) => IDisposable, + listener: ((ev: TEventArgs) => Promise | void) | undefined + ): void => { + const boundedListener = listener?.bind(action); + if (boundedListener === undefined) { + return; } - }); - }; - - // Route each of the action events. - route(onDialDown, action.onDialDown); - route(onDialUp, action.onDialUp); - route(onDialRotate, action.onDialRotate); - route(ui.onSendToPlugin, action.onSendToPlugin); - route(onDidReceiveSettings, action.onDidReceiveSettings); - route(onKeyDown, action.onKeyDown); - route(onKeyUp, action.onKeyUp); - route(ui.onDidAppear, action.onPropertyInspectorDidAppear); - route(ui.onDidDisappear, action.onPropertyInspectorDidDisappear); - route(onTitleParametersDidChange, action.onTitleParametersDidChange); - route(onTouchTap, action.onTouchTap); - route(onWillAppear, action.onWillAppear); - route(onWillDisappear, action.onWillDisappear); + + fn.bind(action)(async (ev) => { + if (ev.action.manifestId == manifestId) { + await boundedListener(ev); + } + }); + }; + + // Route each of the action events. + route(this.onDialDown, action.onDialDown); + route(this.onDialUp, action.onDialUp); + route(this.onDialRotate, action.onDialRotate); + route(ui.onSendToPlugin, action.onSendToPlugin); + route(onDidReceiveSettings, action.onDidReceiveSettings); + route(this.onKeyDown, action.onKeyDown); + route(this.onKeyUp, action.onKeyUp); + route(ui.onDidAppear, action.onPropertyInspectorDidAppear); + route(ui.onDidDisappear, action.onPropertyInspectorDidDisappear); + route(this.onTitleParametersDidChange, action.onTitleParametersDidChange); + route(this.onTouchTap, action.onTouchTap); + route(this.onWillAppear, action.onWillAppear); + route(this.onWillDisappear, action.onWillDisappear); + } } +/** + * Service for interacting with Stream Deck actions. + */ +export const actionService = new ActionService(); + +export { type ActionService }; + /** * Event associated with an {@link Action}. */ diff --git a/src/plugin/index.ts b/src/plugin/index.ts index ff3a2769..a7269ea4 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -1,7 +1,7 @@ import type { Manifest, RegistrationInfo } from "../api"; import { I18nProvider } from "../common/i18n"; import { registerCreateLogEntryRoute, type Logger } from "../common/logging"; -import * as actions from "./actions"; +import { actionService, type ActionService } from "./actions"; import { connection } from "./connection"; import { devices } from "./devices"; import { fileSystemLocaleProvider } from "./i18n"; @@ -54,8 +54,8 @@ export const streamDeck = { * Namespace for event listeners and functionality relating to Stream Deck actions. * @returns Actions namespace. */ - get actions(): typeof actions { - return actions; + get actions(): ActionService { + return actionService; }, /** From 995ca5ca181a991a92500734bad6f2cdf5ff1689 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Wed, 18 Sep 2024 19:01:55 +0100 Subject: [PATCH 20/29] feat: add visible actions to SingletonAction --- src/plugin/actions/singleton-action.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/plugin/actions/singleton-action.ts b/src/plugin/actions/singleton-action.ts index 1a6fb9ba..1d72587b 100644 --- a/src/plugin/actions/singleton-action.ts +++ b/src/plugin/actions/singleton-action.ts @@ -1,5 +1,7 @@ -import type streamDeck from "../"; import type { JsonObject, JsonValue } from "../../common/json"; +import type { DialAction } from "../actions/dial"; +import type { KeyAction } from "../actions/key"; +import type { MultiActionKey } from "../actions/multi"; import type { DialDownEvent, DialRotateEvent, @@ -16,6 +18,7 @@ import type { WillDisappearEvent } from "../events"; import type { Action } from "./action"; +import { actionStore } from "./store"; /** * Provides the main bridge between the plugin and the Stream Deck allowing the plugin to send requests and receive events, e.g. when the user presses an action. @@ -27,6 +30,14 @@ export class SingletonAction { */ public readonly manifestId: string | undefined; + /** + * Gets the visible actions with the `manifestId` that match this instance's. + * @returns The visible actions. + */ + public get actions(): IterableIterator | KeyAction | MultiActionKey> { + return actionStore.filter((a) => a.manifestId === this.manifestId); + } + /** * Occurs when the user presses a dial (Stream Deck +). See also {@link SingletonAction.onDialUp}. * From 0a9fff34f9954d7729d94881c93026f4479c73a8 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Wed, 18 Sep 2024 20:08:08 +0100 Subject: [PATCH 21/29] refactor: merge MultiActionKey in KeyAction --- src/plugin/actions/__tests__/action.test.ts | 8 +- src/plugin/actions/__tests__/multi.test.ts | 86 ---------------- src/plugin/actions/action.ts | 86 ++++------------ src/plugin/actions/dial.ts | 95 ++++++------------ src/plugin/actions/index.ts | 21 ++-- src/plugin/actions/key.ts | 59 ++++++----- src/plugin/actions/multi.ts | 40 -------- src/plugin/actions/singleton-action.ts | 3 +- src/plugin/actions/store.ts | 104 +++++++++++--------- src/plugin/devices/device.ts | 3 +- src/plugin/events/index.ts | 21 ++-- src/plugin/index.ts | 7 +- src/plugin/ui/property-inspector.ts | 3 +- 13 files changed, 166 insertions(+), 370 deletions(-) delete mode 100644 src/plugin/actions/__tests__/multi.test.ts delete mode 100644 src/plugin/actions/multi.ts diff --git a/src/plugin/actions/__tests__/action.test.ts b/src/plugin/actions/__tests__/action.test.ts index fb042481..7f9c0d69 100644 --- a/src/plugin/actions/__tests__/action.test.ts +++ b/src/plugin/actions/__tests__/action.test.ts @@ -3,7 +3,7 @@ import { Settings } from "../../../api/__mocks__/events"; import { connection } from "../../connection"; import { Device } from "../../devices/device"; import { Action, type ActionContext } from "../action"; -import { MultiActionKey } from "../multi"; +import { KeyAction } from "../key"; jest.mock("../../logging"); jest.mock("../../manifest"); @@ -36,7 +36,7 @@ describe("Action", () => { }; // Act. - const action = new MultiActionKey(context); + const action = new KeyAction(context); // Assert. expect(action).toBeInstanceOf(Action); @@ -50,7 +50,7 @@ describe("Action", () => { */ it("getSettings", async () => { // Arrange. - const action = new MultiActionKey({ + const action = new KeyAction({ device, id: "ABC123", manifestId: "com.elgato.test.one" @@ -114,7 +114,7 @@ describe("Action", () => { }); describe("sending", () => { - const action = new MultiActionKey({ + const action = new KeyAction({ device, id: "ABC123", manifestId: "com.elgato.test.one" diff --git a/src/plugin/actions/__tests__/multi.test.ts b/src/plugin/actions/__tests__/multi.test.ts deleted file mode 100644 index 82c6a7d8..00000000 --- a/src/plugin/actions/__tests__/multi.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { DeviceType, type SetState } from "../../../api"; -import { connection } from "../../connection"; -import { Device } from "../../devices/device"; -import { Action, type ActionContext } from "../action"; -import { MultiActionKey } from "../multi"; - -jest.mock("../../logging"); -jest.mock("../../manifest"); -jest.mock("../../connection"); - -describe("KeyMultiAction", () => { - // Mock device. - const device = new Device( - "dev123", - { - name: "Device One", - size: { - columns: 5, - rows: 3 - }, - type: DeviceType.StreamDeck - }, - false - ); - - /** - * Asserts the constructor of {@link MultiActionKey} sets the context. - */ - it("constructor sets context", () => { - // Arrange. - const context: ActionContext = { - device, - id: "ABC123", - manifestId: "com.elgato.test.one" - }; - - // Act. - const multiActionKey = new MultiActionKey(context); - - // Assert. - expect(multiActionKey.device).toBe(context.device); - expect(multiActionKey.id).toBe(context.id); - expect(multiActionKey.manifestId).toBe(context.manifestId); - }); - - /** - * Asserts the inheritance of {@link MultiActionKey}. - */ - it("inherits shared methods", () => { - // Arrange, act. - const multiActionKey = new MultiActionKey({ - device, - id: "ABC123", - manifestId: "com.elgato.test.one" - }); - - // Assert. - expect(multiActionKey).toBeInstanceOf(Action); - }); - - describe("sending", () => { - const multiActionKey = new MultiActionKey({ - device, - id: "ABC123", - manifestId: "com.elgato.test.one" - }); - - /** - * Asserts {@link MultiActionKey.setState} forwards the command to the {@link connection}. - */ - it("setState", async () => { - // Arrange, act. - await multiActionKey.setState(1); - - // Assert. - expect(connection.send).toHaveBeenCalledTimes(1); - expect(connection.send).toHaveBeenCalledWith<[SetState]>({ - context: multiActionKey.id, - event: "setState", - payload: { - state: 1 - } - }); - }); - }); -}); diff --git a/src/plugin/actions/action.ts b/src/plugin/actions/action.ts index 621fd8e4..f88c380a 100644 --- a/src/plugin/actions/action.ts +++ b/src/plugin/actions/action.ts @@ -1,19 +1,19 @@ import type streamDeck from "../"; -import type { Coordinates, DidReceiveSettings, SetImage, SetTitle } from "../../api"; + +import type { DidReceiveSettings } from "../../api"; import type { JsonObject, JsonValue } from "../../common/json"; -import type { KeyOf } from "../../common/utils"; import { connection } from "../connection"; import type { Device } from "../devices"; import type { DialAction } from "./dial"; import type { KeyAction } from "./key"; -import type { MultiActionKey } from "./multi"; import type { SingletonAction } from "./singleton-action"; +import type { ActionContext } from "./store"; /** * Provides a contextualized instance of an {@link Action}, allowing for direct communication with the Stream Deck. * @template T The type of settings associated with the action. */ -export abstract class Action implements ActionContext { +export class Action { /** * The action context. */ @@ -48,12 +48,6 @@ export abstract class Action implements Actio return this.#context.manifestId; } - /** - * Underlying type of the action. - * @returns The type. - */ - protected abstract get type(): ActionType; - /** * Gets the settings associated this action instance. * @template U The type of settings associated with the action. @@ -77,27 +71,19 @@ export abstract class Action implements Actio } /** - * Determines whether this instance is a dial action. + * Determines whether this instance is a dial. * @returns `true` when this instance is a dial; otherwise `false`. */ public isDial(): this is DialAction { - return this.type === "Dial"; + return this.#context.controller === "Encoder"; } /** - * Determines whether this instance is a key action. + * Determines whether this instance is a key. * @returns `true` when this instance is a key; otherwise `false`. */ public isKey(): this is KeyAction { - return this.type === "Key"; - } - - /** - * Determines whether this instance is a multi-action key. - * @returns `true` when this instance is a multi-action key; otherwise `false`. - */ - public isMultiActionKey(): this is MultiActionKey { - return this.type === "MultiActionKey"; + return this.#context.controller === "Keypad"; } /** @@ -127,53 +113,15 @@ export abstract class Action implements Actio payload: settings }); } -} - -/** - * Options that define how to render an image associated with an action. - */ -export type ImageOptions = Omit, "image">; - -/** - * Options that define how to render a title associated with an action. - */ -export type TitleOptions = Omit, "title">; - -/** - * Action type, for example dial or key. - */ -export type ActionType = "Dial" | "Key" | "MultiActionKey"; - -/** - * Provides context information for an instance of an action. - */ -export type ActionContext = { - /** - * Stream Deck device the action is positioned on. - * @returns Stream Deck device. - */ - get device(): Device; /** - * Action instance identifier. - * @returns Identifier. + * Temporarily shows an alert (i.e. warning), in the form of an exclamation mark in a yellow triangle, on this action instance. Used to provide visual feedback when an action failed. + * @returns `Promise` resolved when the request to show an alert has been sent to Stream Deck. */ - get id(): string; - - /** - * Manifest identifier (UUID) for this action type. - * @returns Manifest identifier. - */ - get manifestId(): string; -}; - -/** - * Provides context information for an instance of an action, with coordinates. - */ -export type CoordinatedActionContext = ActionContext & { - /** - * Coordinates of the action, on the Stream Deck device. - * @returns Coordinates. - */ - get coordinates(): Readonly; -}; + public showAlert(): Promise { + return connection.send({ + event: "showAlert", + context: this.id + }); + } +} diff --git a/src/plugin/actions/dial.ts b/src/plugin/actions/dial.ts index f37c6fd2..f8ff6e20 100644 --- a/src/plugin/actions/dial.ts +++ b/src/plugin/actions/dial.ts @@ -1,40 +1,59 @@ -import type { Coordinates, FeedbackPayload, SetTriggerDescription } from "../../api"; +import type { Coordinates, FeedbackPayload, SetTriggerDescription, WillAppear } from "../../api"; import type { JsonObject } from "../../common/json"; import type { KeyOf } from "../../common/utils"; import { connection } from "../connection"; -import { Action, type ActionType, type CoordinatedActionContext, type ImageOptions, type TitleOptions } from "./action"; +import { Action } from "./action"; +import type { ActionContext } from "./store"; /** * Provides a contextualized instance of a dial action. * @template T The type of settings associated with the action. */ -export class DialAction extends Action implements CoordinatedActionContext { +export class DialAction extends Action { /** - * The action context. + * Private backing field for {@link coordinates}. */ - readonly #context: CoordinatedActionContext; + readonly #coordinates: Readonly; /** * Initializes a new instance of the {@see DialAction} class. * @param context Action context. + * @param source Source of the action. */ - constructor(context: CoordinatedActionContext) { + constructor(context: ActionContext, source: WillAppear) { super(context); - this.#context = context; + + if (source.payload.controller === "Keypad") { + throw new Error("Unable to create DialAction from Keypad"); + } + + this.#coordinates = Object.freeze(source.payload.coordinates); } /** - * @inheritdoc + * Coordinates of the dial. + * @returns The coordinates. */ - public get coordinates(): Coordinates { - return this.#context.coordinates; + public get coordinates(): Readonly { + return this.#coordinates; } /** - * @inheritdoc + * Sets the {@link image} to be display for this action instance within Stream Deck app. + * + * NB: The image can only be set by the plugin when the the user has not specified a custom image. + * @param image Image to display; this can be either a path to a local file within the plugin's folder, a base64 encoded `string` with the mime type declared (e.g. PNG, JPEG, etc.), + * or an SVG `string`. When `undefined`, the image from the manifest will be used. + * @returns `Promise` resolved when the request to set the {@link image} has been sent to Stream Deck. */ - protected override get type(): ActionType { - return "Dial"; + public setImage(image?: string): Promise { + return connection.send({ + event: "setImage", + context: this.id, + payload: { + image + } + }); } /** @@ -70,45 +89,6 @@ export class DialAction extends Action imp }); } - /** - * Sets the {@link image} to be display for this action instance. - * - * NB: The image can only be set by the plugin when the the user has not specified a custom image. - * @param image Image to display; this can be either a path to a local file within the plugin's folder, a base64 encoded `string` with the mime type declared (e.g. PNG, JPEG, etc.), - * or an SVG `string`. When `undefined`, the image from the manifest will be used. - * @param options Additional options that define where and how the image should be rendered. - * @returns `Promise` resolved when the request to set the {@link image} has been sent to Stream Deck. - */ - public setImage(image?: string, options?: ImageOptions): Promise { - return connection.send({ - event: "setImage", - context: this.id, - payload: { - image, - ...options - } - }); - } - - /** - * Sets the {@link title} displayed for this action instance. - * - * NB: The title can only be set by the plugin when the the user has not specified a custom title. - * @param title Title to display; when `undefined` the title within the manifest will be used. - * @param options Additional options that define where and how the title should be rendered. - * @returns `Promise` resolved when the request to set the {@link title} has been sent to Stream Deck. - */ - public setTitle(title?: string, options?: TitleOptions): Promise { - return connection.send({ - event: "setTitle", - context: this.id, - payload: { - title, - ...options - } - }); - } - /** * Sets the trigger (interaction) {@link descriptions} associated with this action instance. Descriptions are shown within the Stream Deck application, and informs the user what * will happen when they interact with the action, e.g. rotate, touch, etc. When {@link descriptions} is `undefined`, the descriptions will be reset to the values provided as part @@ -125,17 +105,6 @@ export class DialAction extends Action imp payload: descriptions || {} }); } - - /** - * Temporarily shows an alert (i.e. warning), in the form of an exclamation mark in a yellow triangle, on this action instance. Used to provide visual feedback when an action failed. - * @returns `Promise` resolved when the request to show an alert has been sent to Stream Deck. - */ - public showAlert(): Promise { - return connection.send({ - event: "showAlert", - context: this.id - }); - } } /** diff --git a/src/plugin/actions/index.ts b/src/plugin/actions/index.ts index 2c22dedb..a359d455 100644 --- a/src/plugin/actions/index.ts +++ b/src/plugin/actions/index.ts @@ -18,9 +18,9 @@ import { import { getManifest } from "../manifest"; import { onDidReceiveSettings } from "../settings"; import { ui } from "../ui"; -import { Action, type ActionContext } from "./action"; +import { Action } from "./action"; import type { SingletonAction } from "./singleton-action"; -import { ActionStore, actionStore } from "./store"; +import { ActionStore, actionStore, createContext, type ActionContext } from "./store"; const manifest = getManifest(); @@ -88,7 +88,7 @@ class ActionService extends ActionStore { public onKeyDown(listener: (ev: KeyDownEvent) => void): IDisposable { return connection.disposableOn("keyDown", (ev: KeyDown) => { const action = actionStore.getActionById(ev.context); - if (action?.isKey() || action?.isMultiActionKey()) { + if (action?.isKey()) { listener(new ActionEvent(action, ev)); } }); @@ -105,7 +105,7 @@ class ActionService extends ActionStore { public onKeyUp(listener: (ev: KeyUpEvent) => void): IDisposable { return connection.disposableOn("keyUp", (ev: KeyUp) => { const action = actionStore.getActionById(ev.context); - if (action?.isKey() || action?.isMultiActionKey()) { + if (action?.isKey()) { listener(new ActionEvent(action, ev)); } }); @@ -120,7 +120,7 @@ class ActionService extends ActionStore { public onTitleParametersDidChange(listener: (ev: TitleParametersDidChangeEvent) => void): IDisposable { return connection.disposableOn("titleParametersDidChange", (ev: TitleParametersDidChange) => { const action = actionStore.getActionById(ev.context); - if (action?.isKey()) { + if (action) { listener(new ActionEvent(action, ev)); } }); @@ -168,16 +168,7 @@ class ActionService extends ActionStore { return connection.disposableOn("willDisappear", (ev: WillDisappear) => { const device = devices.getDeviceById(ev.device); if (device) { - listener( - new ActionEvent( - { - device, - id: ev.context, - manifestId: ev.action - }, - ev - ) - ); + listener(new ActionEvent(createContext(ev), ev)); } }); } diff --git a/src/plugin/actions/key.ts b/src/plugin/actions/key.ts index ab0261f5..f022aea2 100644 --- a/src/plugin/actions/key.ts +++ b/src/plugin/actions/key.ts @@ -1,39 +1,51 @@ -import type { Coordinates, State } from "../../api"; +import type { Coordinates, SetImage, SetTitle, State, WillAppear } from "../../api"; import type { JsonObject } from "../../common/json"; +import type { KeyOf } from "../../common/utils"; import { connection } from "../connection"; -import { Action, type ActionType, type CoordinatedActionContext, type ImageOptions, type TitleOptions } from "./action"; +import { Action } from "./action"; +import type { ActionContext } from "./store"; /** * Provides a contextualized instance of a key action. * @template T The type of settings associated with the action. */ -export class KeyAction extends Action implements CoordinatedActionContext { +export class KeyAction extends Action { /** - * The action context. + * Private backing field for {@link coordinates}. */ - readonly #context: CoordinatedActionContext; + readonly #coordinates: Readonly | undefined; + + /** + * Source of the action. + */ + readonly #source: WillAppear; /** * Initializes a new instance of the {@see KeyAction} class. * @param context Action context. + * @param source Source of the action. */ - constructor(context: CoordinatedActionContext) { + constructor(context: ActionContext, source: WillAppear) { super(context); - this.#context = context; + + this.#coordinates = !source.payload.isInMultiAction ? Object.freeze(source.payload.coordinates) : undefined; + this.#source = source; } /** - * @inheritdoc + * Coordinates of the key; otherwise `undefined` when the action is part of a multi-action. + * @returns The coordinates. */ - public get coordinates(): Coordinates { - return this.#context.coordinates; + public get coordinates(): Coordinates | undefined { + return this.#coordinates; } /** - * @inheritdoc + * Determines whether the key is part of a multi-action. + * @returns `true` when in a multi-action; otherwise `false`. */ - protected override get type(): ActionType { - return "Key"; + public get isInMultiAction(): boolean { + return this.#source.payload.isInMultiAction; } /** @@ -90,17 +102,6 @@ export class KeyAction extends Action impl }); } - /** - * Temporarily shows an alert (i.e. warning), in the form of an exclamation mark in a yellow triangle, on this action instance. Used to provide visual feedback when an action failed. - * @returns `Promise` resolved when the request to show an alert has been sent to Stream Deck. - */ - public showAlert(): Promise { - return connection.send({ - event: "showAlert", - context: this.id - }); - } - /** * Temporarily shows an "OK" (i.e. success), in the form of a check-mark in a green circle, on this action instance. Used to provide visual feedback when an action successfully * executed. @@ -113,3 +114,13 @@ export class KeyAction extends Action impl }); } } + +/** + * Options that define how to render an image associated with an action. + */ +export type ImageOptions = Omit, "image">; + +/** + * Options that define how to render a title associated with an action. + */ +export type TitleOptions = Omit, "title">; diff --git a/src/plugin/actions/multi.ts b/src/plugin/actions/multi.ts deleted file mode 100644 index 7d4c3ad2..00000000 --- a/src/plugin/actions/multi.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { State } from "../../api"; -import type { JsonObject } from "../../common/json"; -import { connection } from "../connection"; -import { Action, type ActionContext, type ActionType } from "./action"; - -/** - * Provides a contextualized instance of a key action, within a multi-action. - * @template T The type of settings associated with the action. - */ -export class MultiActionKey extends Action { - /** - * Initializes a new instance of the {@see KeyMultiAction} class. - * @param context Action context. - */ - constructor(context: ActionContext) { - super(context); - } - - /** - * @inheritdoc - */ - protected override get type(): ActionType { - return "MultiActionKey"; - } - - /** - * Sets the current {@link state} of this action instance; only applies to actions that have multiple states defined within the manifest. - * @param state State to set; this be either 0, or 1. - * @returns `Promise` resolved when the request to set the state of an action instance has been sent to Stream Deck. - */ - public setState(state: State): Promise { - return connection.send({ - event: "setState", - context: this.id, - payload: { - state - } - }); - } -} diff --git a/src/plugin/actions/singleton-action.ts b/src/plugin/actions/singleton-action.ts index 1d72587b..48005bb2 100644 --- a/src/plugin/actions/singleton-action.ts +++ b/src/plugin/actions/singleton-action.ts @@ -1,7 +1,6 @@ import type { JsonObject, JsonValue } from "../../common/json"; import type { DialAction } from "../actions/dial"; import type { KeyAction } from "../actions/key"; -import type { MultiActionKey } from "../actions/multi"; import type { DialDownEvent, DialRotateEvent, @@ -34,7 +33,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 | MultiActionKey> { + public get actions(): IterableIterator | KeyAction> { return actionStore.filter((a) => a.manifestId === this.manifestId); } diff --git a/src/plugin/actions/store.ts b/src/plugin/actions/store.ts index 7f1716f3..a800b8b3 100644 --- a/src/plugin/actions/store.ts +++ b/src/plugin/actions/store.ts @@ -1,61 +1,25 @@ -import type { WillAppear } from "../../api"; +import type { Controller, JsonObject } from ".."; +import type { WillAppear, WillDisappear } from "../../api"; import { Enumerable } from "../../common/enumerable"; -import type { JsonObject } from "../../common/json"; import { connection } from "../connection"; -import type { DeviceCollection } from "../devices"; -import { type ActionContext } from "./action"; +import type { Device, DeviceCollection } from "../devices"; import { DialAction } from "./dial"; import { KeyAction } from "./key"; -import { MultiActionKey } from "./multi"; -const __actions = new Map(); +const __actions = new Map(); let __devices: DeviceCollection | undefined; // Adds the action to the store. connection.prependListener("willAppear", (ev) => { - if (__devices === undefined) { - throw new Error("Action store has not been initialized"); - } + const context = createContext(ev); + const action = ev.payload.controller === "Encoder" ? new DialAction(context, ev) : new KeyAction(context, ev); - const context: ActionContext = { - device: __devices.getDeviceById(ev.device)!, - id: ev.context, - manifestId: ev.action - }; - - __actions.set(ev.context, create(ev, context)); + __actions.set(ev.context, action); }); // Remove the action from the store. connection.prependListener("willDisappear", (ev) => __actions.delete(ev.context)); -/** - * Creates a new action from the event information, using the context. - * @param ev Source appearance event. - * @param context Context of the action. - * @returns The new action. - */ -function create(ev: WillAppear, context: ActionContext): DialAction | KeyAction | MultiActionKey { - // Dial. - if (ev.payload.controller === "Encoder") { - return new DialAction({ - ...context, - coordinates: Object.freeze(ev.payload.coordinates) - }); - } - - // Multi-action key - if (ev.payload.isInMultiAction) { - return new MultiActionKey(context); - } - - // Key action. - return new KeyAction({ - ...context, - coordinates: Object.freeze(ev.payload.coordinates) - }); -} - /** * Initializes the action store, allowing for actions to be associated with devices. * @param devices Collection of devices. @@ -71,7 +35,7 @@ export function initializeStore(devices: DeviceCollection): void { /** * Provides a store of visible actions. */ -export class ActionStore extends Enumerable { +export class ActionStore extends Enumerable { /** * Initializes a new instance of the {@link ActionStore} class. */ @@ -84,7 +48,7 @@ export class ActionStore extends Enumerable | WillDisappear): ActionContext { + if (__devices === undefined) { + throw new Error("Action store must be initialized before creating an action's context"); + } + + return { + controller: source.payload.controller, + device: __devices.getDeviceById(source.device)!, + id: source.context, + manifestId: source.action + }; +} diff --git a/src/plugin/devices/device.ts b/src/plugin/devices/device.ts index e40e5a0f..85b05fae 100644 --- a/src/plugin/devices/device.ts +++ b/src/plugin/devices/device.ts @@ -1,7 +1,6 @@ import type { DeviceInfo, DeviceType, Size } from "../../api"; import type { DialAction } from "../actions/dial"; import type { KeyAction } from "../actions/key"; -import type { MultiActionKey } from "../actions/multi"; import { actionStore } from "../actions/store"; import { connection } from "../connection"; @@ -55,7 +54,7 @@ export class Device { * Actions currently visible on the device. * @returns Collection of visible actions. */ - public get actions(): IterableIterator { + public get actions(): IterableIterator { return actionStore.filter((a) => a.device.id === this.id); } diff --git a/src/plugin/events/index.ts b/src/plugin/events/index.ts index 4f92f892..59239717 100644 --- a/src/plugin/events/index.ts +++ b/src/plugin/events/index.ts @@ -19,10 +19,9 @@ import type { } from "../../api"; import { ActionWithoutPayloadEvent, Event, type ActionEvent } from "../../common/events"; import type { JsonObject } from "../../common/json"; -import type { ActionContext } from "../actions/action"; import type { DialAction } from "../actions/dial"; import type { KeyAction } from "../actions/key"; -import type { MultiActionKey } from "../actions/multi"; +import type { ActionContext } from "../actions/store"; import type { Device } from "../devices"; import { ApplicationEvent } from "./application-event"; import { DeviceEvent } from "./device-event"; @@ -70,27 +69,24 @@ export type DialUpEvent = ActionEvent /** * Event information received from Stream Deck when the plugin receives settings. */ -export type DidReceiveSettingsEvent = ActionEvent< - DidReceiveSettings, - DialAction | KeyAction | MultiActionKey ->; +export type DidReceiveSettingsEvent = ActionEvent, DialAction | KeyAction>; /** * Event information received from Stream Deck when a key is pressed down. */ -export type KeyDownEvent = ActionEvent, KeyAction | MultiActionKey>; +export type KeyDownEvent = ActionEvent, KeyAction>; /** * Event information received from Stream Deck when a pressed key is release. */ -export type KeyUpEvent = ActionEvent, KeyAction | MultiActionKey>; +export type KeyUpEvent = ActionEvent, KeyAction>; /** * Event information received from Stream Deck when the property inspector appears. */ export type PropertyInspectorDidAppearEvent = ActionWithoutPayloadEvent< PropertyInspectorDidAppear, - DialAction | KeyAction | MultiActionKey + DialAction | KeyAction >; /** @@ -98,7 +94,7 @@ export type PropertyInspectorDidAppearEvent = ActionWithoutPayloadEvent< PropertyInspectorDidDisappear, - DialAction | KeyAction | MultiActionKey + DialAction | KeyAction >; /** @@ -122,10 +118,7 @@ export type TouchTapEvent = ActionEve /** * Event information received from Stream Deck when an action appears on the canvas. */ -export type WillAppearEvent = ActionEvent< - WillAppear, - DialAction | KeyAction | MultiActionKey ->; +export type WillAppearEvent = ActionEvent, DialAction | KeyAction>; /** * Event information received from Stream Deck when an action disappears from the canvas. diff --git a/src/plugin/index.ts b/src/plugin/index.ts index a7269ea4..d4941b42 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -36,11 +36,10 @@ export { EventEmitter, EventsOf } from "../common/event-emitter"; export { type JsonObject, type JsonPrimitive, type JsonValue } from "../common/json"; export { LogLevel } from "../common/logging"; export { type MessageRequestOptions, type MessageResponder, type MessageResponse, type RouteConfiguration, type StatusCode } from "../common/messaging"; -export { Action, ImageOptions, TitleOptions } from "./actions/action"; +export type { Action } from "./actions/action"; export { action } from "./actions/decorators"; -export { DialAction, TriggerDescriptionOptions } from "./actions/dial"; -export { KeyAction } from "./actions/key"; -export { MultiActionKey } from "./actions/multi"; +export { type DialAction, type TriggerDescriptionOptions } from "./actions/dial"; +export { type ImageOptions, type KeyAction, type TitleOptions } from "./actions/key"; export { SingletonAction } from "./actions/singleton-action"; export { type Device } from "./devices"; export * from "./events"; diff --git a/src/plugin/ui/property-inspector.ts b/src/plugin/ui/property-inspector.ts index 201812b5..bac870b0 100644 --- a/src/plugin/ui/property-inspector.ts +++ b/src/plugin/ui/property-inspector.ts @@ -5,7 +5,6 @@ import { PUBLIC_PATH_PREFIX, type MessageGateway, type MessageRequestOptions, ty import type { Action } from "../actions/action"; import type { DialAction } from "../actions/dial"; import type { KeyAction } from "../actions/key"; -import type { MultiActionKey } from "../actions/multi"; import type { SingletonAction } from "../actions/singleton-action"; import { actionStore } from "../actions/store"; import { connection } from "../connection"; @@ -17,7 +16,7 @@ export class PropertyInspector implements Pick, "fetch"> /** * Action associated with the property inspector */ - public readonly action: DialAction | KeyAction | MultiActionKey; + public readonly action: DialAction | KeyAction; /** * Initializes a new instance of the {@link PropertyInspector} class. From 8a94788b1898b163b0812f5485bc471cedd5b259 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sun, 22 Sep 2024 13:28:07 +0100 Subject: [PATCH 22/29] test: mock ActionStore (WIP) --- src/plugin/actions/__mocks__/store.ts | 62 ++++++-- src/plugin/actions/__tests__/dial.test.ts | 164 ++++++--------------- src/plugin/actions/__tests__/index.test.ts | 29 +++- src/plugin/actions/__tests__/key.test.ts | 127 +++++++--------- src/plugin/actions/dial.ts | 53 ++++--- src/plugin/actions/key.ts | 8 +- src/plugin/actions/store.ts | 2 +- src/plugin/ui/__tests__/router.test.ts | 60 ++++---- 8 files changed, 237 insertions(+), 268 deletions(-) diff --git a/src/plugin/actions/__mocks__/store.ts b/src/plugin/actions/__mocks__/store.ts index c8ce0bab..5e157dfb 100644 --- a/src/plugin/actions/__mocks__/store.ts +++ b/src/plugin/actions/__mocks__/store.ts @@ -1,27 +1,48 @@ +import type { WillAppear, WillDisappear } from "../../../api"; +import type { JsonObject } from "../../../common/json"; import { DialAction } from "../dial"; import { KeyAction } from "../key"; +import type { ActionContext } from "../store"; const { ActionStore, initializeStore: __initializeStore } = jest.requireActual("../store"); -const key = new KeyAction({ - id: "key123", - manifestId: "com.elgato.test.key", - coordinates: { - column: 1, - row: 1 +// Mock key. +const key = new KeyAction( + { + id: "key123", + manifestId: "com.elgato.test.key", + device: undefined!, + controller: "Keypad" }, - device: undefined! -}); + { + controller: "Keypad", + coordinates: { + column: 1, + row: 1 + }, + isInMultiAction: false, + settings: {} + } +); -const dial = new DialAction({ - id: "dial123", - manifestId: "com.elgato.test.dial", - coordinates: { - column: 1, - row: 1 +// Mock dial. +const dial = new DialAction( + { + id: "dial123", + manifestId: "com.elgato.test.dial", + device: undefined!, + controller: "Encoder" }, - device: undefined! -}); + { + controller: "Encoder", + coordinates: { + column: 1, + row: 1 + }, + isInMultiAction: false, + settings: {} + } +); export const actionStore = { getActionById: jest.fn().mockImplementation((id) => { @@ -42,3 +63,12 @@ __initializeStore({ export const initializeStore = jest.fn(); export { ActionStore }; + +export const createContext = jest.fn().mockImplementation((source: WillAppear | WillDisappear) => { + return { + controller: source.payload.controller, + device: undefined!, + id: source.context, + manifestId: source.action + } satisfies ActionContext; +}); diff --git a/src/plugin/actions/__tests__/dial.test.ts b/src/plugin/actions/__tests__/dial.test.ts index 6f3c27b1..57abaa2b 100644 --- a/src/plugin/actions/__tests__/dial.test.ts +++ b/src/plugin/actions/__tests__/dial.test.ts @@ -1,89 +1,62 @@ -import { DeviceType, Target, type SetFeedback, type SetFeedbackLayout, type SetImage, type SetTitle, type SetTriggerDescription, type ShowAlert } from "../../../api"; +import { type SetFeedback, type SetFeedbackLayout, type SetImage, type SetTriggerDescription, type WillAppear } from "../../../api"; +import type { JsonObject } from "../../../common/json"; import { connection } from "../../connection"; import { Device } from "../../devices"; -import { Action, type CoordinatedActionContext } from "../action"; +import { Action } from "../action"; import { DialAction } from "../dial"; +import type { ActionContext } from "../store"; jest.mock("../../logging"); jest.mock("../../manifest"); jest.mock("../../connection"); describe("DialAction", () => { - // Mock device. - const device = new Device( - "dev123", - { - name: "Device One", - size: { - columns: 5, - rows: 3 - }, - type: DeviceType.StreamDeck + // Mock context. + const context: ActionContext = { + // @ts-expect-error Mocked device. + device: new Device(), + controller: "Keypad", + id: "ABC123", + manifestId: "com.elgato.test.one" + }; + + // Mock source. + const source: WillAppear["payload"] = { + controller: "Encoder", + coordinates: { + column: 1, + row: 2 }, - false - ); + isInMultiAction: false, + settings: {} + }; /** * Asserts the constructor of {@link DialAction} sets the context. */ it("constructor sets context", () => { - // Arrange. - const source: CoordinatedActionContext = { - device, - id: "ABC123", - manifestId: "com.elgato.test.one", - coordinates: { - column: 1, - row: 2 - } - }; - - // Act. - const dialAction = new DialAction(source); - - // Assert. - expect(dialAction.coordinates).toBe(source.coordinates); - expect(dialAction.device).toBe(source.device); - expect(dialAction.id).toBe(source.id); - expect(dialAction.manifestId).toBe(source.manifestId); - }); - - /** - * Asserts the inheritance of {@link DialAction}. - */ - it("inherits shared methods", () => { // Arrange, act. - const dialAction = new DialAction({ - device, - id: "ABC123", - manifestId: "com.elgato.test.one", - coordinates: { - column: 1, - row: 2 - } - }); + const action = new DialAction(context, source); // Assert. - expect(dialAction).toBeInstanceOf(Action); + expect(action).toBeInstanceOf(Action); + expect(action.coordinates).not.toBeUndefined(); + expect(action.coordinates?.column).toBe(1); + expect(action.coordinates?.row).toBe(2); + expect(action.device).toBe(context.device); + expect(action.id).toBe(context.id); + expect(action.manifestId).toBe(context.manifestId); }); describe("sending", () => { - const dialAction = new DialAction({ - device, - id: "ABC123", - manifestId: "com.elgato.test.one", - coordinates: { - column: 1, - row: 2 - } - }); + const action = new DialAction(context, source); /** * Asserts {@link DialAction.setFeedback} forwards the command to the {@link connection}. */ it("setFeedback", async () => { // Arrange, act. - await dialAction.setFeedback({ + await action.setFeedback({ bar: 50, title: "Hello world" }); @@ -91,7 +64,7 @@ describe("DialAction", () => { // Assert. expect(connection.send).toHaveBeenCalledTimes(1); expect(connection.send).toHaveBeenCalledWith<[SetFeedback]>({ - context: dialAction.id, + context: action.id, event: "setFeedback", payload: { bar: 50, @@ -105,12 +78,12 @@ describe("DialAction", () => { */ it("Sends setFeedbackLayout", async () => { // Arrange, act. - await dialAction.setFeedbackLayout("CustomLayout.json"); + await action.setFeedbackLayout("CustomLayout.json"); // Assert. expect(connection.send).toHaveBeenCalledTimes(1); expect(connection.send).toHaveBeenCalledWith<[SetFeedbackLayout]>({ - context: dialAction.id, + context: action.id, event: "setFeedbackLayout", payload: { layout: "CustomLayout.json" @@ -123,16 +96,13 @@ describe("DialAction", () => { */ it("setImage", async () => { // Arrange, act - await dialAction.setImage(); - await dialAction.setImage("./imgs/test.png", { - state: 1, - target: Target.Hardware - }); + await action.setImage(); + await action.setImage("./imgs/test.png"); // Assert. expect(connection.send).toHaveBeenCalledTimes(2); expect(connection.send).toHaveBeenNthCalledWith<[SetImage]>(1, { - context: dialAction.id, + context: action.id, event: "setImage", payload: { image: undefined, @@ -142,41 +112,10 @@ describe("DialAction", () => { }); expect(connection.send).toHaveBeenNthCalledWith<[SetImage]>(2, { - context: dialAction.id, + context: action.id, event: "setImage", payload: { - image: "./imgs/test.png", - state: 1, - target: Target.Hardware - } - }); - }); - - /** - * Asserts {@link DialAction.setTitle} forwards the command to the {@link connection}. - */ - it("setTitle", async () => { - // Arrange, act. - await dialAction.setTitle("Hello world"); - await dialAction.setTitle("This is a test", { state: 1, target: Target.Software }); - - // Assert. - expect(connection.send).toHaveBeenCalledTimes(2); - expect(connection.send).toHaveBeenNthCalledWith<[SetTitle]>(1, { - event: "setTitle", - context: "ABC123", - payload: { - title: "Hello world" - } - }); - - expect(connection.send).toHaveBeenNthCalledWith<[SetTitle]>(2, { - event: "setTitle", - context: "ABC123", - payload: { - state: 1, - target: Target.Software, - title: "This is a test" + image: "./imgs/test.png" } }); }); @@ -186,8 +125,8 @@ describe("DialAction", () => { */ it("setTriggerDescription", async () => { // Arrange, act. - await dialAction.setTriggerDescription(); - await dialAction.setTriggerDescription({ + await action.setTriggerDescription(); + await action.setTriggerDescription({ longTouch: "Long-touch", push: "Push", rotate: "Rotate", @@ -198,13 +137,13 @@ describe("DialAction", () => { expect(connection.send).toHaveBeenCalledTimes(2); expect(connection.send).toHaveBeenNthCalledWith<[SetTriggerDescription]>(1, { event: "setTriggerDescription", - context: dialAction.id, + context: action.id, payload: {} }); expect(connection.send).toHaveBeenNthCalledWith<[SetTriggerDescription]>(2, { event: "setTriggerDescription", - context: dialAction.id, + context: action.id, payload: { longTouch: "Long-touch", push: "Push", @@ -213,20 +152,5 @@ describe("DialAction", () => { } }); }); - - /** - * Asserts {@link DialAction.showAlert} forwards the command to the {@link connection}. - */ - it("showAlert", async () => { - // Arrange, act. - await dialAction.showAlert(); - - // Assert. - expect(connection.send).toHaveBeenCalledTimes(1); - expect(connection.send).toHaveBeenCalledWith<[ShowAlert]>({ - context: dialAction.id, - event: "showAlert" - }); - }); }); }); diff --git a/src/plugin/actions/__tests__/index.test.ts b/src/plugin/actions/__tests__/index.test.ts index c6fcfe63..9bd6251f 100644 --- a/src/plugin/actions/__tests__/index.test.ts +++ b/src/plugin/actions/__tests__/index.test.ts @@ -1,18 +1,19 @@ import { actionService } from ".."; import { DeviceType, + SingletonAction, type DialAction, type DialDownEvent, type DialRotateEvent, type DialUpEvent, type DidReceiveSettingsEvent, + type JsonObject, type KeyAction, type KeyDownEvent, type KeyUpEvent, type PropertyInspectorDidAppearEvent, type PropertyInspectorDidDisappearEvent, type SendToPluginEvent, - type SingletonAction, type TitleParametersDidChangeEvent, type TouchTapEvent, type WillAppearEvent, @@ -462,10 +463,12 @@ describe("actions", () => { // Act (emit). const disposable = actionService.onWillDisappear(listener); connection.emit("willDisappear", ev); + // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[WillDisappearEvent]>({ action: { + controller: "Encoder", device, id: ev.context, manifestId: ev.action @@ -487,6 +490,7 @@ describe("actions", () => { describe("registering an action", () => { const keyManifestId = "com.elgato.test.key"; const dialManifestId = "com.elgato.test.dial"; + const actions = jest.fn() as unknown as IterableIterator | KeyAction>; /** * Asserts {@link registerAction} validates the manifest identifier is not undefined. @@ -494,7 +498,8 @@ describe("actions", () => { it("validates the manifestId is not undefined", () => { // Arrange. const action: SingletonAction = { - manifestId: undefined + manifestId: undefined, + actions }; // Act, assert. @@ -507,6 +512,7 @@ describe("actions", () => { it("validates when action does not exist in manifest", () => { // Arrange. const action: SingletonAction = { + actions, manifestId: "com.elgato.action-service.__one" }; @@ -527,7 +533,10 @@ describe("actions", () => { const spyOnPrependOnceListener = jest.spyOn(connection, "prependOnceListener"); // Act. - actionService.registerAction({ manifestId: keyManifestId }); + actionService.registerAction({ + actions, + manifestId: keyManifestId + }); // Assert. expect(spyOnAddListener).not.toHaveBeenCalled(); @@ -563,6 +572,7 @@ describe("actions", () => { // Act. actionService.registerAction({ + actions, manifestId: ev.action, onDialDown: listener }); @@ -606,6 +616,7 @@ describe("actions", () => { // Act. actionService.registerAction({ + actions, manifestId: ev.action, onDialRotate: listener }); @@ -647,6 +658,7 @@ describe("actions", () => { // Act. actionService.registerAction({ + actions, manifestId: ev.action, onDialUp: listener }); @@ -680,6 +692,7 @@ describe("actions", () => { // Act. actionService.registerAction({ + actions, manifestId: ev.action, onSendToPlugin: listener }); @@ -723,6 +736,7 @@ describe("actions", () => { // Act. actionService.registerAction({ + actions, manifestId: ev.action, onDidReceiveSettings: listener }); @@ -765,6 +779,7 @@ describe("actions", () => { // Act. actionService.registerAction({ + actions, manifestId: ev.action, onKeyDown: listener }); @@ -807,6 +822,7 @@ describe("actions", () => { // Act. actionService.registerAction({ + actions, manifestId: ev.action, onKeyUp: listener }); @@ -838,6 +854,7 @@ describe("actions", () => { // Act. actionService.registerAction({ + actions, manifestId: ev.action, onPropertyInspectorDidAppear: listener }); @@ -868,6 +885,7 @@ describe("actions", () => { // Act (emit). actionService.registerAction({ + actions, manifestId: ev.action, onPropertyInspectorDidDisappear: listener }); @@ -918,6 +936,7 @@ describe("actions", () => { // Act. actionService.registerAction({ + actions, manifestId: ev.action, onTitleParametersDidChange: listener }); @@ -961,6 +980,7 @@ describe("actions", () => { // Act. actionService.registerAction({ + actions, manifestId: ev.action, onTouchTap: listener }); @@ -1003,6 +1023,7 @@ describe("actions", () => { // Act. actionService.registerAction({ + actions, manifestId: ev.action, onWillAppear: listener }); @@ -1045,6 +1066,7 @@ describe("actions", () => { // Act. actionService.registerAction({ + actions, manifestId: ev.action, onWillDisappear: listener }); @@ -1055,6 +1077,7 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[WillDisappearEvent]>({ action: { + controller: "Encoder", device, id: ev.context, manifestId: ev.action diff --git a/src/plugin/actions/__tests__/key.test.ts b/src/plugin/actions/__tests__/key.test.ts index bd37ce3e..0f1bad97 100644 --- a/src/plugin/actions/__tests__/key.test.ts +++ b/src/plugin/actions/__tests__/key.test.ts @@ -1,90 +1,78 @@ -import { DeviceType, Target, type SetImage, type SetState, type SetTitle, type ShowAlert, type ShowOk } from "../../../api"; +import { Target, type SetImage, type SetState, type SetTitle, type ShowOk, type WillAppear } from "../../../api"; +import type { JsonObject } from "../../../common/json"; import { connection } from "../../connection"; -import { Device } from "../../devices"; -import { Action, type CoordinatedActionContext } from "../action"; +import { Device } from "../../devices/device"; +import { Action } from "../action"; import { KeyAction } from "../key"; +import type { ActionContext } from "../store"; +jest.mock("../../devices/device"); jest.mock("../../logging"); jest.mock("../../manifest"); jest.mock("../../connection"); describe("KeyAction", () => { - // Mock device. - const device = new Device( - "dev123", - { - name: "Device One", - size: { - columns: 5, - rows: 3 - }, - type: DeviceType.StreamDeck + // Mock context. + const context: ActionContext = { + // @ts-expect-error Mocked device. + device: new Device(), + controller: "Keypad", + id: "ABC123", + manifestId: "com.elgato.test.one" + }; + + // Mock source. + const source: WillAppear["payload"] = { + controller: "Keypad", + coordinates: { + column: 1, + row: 2 }, - false - ); + isInMultiAction: false, + settings: {} + }; /** * Asserts the constructor of {@link KeyAction} sets the context. */ it("constructor sets context", () => { - // Arrange. - const context: CoordinatedActionContext = { - device, - id: "ABC123", - manifestId: "com.elgato.test.one", - coordinates: { - column: 1, - row: 2 - } - }; - - // Act. - const keyAction = new KeyAction(context); + // Arrange, act. + const action = new KeyAction(context, source); // Assert. - expect(keyAction.coordinates).toBe(context.coordinates); - expect(keyAction.device).toBe(context.device); - expect(keyAction.id).toBe(context.id); - expect(keyAction.manifestId).toBe(context.manifestId); + expect(action).toBeInstanceOf(Action); + expect(action.coordinates).not.toBeUndefined(); + expect(action.coordinates?.column).toBe(1); + expect(action.coordinates?.row).toBe(2); + expect(action.device).toBe(context.device); + expect(action.id).toBe(context.id); + expect(action.manifestId).toBe(context.manifestId); }); /** - * Asserts the inheritance of {@link KeyAction}. + * Asserts the coordinates are undefined when the {@link KeyAction} is in a multi-action. */ - it("inherits shared methods", () => { + it("does not have coordinates when multi-action", () => { // Arrange, act. - const keyAction = new KeyAction({ - device, - id: "ABC123", - manifestId: "com.elgato.test.one", - coordinates: { - column: 1, - row: 2 - } + const action = new KeyAction(context, { + ...source, + isInMultiAction: true }); // Assert. - expect(keyAction).toBeInstanceOf(Action); + expect(action.coordinates).toBeUndefined(); }); describe("sending", () => { - const keyAction = new KeyAction({ - device, - id: "ABC123", - manifestId: "com.elgato.test.one", - coordinates: { - column: 1, - row: 2 - } - }); + const action = new KeyAction(context, source); /** * Asserts {@link KeyAction.setImage} forwards the command to the {@link connection}. */ it("setImage", async () => { // Arrange, act - await keyAction.setImage(); - await keyAction.setImage("./imgs/test.png", { + await action.setImage(); + await action.setImage("./imgs/test.png", { state: 1, target: Target.Hardware }); @@ -92,7 +80,7 @@ describe("KeyAction", () => { // Assert. expect(connection.send).toHaveBeenCalledTimes(2); expect(connection.send).toHaveBeenNthCalledWith<[SetImage]>(1, { - context: keyAction.id, + context: action.id, event: "setImage", payload: { image: undefined, @@ -102,7 +90,7 @@ describe("KeyAction", () => { }); expect(connection.send).toHaveBeenNthCalledWith<[SetImage]>(2, { - context: keyAction.id, + context: action.id, event: "setImage", payload: { image: "./imgs/test.png", @@ -117,12 +105,12 @@ describe("KeyAction", () => { */ it("setState", async () => { // Arrange, act. - await keyAction.setState(1); + await action.setState(1); // Assert. expect(connection.send).toHaveBeenCalledTimes(1); expect(connection.send).toHaveBeenCalledWith<[SetState]>({ - context: keyAction.id, + context: action.id, event: "setState", payload: { state: 1 @@ -135,8 +123,8 @@ describe("KeyAction", () => { */ it("setTitle", async () => { // Arrange, act. - await keyAction.setTitle("Hello world"); - await keyAction.setTitle("This is a test", { state: 1, target: Target.Software }); + await action.setTitle("Hello world"); + await action.setTitle("This is a test", { state: 1, target: Target.Software }); // Assert. expect(connection.send).toHaveBeenCalledTimes(2); @@ -159,32 +147,17 @@ describe("KeyAction", () => { }); }); - /** - * Asserts {@link KeyAction.showAlert} forwards the command to the {@link connection}. - */ - it("showAlert", async () => { - // Arrange, act. - await keyAction.showAlert(); - - // Assert. - expect(connection.send).toHaveBeenCalledTimes(1); - expect(connection.send).toHaveBeenCalledWith<[ShowAlert]>({ - context: keyAction.id, - event: "showAlert" - }); - }); - /** * Asserts {@link KeyAction.showOk} forwards the command to the {@link connection}. */ it("showOk", async () => { // Arrange, act - await keyAction.showOk(); + await action.showOk(); // Assert. expect(connection.send).toHaveBeenCalledTimes(1); expect(connection.send).toHaveBeenCalledWith<[ShowOk]>({ - context: keyAction.id, + context: action.id, event: "showOk" }); }); diff --git a/src/plugin/actions/dial.ts b/src/plugin/actions/dial.ts index f8ff6e20..476cacb4 100644 --- a/src/plugin/actions/dial.ts +++ b/src/plugin/actions/dial.ts @@ -20,14 +20,14 @@ export class DialAction extends Action { * @param context Action context. * @param source Source of the action. */ - constructor(context: ActionContext, source: WillAppear) { + constructor(context: ActionContext, source: WillAppear["payload"]) { super(context); - if (source.payload.controller === "Keypad") { + if (source.controller === "Keypad") { throw new Error("Unable to create DialAction from Keypad"); } - this.#coordinates = Object.freeze(source.payload.coordinates); + this.#coordinates = Object.freeze(source.coordinates); } /** @@ -38,24 +38,6 @@ export class DialAction extends Action { return this.#coordinates; } - /** - * Sets the {@link image} to be display for this action instance within Stream Deck app. - * - * NB: The image can only be set by the plugin when the the user has not specified a custom image. - * @param image Image to display; this can be either a path to a local file within the plugin's folder, a base64 encoded `string` with the mime type declared (e.g. PNG, JPEG, etc.), - * or an SVG `string`. When `undefined`, the image from the manifest will be used. - * @returns `Promise` resolved when the request to set the {@link image} has been sent to Stream Deck. - */ - public setImage(image?: string): Promise { - return connection.send({ - event: "setImage", - context: this.id, - payload: { - image - } - }); - } - /** * Sets the feedback for the current layout associated with this action instance, allowing for the visual items to be updated. Layouts are a powerful way to provide dynamic information * to users, and can be assigned in the manifest, or dynamically via {@link Action.setFeedbackLayout}. @@ -89,6 +71,35 @@ export class DialAction extends Action { }); } + /** + * Sets the {@link image} to be display for this action instance within Stream Deck app. + * + * NB: The image can only be set by the plugin when the the user has not specified a custom image. + * @param image Image to display; this can be either a path to a local file within the plugin's folder, a base64 encoded `string` with the mime type declared (e.g. PNG, JPEG, etc.), + * or an SVG `string`. When `undefined`, the image from the manifest will be used. + * @returns `Promise` resolved when the request to set the {@link image} has been sent to Stream Deck. + */ + public setImage(image?: string): Promise { + return connection.send({ + event: "setImage", + context: this.id, + payload: { + image + } + }); + } + + /** + * Sets the {@link title} displayed for this action instance. + * + * NB: The title can only be set by the plugin when the the user has not specified a custom title. + * @param title Title to display. + * @returns `Promise` resolved when the request to set the {@link title} has been sent to Stream Deck. + */ + public setTitle(title: string): Promise { + return this.setFeedback({ title }); + } + /** * Sets the trigger (interaction) {@link descriptions} associated with this action instance. Descriptions are shown within the Stream Deck application, and informs the user what * will happen when they interact with the action, e.g. rotate, touch, etc. When {@link descriptions} is `undefined`, the descriptions will be reset to the values provided as part diff --git a/src/plugin/actions/key.ts b/src/plugin/actions/key.ts index f022aea2..040fa2dd 100644 --- a/src/plugin/actions/key.ts +++ b/src/plugin/actions/key.ts @@ -18,17 +18,17 @@ export class KeyAction extends Action { /** * Source of the action. */ - readonly #source: WillAppear; + readonly #source: WillAppear["payload"]; /** * Initializes a new instance of the {@see KeyAction} class. * @param context Action context. * @param source Source of the action. */ - constructor(context: ActionContext, source: WillAppear) { + constructor(context: ActionContext, source: WillAppear["payload"]) { super(context); - this.#coordinates = !source.payload.isInMultiAction ? Object.freeze(source.payload.coordinates) : undefined; + this.#coordinates = !source.isInMultiAction ? Object.freeze(source.coordinates) : undefined; this.#source = source; } @@ -45,7 +45,7 @@ export class KeyAction extends Action { * @returns `true` when in a multi-action; otherwise `false`. */ public get isInMultiAction(): boolean { - return this.#source.payload.isInMultiAction; + return this.#source.isInMultiAction; } /** diff --git a/src/plugin/actions/store.ts b/src/plugin/actions/store.ts index a800b8b3..2523578e 100644 --- a/src/plugin/actions/store.ts +++ b/src/plugin/actions/store.ts @@ -12,7 +12,7 @@ let __devices: DeviceCollection | undefined; // Adds the action to the store. connection.prependListener("willAppear", (ev) => { const context = createContext(ev); - const action = ev.payload.controller === "Encoder" ? new DialAction(context, ev) : new KeyAction(context, ev); + const action = ev.payload.controller === "Encoder" ? new DialAction(context, ev.payload) : new KeyAction(context, ev.payload); __actions.set(ev.context, action); }); diff --git a/src/plugin/ui/__tests__/router.test.ts b/src/plugin/ui/__tests__/router.test.ts index cbafa2c7..0aae65ff 100644 --- a/src/plugin/ui/__tests__/router.test.ts +++ b/src/plugin/ui/__tests__/router.test.ts @@ -1,9 +1,10 @@ -import { DeviceType, KeyAction, MessageRequest, type MessageRequestOptions } from "../.."; +import { MessageRequest, type MessageRequestOptions } from "../.."; import type { DidReceivePropertyInspectorMessage, SendToPropertyInspector } from "../../../api"; import type { RawMessageRequest } from "../../../common/messaging/message"; import { MessageResponder } from "../../../common/messaging/responder"; import { PromiseCompletionSource } from "../../../common/promises"; -import { actionStore } from "../../actions/store"; +import { KeyAction } from "../../actions/key"; +import { actionStore, type ActionContext } from "../../actions/store"; import { connection } from "../../connection"; import { Device } from "../../devices/device"; import { PropertyInspector } from "../property-inspector"; @@ -260,26 +261,25 @@ describe("router", () => { describe("outbound messages", () => { describe("with ui", () => { - const action = new KeyAction({ - id: "key123", - manifestId: "com.elgato.test.key", - device: new Device( - "device123", - { - name: "Device One", - size: { - columns: 5, - rows: 3 - }, - type: DeviceType.StreamDeck - }, - false - ), + // Mock context. + const context: ActionContext = { + // @ts-expect-error Mocked device. + device: new Device(), + controller: "Keypad", + id: "ABC123", + manifestId: "com.elgato.test.one" + }; + + // Mock action. + const action = new KeyAction(context, { + controller: "Keypad", coordinates: { - column: 0, - row: 0 - } - }); + column: 5, + row: 3, + }, + isInMultiAction: false, + settings: {} + }) beforeAll(() => { jest.useFakeTimers(); @@ -370,14 +370,22 @@ describe("router", () => { */ test("without ui", async () => { // Arrange. - const action = new KeyAction({ - manifestId: "com.elgato.test.one", + const context: ActionContext = { + // @ts-expect-error Mocked device. + device: new Device(), + controller: "Keypad", id: "proxy-outbound-message-without-ui", + manifestId: "com.elgato.test.one" + }; + + const action = new KeyAction(context, { + controller: "Keypad", coordinates: { - column: 0, - row: 0 + column: 5, + row: 3 }, - device: undefined! + isInMultiAction: false, + settings: {} }); jest.spyOn(actionStore, "getActionById").mockReturnValue(action); From d43ed594524ab4df7e967d837b6e24a64501320c Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sun, 22 Sep 2024 16:46:51 +0100 Subject: [PATCH 23/29] refactor: action and device store --- src/plugin/actions/action.ts | 43 +---------- src/plugin/actions/context.ts | 68 +++++++++++++++++ src/plugin/actions/dial.ts | 12 ++- src/plugin/actions/index.ts | 11 +-- src/plugin/actions/key.ts | 12 ++- src/plugin/actions/store.ts | 73 +------------------ .../{index.test.ts => service.test.ts} | 4 +- src/plugin/devices/device.ts | 13 +++- src/plugin/devices/index.ts | 72 +----------------- src/plugin/devices/service.ts | 34 +++++++++ src/plugin/devices/store.ts | 61 ++++++++++++++++ src/plugin/events/index.ts | 2 +- 12 files changed, 200 insertions(+), 205 deletions(-) create mode 100644 src/plugin/actions/context.ts rename src/plugin/devices/__tests__/{index.test.ts => service.test.ts} (99%) create mode 100644 src/plugin/devices/service.ts create mode 100644 src/plugin/devices/store.ts diff --git a/src/plugin/actions/action.ts b/src/plugin/actions/action.ts index f88c380a..1365a858 100644 --- a/src/plugin/actions/action.ts +++ b/src/plugin/actions/action.ts @@ -3,51 +3,16 @@ import type streamDeck from "../"; import type { DidReceiveSettings } from "../../api"; import type { JsonObject, JsonValue } from "../../common/json"; import { connection } from "../connection"; -import type { Device } from "../devices"; +import { ActionContext } from "./context"; import type { DialAction } from "./dial"; import type { KeyAction } from "./key"; import type { SingletonAction } from "./singleton-action"; -import type { ActionContext } from "./store"; /** * Provides a contextualized instance of an {@link Action}, allowing for direct communication with the Stream Deck. * @template T The type of settings associated with the action. */ -export class Action { - /** - * The action context. - */ - readonly #context: ActionContext; - - /** - * Initializes a new instance of the {@see Action} class. - * @param context Action context. - */ - constructor(context: ActionContext) { - this.#context = context; - } - - /** - * @inheritdoc - */ - public get device(): Device { - return this.#context.device; - } - - /** - * @inheritdoc - */ - public get id(): string { - return this.#context.id; - } - - /** - * @inheritdoc - */ - public get manifestId(): string { - return this.#context.manifestId; - } - +export class Action extends ActionContext { /** * Gets the settings associated this action instance. * @template U The type of settings associated with the action. @@ -75,7 +40,7 @@ export class Action { * @returns `true` when this instance is a dial; otherwise `false`. */ public isDial(): this is DialAction { - return this.#context.controller === "Encoder"; + return this.controller === "Encoder"; } /** @@ -83,7 +48,7 @@ export class Action { * @returns `true` when this instance is a key; otherwise `false`. */ public isKey(): this is KeyAction { - return this.#context.controller === "Keypad"; + return this.controller === "Keypad"; } /** diff --git a/src/plugin/actions/context.ts b/src/plugin/actions/context.ts new file mode 100644 index 00000000..71433169 --- /dev/null +++ b/src/plugin/actions/context.ts @@ -0,0 +1,68 @@ +import type { Controller, WillAppear, WillDisappear } from "../../api"; +import type { JsonObject } from "../../common/json"; +import { deviceStore, type Device } from "../devices"; + +/** + * Provides information about an instance of a Stream Deck action. + */ +export class ActionContext { + /** + * Device the action is associated with. + */ + readonly #device: Device; + + /** + * Source of the action. + */ + readonly #source: WillAppear | WillDisappear; + + /** + * Initializes a new instance of the {@link ActionContext} class. + * @param source Source of the action. + */ + constructor(source: WillAppear | WillDisappear) { + this.#source = source; + + const device = deviceStore.getDeviceById(source.device); + if (!device) { + throw new Error(`Failed to initialize action; device ${source.device} not found`); + } + + this.#device = device; + } + + /** + * Type of the action. + * - `Keypad` is a key. + * - `Encoder` is a dial and portion of the touch strip. + * + * @returns Controller type. + */ + public get controller(): Controller { + return this.#source.payload.controller; + } + + /** + * Stream Deck device the action is positioned on. + * @returns Stream Deck device. + */ + public get device(): Device { + return this.#device; + } + + /** + * Action instance identifier. + * @returns Identifier. + */ + public get id(): string { + return this.#source.context; + } + + /** + * Manifest identifier (UUID) for this action type. + * @returns Manifest identifier. + */ + public get manifestId(): string { + return this.#source.action; + } +} diff --git a/src/plugin/actions/dial.ts b/src/plugin/actions/dial.ts index 476cacb4..b9de7e68 100644 --- a/src/plugin/actions/dial.ts +++ b/src/plugin/actions/dial.ts @@ -3,7 +3,6 @@ import type { JsonObject } from "../../common/json"; import type { KeyOf } from "../../common/utils"; import { connection } from "../connection"; import { Action } from "./action"; -import type { ActionContext } from "./store"; /** * Provides a contextualized instance of a dial action. @@ -17,17 +16,16 @@ export class DialAction extends Action { /** * Initializes a new instance of the {@see DialAction} class. - * @param context Action context. * @param source Source of the action. */ - constructor(context: ActionContext, source: WillAppear["payload"]) { - super(context); + constructor(source: WillAppear) { + super(source); - if (source.controller === "Keypad") { - throw new Error("Unable to create DialAction from Keypad"); + if (source.payload.controller !== "Encoder") { + throw new Error("Unable to create DialAction a source that isn't an Encoder"); } - this.#coordinates = Object.freeze(source.coordinates); + this.#coordinates = Object.freeze(source.payload.coordinates); } /** diff --git a/src/plugin/actions/index.ts b/src/plugin/actions/index.ts index a359d455..abb5f431 100644 --- a/src/plugin/actions/index.ts +++ b/src/plugin/actions/index.ts @@ -3,7 +3,6 @@ import type { IDisposable } from "../../common/disposable"; import { ActionEvent } from "../../common/events"; import type { JsonObject } from "../../common/json"; import { connection } from "../connection"; -import { devices } from "../devices"; import { DialDownEvent, DialRotateEvent, @@ -19,8 +18,9 @@ import { getManifest } from "../manifest"; import { onDidReceiveSettings } from "../settings"; import { ui } from "../ui"; import { Action } from "./action"; +import { ActionContext } from "./context"; import type { SingletonAction } from "./singleton-action"; -import { ActionStore, actionStore, createContext, type ActionContext } from "./store"; +import { ActionStore, actionStore } from "./store"; const manifest = getManifest(); @@ -165,12 +165,7 @@ class ActionService extends ActionStore { * @returns A disposable that, when disposed, removes the listener. */ public onWillDisappear(listener: (ev: WillDisappearEvent) => void): IDisposable { - return connection.disposableOn("willDisappear", (ev: WillDisappear) => { - const device = devices.getDeviceById(ev.device); - if (device) { - listener(new ActionEvent(createContext(ev), ev)); - } - }); + return connection.disposableOn("willDisappear", (ev: WillDisappear) => listener(new ActionEvent(new ActionContext(ev), ev))); } /** diff --git a/src/plugin/actions/key.ts b/src/plugin/actions/key.ts index 040fa2dd..4fcced7d 100644 --- a/src/plugin/actions/key.ts +++ b/src/plugin/actions/key.ts @@ -3,7 +3,6 @@ import type { JsonObject } from "../../common/json"; import type { KeyOf } from "../../common/utils"; import { connection } from "../connection"; import { Action } from "./action"; -import type { ActionContext } from "./store"; /** * Provides a contextualized instance of a key action. @@ -18,17 +17,16 @@ export class KeyAction extends Action { /** * Source of the action. */ - readonly #source: WillAppear["payload"]; + readonly #source: WillAppear; /** * Initializes a new instance of the {@see KeyAction} class. - * @param context Action context. * @param source Source of the action. */ - constructor(context: ActionContext, source: WillAppear["payload"]) { - super(context); + constructor(source: WillAppear) { + super(source); - this.#coordinates = !source.isInMultiAction ? Object.freeze(source.coordinates) : undefined; + this.#coordinates = !source.payload.isInMultiAction ? Object.freeze(source.payload.coordinates) : undefined; this.#source = source; } @@ -45,7 +43,7 @@ export class KeyAction extends Action { * @returns `true` when in a multi-action; otherwise `false`. */ public get isInMultiAction(): boolean { - return this.#source.isInMultiAction; + return this.#source.payload.isInMultiAction; } /** diff --git a/src/plugin/actions/store.ts b/src/plugin/actions/store.ts index 2523578e..ddcd30b2 100644 --- a/src/plugin/actions/store.ts +++ b/src/plugin/actions/store.ts @@ -1,37 +1,20 @@ -import type { Controller, JsonObject } from ".."; -import type { WillAppear, WillDisappear } from "../../api"; import { Enumerable } from "../../common/enumerable"; import { connection } from "../connection"; -import type { Device, DeviceCollection } from "../devices"; +import { initializeStore } from "../devices/store"; import { DialAction } from "./dial"; import { KeyAction } from "./key"; const __actions = new Map(); -let __devices: DeviceCollection | undefined; // Adds the action to the store. connection.prependListener("willAppear", (ev) => { - const context = createContext(ev); - const action = ev.payload.controller === "Encoder" ? new DialAction(context, ev.payload) : new KeyAction(context, ev.payload); - + const action = ev.payload.controller === "Encoder" ? new DialAction(ev) : new KeyAction(ev); __actions.set(ev.context, action); }); // Remove the action from the store. connection.prependListener("willDisappear", (ev) => __actions.delete(ev.context)); -/** - * Initializes the action store, allowing for actions to be associated with devices. - * @param devices Collection of devices. - */ -export function initializeStore(devices: DeviceCollection): void { - if (__devices !== undefined) { - throw new Error("Action store has already been initialized"); - } - - __devices = devices; -} - /** * Provides a store of visible actions. */ @@ -54,56 +37,8 @@ export class ActionStore extends Enumerable { } /** - * Action store containing visible actions. + * Store of visible Stream Deck actions. */ export const actionStore = new ActionStore(); -/** - * Provides context information for an instance of an action. - */ -export type ActionContext = { - /** - * Type of the action. - * - `Keypad` is a key. - * - `Encoder` is a dial and portion of the touch strip. - * - * @returns Controller type. - */ - get controller(): Controller; - - /** - * Stream Deck device the action is positioned on. - * @returns Stream Deck device. - */ - get device(): Device; - - /** - * Action instance identifier. - * @returns Identifier. - */ - get id(): string; - - /** - * Manifest identifier (UUID) for this action type. - * @returns Manifest identifier. - */ - get manifestId(): string; -}; - -/** - * Creates a new {@link ActionContext} from the specified source event. - * @param source Event source of the action. - * @returns The action context. - */ -export function createContext(source: WillAppear | WillDisappear): ActionContext { - if (__devices === undefined) { - throw new Error("Action store must be initialized before creating an action's context"); - } - - return { - controller: source.payload.controller, - device: __devices.getDeviceById(source.device)!, - id: source.context, - manifestId: source.action - }; -} +initializeStore(actionStore); diff --git a/src/plugin/devices/__tests__/index.test.ts b/src/plugin/devices/__tests__/service.test.ts similarity index 99% rename from src/plugin/devices/__tests__/index.test.ts rename to src/plugin/devices/__tests__/service.test.ts index 17c455ea..dcaa1062 100644 --- a/src/plugin/devices/__tests__/index.test.ts +++ b/src/plugin/devices/__tests__/service.test.ts @@ -1,4 +1,4 @@ -import { Device, type DeviceCollection } from "../"; +import { Device, type DeviceService } from ".."; import type { DeviceDidConnectEvent, DeviceDidDisconnectEvent } from "../.."; import { DeviceType, type DeviceDidConnect, type DeviceDidDisconnect } from "../../../api"; import { type connection as Connection } from "../../connection"; @@ -10,7 +10,7 @@ jest.mock("../../manifest"); describe("devices", () => { let connection!: typeof Connection; - let devices!: DeviceCollection; + let devices!: DeviceService; beforeEach(async () => { jest.resetModules(); diff --git a/src/plugin/devices/device.ts b/src/plugin/devices/device.ts index 85b05fae..e4741706 100644 --- a/src/plugin/devices/device.ts +++ b/src/plugin/devices/device.ts @@ -1,13 +1,18 @@ import type { DeviceInfo, DeviceType, Size } from "../../api"; import type { DialAction } from "../actions/dial"; import type { KeyAction } from "../actions/key"; -import { actionStore } from "../actions/store"; +import type { ActionStore } from "../actions/store"; import { connection } from "../connection"; /** * Provides information about a device. */ export class Device { + /** + * Store of Stream Deck actions. + */ + #actionStore: ActionStore; + /** * Private backing field for {@link Device.isConnected}. */ @@ -28,11 +33,13 @@ export class Device { * @param id Device identifier. * @param info Information about the device. * @param isConnected Determines whether the device is connected. + * @param actionStore Store of Stream Deck actions. */ - constructor(id: string, info: DeviceInfo, isConnected: boolean) { + constructor(id: string, info: DeviceInfo, isConnected: boolean, actionStore: ActionStore) { this.id = id; this.#info = info; this.#isConnected = isConnected; + this.#actionStore = actionStore; // Set connected. connection.prependListener("deviceDidConnect", (ev) => { @@ -55,7 +62,7 @@ export class Device { * @returns Collection of visible actions. */ public get actions(): IterableIterator { - return actionStore.filter((a) => a.device.id === this.id); + return this.#actionStore.filter((a) => a.device.id === this.id); } /** diff --git a/src/plugin/devices/index.ts b/src/plugin/devices/index.ts index da6fab3a..5dce1770 100644 --- a/src/plugin/devices/index.ts +++ b/src/plugin/devices/index.ts @@ -1,69 +1,3 @@ -import type { IDisposable } from "../../common/disposable"; -import { Enumerable } from "../../common/enumerable"; -import { initializeStore } from "../actions/store"; -import { connection } from "../connection"; -import { DeviceDidConnectEvent, DeviceDidDisconnectEvent, DeviceEvent } from "../events"; -import { Device } from "./device"; - -const __devices = new Map(); - -/** - * Collection of tracked Stream Deck devices. - */ -class DeviceCollection extends Enumerable { - /** - * Initializes a new instance of the {@link DeviceCollection} class. - */ - constructor() { - super(__devices); - - // Add the devices from registration parameters. - connection.once("connected", (info) => { - info.devices.forEach((dev) => __devices.set(dev.id, new Device(dev.id, dev, false))); - }); - - // Add new devices. - connection.on("deviceDidConnect", ({ device: id, deviceInfo }) => { - if (!__devices.get(id)) { - __devices.set(id, new Device(id, deviceInfo, true)); - } - }); - } - - /** - * Gets the Stream Deck {@link Device} associated with the specified {@link deviceId}. - * @param deviceId Identifier of the Stream Deck device. - * @returns The Stream Deck device information; otherwise `undefined` if a device with the {@link deviceId} does not exist. - */ - public getDeviceById(deviceId: string): Device | undefined { - return __devices.get(deviceId); - } - - /** - * Occurs when a Stream Deck device is connected. See also {@link DeviceCollection.onDeviceDidConnect}. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. - */ - public onDeviceDidConnect(listener: (ev: DeviceDidConnectEvent) => void): IDisposable { - return connection.disposableOn("deviceDidConnect", (ev) => listener(new DeviceEvent(ev, this.getDeviceById(ev.device)!))); - } - - /** - * Occurs when a Stream Deck device is disconnected. See also {@link DeviceCollection.onDeviceDidDisconnect}. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. - */ - public onDeviceDidDisconnect(listener: (ev: DeviceDidDisconnectEvent) => void): IDisposable { - return connection.disposableOn("deviceDidDisconnect", (ev) => listener(new DeviceEvent(ev, this.getDeviceById(ev.device)!))); - } -} - -/** - * Collection of tracked Stream Deck devices. - */ -export const devices = new DeviceCollection(); - -// Initializes the action store. -initializeStore(devices); - -export { Device, type DeviceCollection }; +export { type Device } from "./device"; +export { devices, type DeviceService } from "./service"; +export { deviceStore } from "./store"; diff --git a/src/plugin/devices/service.ts b/src/plugin/devices/service.ts new file mode 100644 index 00000000..540ee4fc --- /dev/null +++ b/src/plugin/devices/service.ts @@ -0,0 +1,34 @@ +import type { IDisposable } from "../../common/disposable"; +import { connection } from "../connection"; +import { DeviceDidConnectEvent, DeviceDidDisconnectEvent, DeviceEvent } from "../events"; +import { DeviceStore, deviceStore } from "./store"; + +/** + * Provides functions, and information, for interacting with Stream Deck actions. + */ +class DeviceService extends DeviceStore { + /** + * Occurs when a Stream Deck device is connected. See also {@link DeviceService.onDeviceDidConnect}. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onDeviceDidConnect(listener: (ev: DeviceDidConnectEvent) => void): IDisposable { + return connection.disposableOn("deviceDidConnect", (ev) => listener(new DeviceEvent(ev, deviceStore.getDeviceById(ev.device)!))); + } + + /** + * Occurs when a Stream Deck device is disconnected. See also {@link DeviceService.onDeviceDidDisconnect}. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onDeviceDidDisconnect(listener: (ev: DeviceDidDisconnectEvent) => void): IDisposable { + return connection.disposableOn("deviceDidDisconnect", (ev) => listener(new DeviceEvent(ev, deviceStore.getDeviceById(ev.device)!))); + } +} + +/** + * Provides functions, and information, for interacting with Stream Deck actions. + */ +export const devices = new DeviceService(); + +export { type DeviceService }; diff --git a/src/plugin/devices/store.ts b/src/plugin/devices/store.ts new file mode 100644 index 00000000..2991110a --- /dev/null +++ b/src/plugin/devices/store.ts @@ -0,0 +1,61 @@ +import { Enumerable } from "../../common/enumerable"; +import type { ActionStore } from "../actions/store"; +import { connection } from "../connection"; +import { Device } from "./device"; + +const __devices = new Map(); +let __actionStore: ActionStore; + +// Add the devices from registration parameters. +connection.once("connected", (info) => { + if (!__actionStore) { + throw new Error("Device store has not yet been initialized"); + } + + info.devices.forEach((dev) => __devices.set(dev.id, new Device(dev.id, dev, false, __actionStore))); +}); + +// Add new devices. +connection.on("deviceDidConnect", ({ device: id, deviceInfo }) => { + if (!__actionStore) { + throw new Error("Device store has not yet been initialized"); + } + + if (!__devices.get(id)) { + __devices.set(id, new Device(id, deviceInfo, true, __actionStore)); + } +}); + +/** + * Provides a store of Stream Deck devices. + */ +export class DeviceStore extends Enumerable { + /** + * Initializes a new instance of the {@link DeviceStore} class. + */ + constructor() { + super(__devices); + } + + /** + * Gets the Stream Deck {@link Device} associated with the specified {@link deviceId}. + * @param deviceId Identifier of the Stream Deck device. + * @returns The Stream Deck device information; otherwise `undefined` if a device with the {@link deviceId} does not exist. + */ + public getDeviceById(deviceId: string): Device | undefined { + return __devices.get(deviceId); + } +} + +/** + * Store of Stream Deck devices. + */ +export const deviceStore = new DeviceStore(); + +/** + * Initializes the device store. + * @param actionStore Store of Stream Deck actions. + */ +export function initializeStore(actionStore: ActionStore): void { + __actionStore = actionStore; +} diff --git a/src/plugin/events/index.ts b/src/plugin/events/index.ts index 59239717..b3b067a8 100644 --- a/src/plugin/events/index.ts +++ b/src/plugin/events/index.ts @@ -19,9 +19,9 @@ import type { } from "../../api"; import { ActionWithoutPayloadEvent, Event, type ActionEvent } from "../../common/events"; import type { JsonObject } from "../../common/json"; +import type { ActionContext } from "../actions/context"; import type { DialAction } from "../actions/dial"; import type { KeyAction } from "../actions/key"; -import type { ActionContext } from "../actions/store"; import type { Device } from "../devices"; import { ApplicationEvent } from "./application-event"; import { DeviceEvent } from "./device-event"; From e3f3ec26754c4435a56c652bb6b54aab4907c6a1 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sun, 22 Sep 2024 16:48:04 +0100 Subject: [PATCH 24/29] refactor: improve exports --- src/plugin/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plugin/index.ts b/src/plugin/index.ts index d4941b42..ffee2443 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -3,7 +3,7 @@ import { I18nProvider } from "../common/i18n"; import { registerCreateLogEntryRoute, type Logger } from "../common/logging"; import { actionService, type ActionService } from "./actions"; import { connection } from "./connection"; -import { devices } from "./devices"; +import { devices, type DeviceService } from "./devices"; import { fileSystemLocaleProvider } from "./i18n"; import { logger } from "./logging"; import { getManifest } from "./manifest"; @@ -41,7 +41,7 @@ export { action } from "./actions/decorators"; export { type DialAction, type TriggerDescriptionOptions } from "./actions/dial"; export { type ImageOptions, type KeyAction, type TitleOptions } from "./actions/key"; export { SingletonAction } from "./actions/singleton-action"; -export { type Device } from "./devices"; +export { type Device, type DeviceService } from "./devices"; export * from "./events"; export { route, type MessageRequest, type PropertyInspector } from "./ui"; export { type Logger }; @@ -61,7 +61,7 @@ export const streamDeck = { * Namespace for interacting with Stream Deck devices. * @returns Devices namespace. */ - get devices(): typeof devices { + get devices(): DeviceService { return devices; }, From 625a39602d4534c30ff23ede7b74d5b8fac0d2a0 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sun, 22 Sep 2024 20:05:43 +0100 Subject: [PATCH 25/29] refactor: decouple stores --- src/plugin/__tests__/index.test.ts | 8 +- src/plugin/actions/__mocks__/store.ts | 120 ++++---- src/plugin/actions/__tests__/action.test.ts | 120 +++++--- src/plugin/actions/__tests__/dial.test.ts | 104 +++++-- src/plugin/actions/__tests__/key.test.ts | 106 +++++-- .../{index.test.ts => service.test.ts} | 138 ++++----- src/plugin/actions/context.ts | 3 +- src/plugin/actions/dial.ts | 2 +- src/plugin/actions/index.ts | 250 +---------------- src/plugin/actions/key.ts | 4 + src/plugin/actions/service.ts | 261 ++++++++++++++++++ src/plugin/actions/store.ts | 52 ++-- src/plugin/devices/__mocks__/index.ts | 21 ++ src/plugin/devices/__tests__/service.test.ts | 45 +-- src/plugin/devices/device.ts | 13 +- src/plugin/devices/index.ts | 5 +- src/plugin/devices/service.ts | 32 ++- src/plugin/devices/store.ts | 56 ++-- src/plugin/index.ts | 14 +- .../ui/__tests__/property-inspector.test.ts | 6 +- src/plugin/ui/__tests__/route.test.ts | 4 +- src/plugin/ui/__tests__/router.test.ts | 71 ++--- 22 files changed, 784 insertions(+), 651 deletions(-) rename src/plugin/actions/__tests__/{index.test.ts => service.test.ts} (92%) create mode 100644 src/plugin/actions/service.ts create mode 100644 src/plugin/devices/__mocks__/index.ts diff --git a/src/plugin/__tests__/index.test.ts b/src/plugin/__tests__/index.test.ts index 16fc4e27..927ce89a 100644 --- a/src/plugin/__tests__/index.test.ts +++ b/src/plugin/__tests__/index.test.ts @@ -2,7 +2,6 @@ import { BarSubType, DeviceType, Target } from "../../api"; import { EventEmitter } from "../../common/event-emitter"; import { I18nProvider } from "../../common/i18n"; import { LogLevel } from "../../common/logging"; -import { Action } from "../actions/action"; import { SingletonAction } from "../actions/singleton-action"; import { connection } from "../connection"; import streamDeckAsDefaultExport, { streamDeck } from "../index"; @@ -28,8 +27,8 @@ describe("index", () => { */ it("exports namespaces", async () => { // Arrange. - const { actionService } = await require("../actions"); - const { devices } = await require("../devices"); + const { actionService } = await require("../actions/service"); + const { deviceService } = await require("../devices/service"); const { getManifest } = await require("../manifest"); const profiles = await require("../profiles"); const settings = await require("../settings"); @@ -38,7 +37,7 @@ describe("index", () => { // Act, assert. expect(streamDeck.actions).toBe(actionService); - expect(streamDeck.devices).toBe(devices); + expect(streamDeck.devices).toBe(deviceService); expect(streamDeck.manifest).toBe(getManifest()); expect(streamDeck.profiles).toBe(profiles); expect(streamDeck.settings).toBe(settings); @@ -73,7 +72,6 @@ describe("index", () => { const index = (await require("../index")) as typeof import("../index"); // Act, assert. - expect(index.Action).toBe(Action); expect(index.ApplicationEvent).not.toBeUndefined(); expect(index.BarSubType).toBe(BarSubType); expect(index.DeviceType).toBe(DeviceType); diff --git a/src/plugin/actions/__mocks__/store.ts b/src/plugin/actions/__mocks__/store.ts index 5e157dfb..a462dafa 100644 --- a/src/plugin/actions/__mocks__/store.ts +++ b/src/plugin/actions/__mocks__/store.ts @@ -1,74 +1,62 @@ -import type { WillAppear, WillDisappear } from "../../../api"; -import type { JsonObject } from "../../../common/json"; -import { DialAction } from "../dial"; -import { KeyAction } from "../key"; -import type { ActionContext } from "../store"; - -const { ActionStore, initializeStore: __initializeStore } = jest.requireActual("../store"); - -// Mock key. -const key = new KeyAction( - { - id: "key123", - manifestId: "com.elgato.test.key", - device: undefined!, - controller: "Keypad" - }, - { - controller: "Keypad", - coordinates: { - column: 1, - row: 1 - }, - isInMultiAction: false, - settings: {} - } -); - -// Mock dial. -const dial = new DialAction( - { - id: "dial123", - manifestId: "com.elgato.test.dial", - device: undefined!, - controller: "Encoder" +import { DeviceType } from "../../../api/device"; +import type { Device } from "../../devices"; +import { deviceStore } from "../../devices/store"; + +const { ReadOnlyActionStore } = jest.requireActual("../store"); +const { KeyAction } = jest.requireActual("../key"); +const { DialAction } = jest.requireActual("../dial"); + +jest.mock("../../devices/store"); + +jest.spyOn(deviceStore, "getDeviceById").mockReturnValue({ + id: "device123", + isConnected: true, + name: "Device 1", + size: { + columns: 5, + rows: 3 }, - { - controller: "Encoder", - coordinates: { - column: 1, - row: 1 - }, - isInMultiAction: false, - settings: {} - } -); + type: DeviceType.StreamDeck +} as unknown as Device); export const actionStore = { - getActionById: jest.fn().mockImplementation((id) => { - if (id === key.id) { - return key; - } else if (id === dial.id) { - return dial; + set: jest.fn(), + delete: jest.fn(), + getActionById: jest.fn().mockImplementation((id: string) => { + if (id === "dial123") { + return new DialAction({ + action: "com.elgato.test.dial", + context: id, + device: "device123", + event: "willAppear", + payload: { + controller: "Encoder", + coordinates: { + column: 1, + row: 2 + }, + isInMultiAction: false, + settings: {} + } + }); } - return undefined; + return new KeyAction({ + action: "com.elgato.test.key", + context: id, + device: "device123", + event: "willAppear", + payload: { + controller: "Keypad", + coordinates: { + column: 1, + row: 2 + }, + isInMultiAction: false, + settings: {} + } + }); }) }; -// @ts-expect-error Underlying store is not used, but still registers on the connection. -__initializeStore({ - getDeviceById: jest.fn() -}); - -export const initializeStore = jest.fn(); -export { ActionStore }; - -export const createContext = jest.fn().mockImplementation((source: WillAppear | WillDisappear) => { - return { - controller: source.payload.controller, - device: undefined!, - id: source.context, - manifestId: source.action - } satisfies ActionContext; -}); +export { ReadOnlyActionStore }; diff --git a/src/plugin/actions/__tests__/action.test.ts b/src/plugin/actions/__tests__/action.test.ts index 7f9c0d69..0364b006 100644 --- a/src/plugin/actions/__tests__/action.test.ts +++ b/src/plugin/actions/__tests__/action.test.ts @@ -1,48 +1,66 @@ -import { DeviceType, type GetSettings, type SendToPropertyInspector, type SetSettings } from "../../../api"; +import { DeviceType, type GetSettings, type SendToPropertyInspector, type SetSettings, type ShowAlert, type WillAppear } from "../../../api"; import { Settings } from "../../../api/__mocks__/events"; +import { type JsonObject } from "../../../common/json"; import { connection } from "../../connection"; import { Device } from "../../devices/device"; -import { Action, type ActionContext } from "../action"; -import { KeyAction } from "../key"; +import { deviceStore } from "../../devices/store"; +import { Action } from "../action"; +import { DialAction } from "../dial"; +jest.mock("../../devices/store"); jest.mock("../../logging"); jest.mock("../../manifest"); jest.mock("../../connection"); describe("Action", () => { + // Mock source. + const source: WillAppear = { + action: "com.test.action.one", + context: "action123", + device: "device123", + event: "willAppear", + payload: { + controller: "Keypad", + coordinates: { + column: 1, + row: 2 + }, + isInMultiAction: false, + settings: {} + } + }; + // Mock device. const device = new Device( - "dev123", + "device123", { - name: "Device One", + name: "Device 1", size: { columns: 5, rows: 3 }, type: DeviceType.StreamDeck }, - false + true ); + beforeAll(() => jest.spyOn(deviceStore, "getDeviceById").mockReturnValue(device)); + /** - * Asserts the constructor of {@link Action} sets the context. + * Asserts the constructor of {@link Action} sets the properties from the source. */ - it("constructor sets context", () => { - // Arrange. - const context: ActionContext = { - device, - id: "ABC123", - manifestId: "com.elgato.test.one" - }; - - // Act. - const action = new KeyAction(context); + it("constructor sets properties from source", () => { + // Arrange, act. + const action = new Action(source); // Assert. expect(action).toBeInstanceOf(Action); - expect(action.device).toBe(context.device); - expect(action.id).toBe(context.id); - expect(action.manifestId).toBe(context.manifestId); + expect(action.controller).toBe("Keypad"); + expect(action.device).toBe(device); + expect(action.id).toBe(source.context); + expect(action.manifestId).toBe(source.action); + expect(deviceStore.getDeviceById).toHaveBeenCalledTimes(1); + expect(deviceStore.getDeviceById).toHaveBeenLastCalledWith(source.device); }); /** @@ -50,13 +68,9 @@ describe("Action", () => { */ it("getSettings", async () => { // Arrange. - const action = new KeyAction({ - device, - id: "ABC123", - manifestId: "com.elgato.test.one" - }); + const action = new Action(source); - // Act (Command). + // Array, act (Command). const settings = action.getSettings(); // Assert (Command). @@ -113,13 +127,42 @@ describe("Action", () => { }); }); - describe("sending", () => { - const action = new KeyAction({ - device, - id: "ABC123", - manifestId: "com.elgato.test.one" + /** + * Asserts type-checking when the controller is "Keypad". + */ + test("keypad type assertion", () => { + const action = new Action({ + ...source, + payload: { + ...source.payload, + controller: "Keypad" + } }); + expect(action.isKey()).toBe(true); + expect(action.isDial()).toBe(false); + }); + + /** + * Asserts type-checking when the controller is "Encoder". + */ + test("encoder type assertion", () => { + const action = new DialAction({ + ...source, + payload: { + ...source.payload, + controller: "Encoder" + } + } as WillAppear); + + expect(action.isDial()).toBe(true); + expect(action.isKey()).toBe(false); + }); + + describe("sending", () => { + let action!: Action; + beforeAll(() => (action = new Action(source))); + /** * Asserts {@link Action.sendToPropertyInspector} forwards the command to the {@link connection}. */ @@ -159,5 +202,20 @@ describe("Action", () => { } }); }); + + /** + * Asserts {@link Action.showAlert} forwards the command to the {@link connection}. + */ + it("showAlert", async () => { + // Arrange, act. + await action.showAlert(); + + // Assert. + expect(connection.send).toHaveBeenCalledTimes(1); + expect(connection.send).toHaveBeenCalledWith<[ShowAlert]>({ + context: action.id, + event: "showAlert" + }); + }); }); }); diff --git a/src/plugin/actions/__tests__/dial.test.ts b/src/plugin/actions/__tests__/dial.test.ts index 57abaa2b..0814c7ac 100644 --- a/src/plugin/actions/__tests__/dial.test.ts +++ b/src/plugin/actions/__tests__/dial.test.ts @@ -1,55 +1,89 @@ -import { type SetFeedback, type SetFeedbackLayout, type SetImage, type SetTriggerDescription, type WillAppear } from "../../../api"; +import { DeviceType, type SetFeedback, type SetFeedbackLayout, type SetImage, type SetTriggerDescription, type WillAppear } from "../../../api"; import type { JsonObject } from "../../../common/json"; import { connection } from "../../connection"; -import { Device } from "../../devices"; +import { Device } from "../../devices/device"; +import { deviceStore } from "../../devices/store"; import { Action } from "../action"; import { DialAction } from "../dial"; -import type { ActionContext } from "../store"; +jest.mock("../../devices/store"); jest.mock("../../logging"); jest.mock("../../manifest"); jest.mock("../../connection"); describe("DialAction", () => { - // Mock context. - const context: ActionContext = { - // @ts-expect-error Mocked device. - device: new Device(), - controller: "Keypad", - id: "ABC123", - manifestId: "com.elgato.test.one" + // Mock source. + const source: WillAppear = { + action: "com.test.action.one", + context: "action123", + device: "device123", + event: "willAppear", + payload: { + controller: "Encoder", + coordinates: { + column: 1, + row: 2 + }, + isInMultiAction: false, + settings: {} + } }; - // Mock source. - const source: WillAppear["payload"] = { - controller: "Encoder", - coordinates: { - column: 1, - row: 2 + // Mock device. + const device = new Device( + "device123", + { + name: "Device 1", + size: { + columns: 5, + rows: 3 + }, + type: DeviceType.StreamDeck }, - isInMultiAction: false, - settings: {} - }; + true + ); + + beforeAll(() => jest.spyOn(deviceStore, "getDeviceById").mockReturnValue(device)); /** - * Asserts the constructor of {@link DialAction} sets the context. + * Asserts the constructor of {@link DialAction} sets the properties from the source. */ - it("constructor sets context", () => { + it("constructor sets properties from source", () => { // Arrange, act. - const action = new DialAction(context, source); + const action = new DialAction(source); // Assert. expect(action).toBeInstanceOf(Action); expect(action.coordinates).not.toBeUndefined(); expect(action.coordinates?.column).toBe(1); expect(action.coordinates?.row).toBe(2); - expect(action.device).toBe(context.device); - expect(action.id).toBe(context.id); - expect(action.manifestId).toBe(context.manifestId); + expect(action.device).toBe(device); + expect(action.id).toBe(source.context); + expect(action.manifestId).toBe(source.action); + expect(deviceStore.getDeviceById).toHaveBeenCalledTimes(1); + expect(deviceStore.getDeviceById).toHaveBeenLastCalledWith(source.device); + }); + + /** + * Asserts the constructor of {@link DialAction} throws when the event is for a keypad. + */ + it("throws for non encoder", () => { + // Arrange. + const keypadSource: WillAppear = { + ...source, + payload: { + ...source.payload, + controller: "Keypad" + } + }; + + // Act, assert. + expect(() => new DialAction(keypadSource)).toThrow(); }); describe("sending", () => { - const action = new DialAction(context, source); + let action!: DialAction; + beforeAll(() => (action = new DialAction(source))); /** * Asserts {@link DialAction.setFeedback} forwards the command to the {@link connection}. @@ -120,6 +154,24 @@ describe("DialAction", () => { }); }); + /** + * Asserts {@link DialAction.setTitle} forwards the command to the {@link connection}. + */ + it("setTitle", async () => { + // Arrange, act. + await action.setTitle("Hello world"); + + // Assert. + expect(connection.send).toHaveBeenCalledTimes(1); + expect(connection.send).toHaveBeenCalledWith<[SetFeedback]>({ + context: action.id, + event: "setFeedback", + payload: { + title: "Hello world" + } + }); + }); + /** * Asserts {@link DialAction.setTriggerDescription} forwards the command to the {@link connection}. */ diff --git a/src/plugin/actions/__tests__/key.test.ts b/src/plugin/actions/__tests__/key.test.ts index 0f1bad97..e06286ed 100644 --- a/src/plugin/actions/__tests__/key.test.ts +++ b/src/plugin/actions/__tests__/key.test.ts @@ -1,52 +1,92 @@ -import { Target, type SetImage, type SetState, type SetTitle, type ShowOk, type WillAppear } from "../../../api"; +import { DeviceType, Target, type SetImage, type SetState, type SetTitle, type ShowOk, type WillAppear } from "../../../api"; import type { JsonObject } from "../../../common/json"; import { connection } from "../../connection"; import { Device } from "../../devices/device"; +import { deviceStore } from "../../devices/store"; import { Action } from "../action"; import { KeyAction } from "../key"; -import type { ActionContext } from "../store"; -jest.mock("../../devices/device"); +jest.mock("../../devices/store"); jest.mock("../../logging"); jest.mock("../../manifest"); jest.mock("../../connection"); describe("KeyAction", () => { - // Mock context. - const context: ActionContext = { - // @ts-expect-error Mocked device. - device: new Device(), - controller: "Keypad", - id: "ABC123", - manifestId: "com.elgato.test.one" + // Mock source. + const source: WillAppear = { + action: "com.test.action.one", + context: "action123", + device: "device123", + event: "willAppear", + payload: { + controller: "Keypad", + coordinates: { + column: 1, + row: 2 + }, + isInMultiAction: false, + settings: {} + } }; - // Mock source. - const source: WillAppear["payload"] = { - controller: "Keypad", - coordinates: { - column: 1, - row: 2 + // Mock device. + const device = new Device( + "dev1", + { + name: "Device 1", + size: { + columns: 5, + rows: 3 + }, + type: DeviceType.StreamDeck }, - isInMultiAction: false, - settings: {} - }; + true + ); + + beforeAll(() => jest.spyOn(deviceStore, "getDeviceById").mockReturnValue(device)); /** * Asserts the constructor of {@link KeyAction} sets the context. */ it("constructor sets context", () => { // Arrange, act. - const action = new KeyAction(context, source); + const action = new KeyAction(source); // Assert. expect(action).toBeInstanceOf(Action); expect(action.coordinates).not.toBeUndefined(); expect(action.coordinates?.column).toBe(1); expect(action.coordinates?.row).toBe(2); - expect(action.device).toBe(context.device); - expect(action.id).toBe(context.id); - expect(action.manifestId).toBe(context.manifestId); + expect(action.device).toBe(device); + expect(action.id).toBe(source.context); + expect(action.manifestId).toBe(source.action); + expect(deviceStore.getDeviceById).toHaveBeenCalledTimes(1); + expect(deviceStore.getDeviceById).toHaveBeenLastCalledWith(source.device); + }); + + /** + * Asserts the constructor of {@link DialAction} throws when the event is for a keypad. + */ + it("throws for non keypad", () => { + // Arrange. + const encoderSource: WillAppear = { + action: "com.test.action.one", + context: "action1", + device: "dev1", + event: "willAppear", + payload: { + controller: "Encoder", + coordinates: { + column: 1, + row: 2 + }, + isInMultiAction: false, + settings: {} + } + }; + + // Act, assert. + expect(() => new KeyAction(encoderSource)).toThrow(); }); /** @@ -54,9 +94,16 @@ describe("KeyAction", () => { */ it("does not have coordinates when multi-action", () => { // Arrange, act. - const action = new KeyAction(context, { - ...source, - isInMultiAction: true + const action = new KeyAction({ + action: "action1", + context: "com.test.action.one", + device: "dev1", + event: "willAppear", + payload: { + controller: "Keypad", + settings: {}, + isInMultiAction: true + } }); // Assert. @@ -64,7 +111,8 @@ describe("KeyAction", () => { }); describe("sending", () => { - const action = new KeyAction(context, source); + let action!: KeyAction; + beforeAll(() => (action = new KeyAction(source))); /** * Asserts {@link KeyAction.setImage} forwards the command to the {@link connection}. @@ -130,7 +178,7 @@ describe("KeyAction", () => { expect(connection.send).toHaveBeenCalledTimes(2); expect(connection.send).toHaveBeenNthCalledWith<[SetTitle]>(1, { event: "setTitle", - context: "ABC123", + context: action.id, payload: { title: "Hello world" } @@ -138,7 +186,7 @@ describe("KeyAction", () => { expect(connection.send).toHaveBeenNthCalledWith<[SetTitle]>(2, { event: "setTitle", - context: "ABC123", + context: action.id, payload: { state: 1, target: Target.Software, diff --git a/src/plugin/actions/__tests__/index.test.ts b/src/plugin/actions/__tests__/service.test.ts similarity index 92% rename from src/plugin/actions/__tests__/index.test.ts rename to src/plugin/actions/__tests__/service.test.ts index 9bd6251f..7a69af9a 100644 --- a/src/plugin/actions/__tests__/index.test.ts +++ b/src/plugin/actions/__tests__/service.test.ts @@ -1,24 +1,3 @@ -import { actionService } from ".."; -import { - DeviceType, - SingletonAction, - type DialAction, - type DialDownEvent, - type DialRotateEvent, - type DialUpEvent, - type DidReceiveSettingsEvent, - type JsonObject, - type KeyAction, - type KeyDownEvent, - type KeyUpEvent, - type PropertyInspectorDidAppearEvent, - type PropertyInspectorDidDisappearEvent, - type SendToPluginEvent, - type TitleParametersDidChangeEvent, - type TouchTapEvent, - type WillAppearEvent, - type WillDisappearEvent -} from "../.."; import type { DialDown, DialRotate, @@ -35,37 +14,39 @@ import type { WillDisappear } from "../../../api"; import { Settings } from "../../../api/__mocks__/events"; +import { JsonObject } from "../../../common/json"; import { connection } from "../../connection"; -import { devices } from "../../devices"; -import { Device } from "../../devices/device"; +import { + type DialDownEvent, + type DialRotateEvent, + type DialUpEvent, + type DidReceiveSettingsEvent, + type KeyDownEvent, + type KeyUpEvent, + type PropertyInspectorDidAppearEvent, + type PropertyInspectorDidDisappearEvent, + type SendToPluginEvent, + type TitleParametersDidChangeEvent, + type TouchTapEvent, + type WillAppearEvent, + type WillDisappearEvent +} from "../../events"; import type { onDidReceiveSettings } from "../../settings"; import type { UIController } from "../../ui"; +import { ActionContext } from "../context"; +import { DialAction } from "../dial"; +import { KeyAction } from "../key"; +import { actionService } from "../service"; +import { SingletonAction } from "../singleton-action"; import { actionStore } from "../store"; jest.mock("../store"); -jest.mock("../../devices"); +jest.mock("../../devices/store"); jest.mock("../../connection"); jest.mock("../../logging"); jest.mock("../../manifest"); describe("actions", () => { - const device = new Device( - "device123", - { - name: "Device 1", - size: { - columns: 5, - rows: 3 - }, - type: DeviceType.StreamDeck - }, - false - ); - - beforeAll(() => { - jest.spyOn(devices, "getDeviceById").mockReturnValue(device); - }); - describe("event emitters", () => { /** * Asserts {@link onDialDown} is invoked when `dialDown` is emitted. @@ -74,8 +55,8 @@ describe("actions", () => { // Arrange. const listener = jest.fn(); const ev = { - action: "com.elgato.test.one", - context: "dial123", // Mocked in actionStore + action: "com.elgato.test.dial", + context: "dial123", device: "device123", event: "dialDown", payload: { @@ -118,8 +99,8 @@ describe("actions", () => { // Arrange. const listener = jest.fn(); const ev = { - action: "com.elgato.test.one", - context: "dial123", // Mocked in actionStore + action: "com.elgato.test.dial", + context: "dial123", device: "device123", event: "dialRotate", payload: { @@ -164,8 +145,8 @@ describe("actions", () => { // Arrange. const listener = jest.fn(); const ev = { - action: "com.elgato.test.one", - context: "dial123", // Mocked in actionStore + action: "com.elgato.test.dial", + context: "dial123", device: "device123", event: "dialUp", payload: { @@ -208,8 +189,8 @@ describe("actions", () => { // Arrange. const listener = jest.fn(); const ev = { - action: "com.elgato.test.one", - context: "key123", // Mocked in actionStore + action: "com.elgato.test.key", + context: "key123", device: "device123", event: "keyDown", payload: { @@ -253,8 +234,8 @@ describe("actions", () => { // Arrange. const listener = jest.fn(); const ev = { - action: "com.elgato.test.one", - context: "key123", // Mocked in actionStore + action: "com.elgato.test.key", + context: "key123", device: "device123", event: "keyUp", payload: { @@ -298,8 +279,8 @@ describe("actions", () => { // Arrange. const listener = jest.fn(); const ev = { - action: "com.elgato.test.one", - context: "key123", // Mocked in actionStore + action: "com.elgato.test.key", + context: "key123", device: "device123", event: "titleParametersDidChange", payload: { @@ -352,8 +333,8 @@ describe("actions", () => { // Arrange. const listener = jest.fn(); const ev = { - action: "com.elgato.test.one", - context: "dial123", // Mocked in actionStore + action: "com.elgato.test.dial", + context: "dial123", device: "device123", event: "touchTap", payload: { @@ -398,8 +379,8 @@ describe("actions", () => { // Arrange. const listener = jest.fn(); const ev = { - action: "com.elgato.test.one", - context: "key123", // Mocked in actionStore. + action: "com.elgato.test.key", + context: "key123", device: "device123", event: "willAppear", payload: { @@ -443,7 +424,7 @@ describe("actions", () => { // Arrange. const listener = jest.fn(); const ev = { - action: "com.elgato.test.one", + action: "com.elgato.test.key", context: "context123", device: "device123", event: "willDisappear", @@ -467,12 +448,7 @@ describe("actions", () => { // Assert (emit). expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[WillDisappearEvent]>({ - action: { - controller: "Encoder", - device, - id: ev.context, - manifestId: ev.action - }, + action: new ActionContext(ev), deviceId: ev.device, payload: ev.payload, type: "willDisappear" @@ -553,9 +529,10 @@ describe("actions", () => { it("routes onDialDown", () => { // Arrange. const listener = jest.fn(); + const ev = { action: dialManifestId, - context: "dial123", // Mocked in actionStore. + context: "dial123", device: "device123", event: "dialDown", payload: { @@ -597,7 +574,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: dialManifestId, - context: "dial123", // Mocked in actionStore + context: "dial123", device: "device123", event: "dialRotate", payload: { @@ -641,7 +618,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: dialManifestId, - context: "dial123", // Mocked in actionStore + context: "dial123", device: "device123", event: "dialUp", payload: { @@ -683,7 +660,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: keyManifestId, - context: "key123", // Mocked in actionStore + context: "key123", event: "sendToPlugin", payload: { name: "Hello world" @@ -718,7 +695,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: keyManifestId, - context: "key123", // Mocked in actionStore + context: "key123", device: "device123", event: "didReceiveSettings", payload: { @@ -761,7 +738,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: keyManifestId, - context: "key123", // Mocked in actionStore + context: "key123", device: "device123", event: "keyDown", payload: { @@ -804,7 +781,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: keyManifestId, - context: "key123", // Mocked in actionStore + context: "key123", device: "device123", event: "keyUp", payload: { @@ -847,7 +824,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: keyManifestId, - context: "key123", // Mocked in actionStore + context: "key123", device: "device123", event: "propertyInspectorDidAppear" } satisfies PropertyInspectorDidAppear; @@ -878,7 +855,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: keyManifestId, - context: "key123", // Mocked in actionStore + context: "key123", device: "device123", event: "propertyInspectorDidDisappear" } satisfies PropertyInspectorDidDisappear; @@ -909,7 +886,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: keyManifestId, - context: "key123", // Mocked in actionStore + context: "key123", device: "device123", event: "titleParametersDidChange", payload: { @@ -961,7 +938,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: dialManifestId, - context: "dial123", // Mocked in actionStore + context: "dial123", device: "device123", event: "touchTap", payload: { @@ -1005,7 +982,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: keyManifestId, - context: "key123", // Mocked in actionStore + context: "key123", device: "device123", event: "willAppear", payload: { @@ -1048,7 +1025,7 @@ describe("actions", () => { const listener = jest.fn(); const ev = { action: keyManifestId, - context: "key123", // Mocked in actionStore + context: "key123", device: "device123", event: "willDisappear", payload: { @@ -1076,12 +1053,7 @@ describe("actions", () => { // Assert. expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[WillDisappearEvent]>({ - action: { - controller: "Encoder", - device, - id: ev.context, - manifestId: ev.action - }, + action: new ActionContext(ev), deviceId: ev.device, payload: ev.payload, type: "willDisappear" diff --git a/src/plugin/actions/context.ts b/src/plugin/actions/context.ts index 71433169..48a34bc8 100644 --- a/src/plugin/actions/context.ts +++ b/src/plugin/actions/context.ts @@ -1,6 +1,7 @@ import type { Controller, WillAppear, WillDisappear } from "../../api"; import type { JsonObject } from "../../common/json"; -import { deviceStore, type Device } from "../devices"; +import type { Device } from "../devices"; +import { deviceStore } from "../devices/store"; /** * Provides information about an instance of a Stream Deck action. diff --git a/src/plugin/actions/dial.ts b/src/plugin/actions/dial.ts index b9de7e68..0f8c1bea 100644 --- a/src/plugin/actions/dial.ts +++ b/src/plugin/actions/dial.ts @@ -22,7 +22,7 @@ export class DialAction extends Action { super(source); if (source.payload.controller !== "Encoder") { - throw new Error("Unable to create DialAction a source that isn't an Encoder"); + throw new Error("Unable to create DialAction; source event is not a Encoder"); } this.#coordinates = Object.freeze(source.payload.coordinates); diff --git a/src/plugin/actions/index.ts b/src/plugin/actions/index.ts index abb5f431..5aa0f1b7 100644 --- a/src/plugin/actions/index.ts +++ b/src/plugin/actions/index.ts @@ -1,243 +1,7 @@ -import type { DialDown, DialRotate, DialUp, KeyDown, KeyUp, TitleParametersDidChange, TouchTap, WillAppear, WillDisappear } from "../../api"; -import type { IDisposable } from "../../common/disposable"; -import { ActionEvent } from "../../common/events"; -import type { JsonObject } from "../../common/json"; -import { connection } from "../connection"; -import { - DialDownEvent, - DialRotateEvent, - DialUpEvent, - KeyDownEvent, - KeyUpEvent, - TitleParametersDidChangeEvent, - TouchTapEvent, - WillAppearEvent, - WillDisappearEvent -} from "../events"; -import { getManifest } from "../manifest"; -import { onDidReceiveSettings } from "../settings"; -import { ui } from "../ui"; -import { Action } from "./action"; -import { ActionContext } from "./context"; -import type { SingletonAction } from "./singleton-action"; -import { ActionStore, actionStore } from "./store"; - -const manifest = getManifest(); - -/** - * Provides functions, and information, for interacting with Stream Deck actions. - */ -class ActionService extends ActionStore { - /** - * Occurs when the user presses a dial (Stream Deck +). See also {@link onDialUp}. - * - * NB: For other action types see {@link onKeyDown}. - * @template T The type of settings associated with the action. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. - */ - public onDialDown(listener: (ev: DialDownEvent) => void): IDisposable { - return connection.disposableOn("dialDown", (ev: DialDown) => { - const action = actionStore.getActionById(ev.context); - if (action?.isDial()) { - listener(new ActionEvent(action, ev)); - } - }); - } - - /** - * Occurs when the user rotates a dial (Stream Deck +). - * @template T The type of settings associated with the action. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. - */ - public onDialRotate(listener: (ev: DialRotateEvent) => void): IDisposable { - return connection.disposableOn("dialRotate", (ev: DialRotate) => { - const action = actionStore.getActionById(ev.context); - if (action?.isDial()) { - listener(new ActionEvent(action, ev)); - } - }); - } - - /** - * Occurs when the user releases a pressed dial (Stream Deck +). See also {@link onDialDown}. - * - * NB: For other action types see {@link onKeyUp}. - * @template T The type of settings associated with the action. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. - */ - public onDialUp(listener: (ev: DialUpEvent) => void): IDisposable { - return connection.disposableOn("dialUp", (ev: DialUp) => { - const action = actionStore.getActionById(ev.context); - if (action?.isDial()) { - listener(new ActionEvent(action, ev)); - } - }); - } - - /** - * Occurs when the user presses a action down. See also {@link onKeyUp}. - * - * NB: For dials / touchscreens see {@link onDialDown}. - * @template T The type of settings associated with the action. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. - */ - public onKeyDown(listener: (ev: KeyDownEvent) => void): IDisposable { - return connection.disposableOn("keyDown", (ev: KeyDown) => { - const action = actionStore.getActionById(ev.context); - if (action?.isKey()) { - listener(new ActionEvent(action, ev)); - } - }); - } - - /** - * Occurs when the user releases a pressed action. See also {@link onKeyDown}. - * - * NB: For dials / touchscreens see {@link onDialUp}. - * @template T The type of settings associated with the action. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. - */ - public onKeyUp(listener: (ev: KeyUpEvent) => void): IDisposable { - return connection.disposableOn("keyUp", (ev: KeyUp) => { - const action = actionStore.getActionById(ev.context); - if (action?.isKey()) { - listener(new ActionEvent(action, ev)); - } - }); - } - - /** - * Occurs when the user updates an action's title settings in the Stream Deck application. See also {@link Action.setTitle}. - * @template T The type of settings associated with the action. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. - */ - public onTitleParametersDidChange(listener: (ev: TitleParametersDidChangeEvent) => void): IDisposable { - return connection.disposableOn("titleParametersDidChange", (ev: TitleParametersDidChange) => { - const action = actionStore.getActionById(ev.context); - if (action) { - listener(new ActionEvent(action, ev)); - } - }); - } - - /** - * Occurs when the user taps the touchscreen (Stream Deck +). - * @template T The type of settings associated with the action. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. - */ - public onTouchTap(listener: (ev: TouchTapEvent) => void): IDisposable { - return connection.disposableOn("touchTap", (ev: TouchTap) => { - const action = actionStore.getActionById(ev.context); - if (action?.isDial()) { - listener(new ActionEvent(action, ev)); - } - }); - } - - /** - * Occurs when an action appears on the Stream Deck due to the user navigating to another page, profile, folder, etc. This also occurs during startup if the action is on the "front - * page". An action refers to _all_ types of actions, e.g. keys, dials, - * @template T The type of settings associated with the action. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. - */ - public onWillAppear(listener: (ev: WillAppearEvent) => void): IDisposable { - return connection.disposableOn("willAppear", (ev: WillAppear) => { - const action = actionStore.getActionById(ev.context); - if (action) { - listener(new ActionEvent(action, ev)); - } - }); - } - - /** - * Occurs when an action disappears from the Stream Deck due to the user navigating to another page, profile, folder, etc. An action refers to _all_ types of actions, e.g. keys, - * dials, touchscreens, pedals, etc. - * @template T The type of settings associated with the action. - * @param listener Function to be invoked when the event occurs. - * @returns A disposable that, when disposed, removes the listener. - */ - public onWillDisappear(listener: (ev: WillDisappearEvent) => void): IDisposable { - return connection.disposableOn("willDisappear", (ev: WillDisappear) => listener(new ActionEvent(new ActionContext(ev), ev))); - } - - /** - * Registers the action with the Stream Deck, routing all events associated with the {@link SingletonAction.manifestId} to the specified {@link action}. - * @param action The action to register. - * @example - * ï¼ action({ UUID: "com.elgato.test.action" }) - * class MyCustomAction extends SingletonAction { - * export function onKeyDown(ev: KeyDownEvent) { - * // Do some awesome thing. - * } - * } - * - * streamDeck.actions.registerAction(new MyCustomAction()); - */ - public registerAction, TSettings extends JsonObject = JsonObject>(action: TAction): void { - if (action.manifestId === undefined) { - throw new Error("The action's manifestId cannot be undefined."); - } - - if (!manifest.Actions.some((a) => a.UUID === action.manifestId)) { - throw new Error(`The action's manifestId was not found within the manifest: ${action.manifestId}`); - } - - // Routes an event to the action, when the applicable listener is defined on the action. - const { manifestId } = action; - const route = >( - fn: (listener: (ev: TEventArgs) => void) => IDisposable, - listener: ((ev: TEventArgs) => Promise | void) | undefined - ): void => { - const boundedListener = listener?.bind(action); - if (boundedListener === undefined) { - return; - } - - fn.bind(action)(async (ev) => { - if (ev.action.manifestId == manifestId) { - await boundedListener(ev); - } - }); - }; - - // Route each of the action events. - route(this.onDialDown, action.onDialDown); - route(this.onDialUp, action.onDialUp); - route(this.onDialRotate, action.onDialRotate); - route(ui.onSendToPlugin, action.onSendToPlugin); - route(onDidReceiveSettings, action.onDidReceiveSettings); - route(this.onKeyDown, action.onKeyDown); - route(this.onKeyUp, action.onKeyUp); - route(ui.onDidAppear, action.onPropertyInspectorDidAppear); - route(ui.onDidDisappear, action.onPropertyInspectorDidDisappear); - route(this.onTitleParametersDidChange, action.onTitleParametersDidChange); - route(this.onTouchTap, action.onTouchTap); - route(this.onWillAppear, action.onWillAppear); - route(this.onWillDisappear, action.onWillDisappear); - } -} - -/** - * Service for interacting with Stream Deck actions. - */ -export const actionService = new ActionService(); - -export { type ActionService }; - -/** - * Event associated with an {@link Action}. - */ -type RoutingEvent = { - /** - * The {@link Action} the event is associated with. - */ - action: Action | ActionContext; -}; +export type { Action } from "./action"; +export type { ActionContext } from "./context"; +export { action } from "./decorators"; +export type { DialAction, TriggerDescriptionOptions } from "./dial"; +export type { ImageOptions, KeyAction, TitleOptions } from "./key"; +export type { ActionService } from "./service"; +export { SingletonAction } from "./singleton-action"; diff --git a/src/plugin/actions/key.ts b/src/plugin/actions/key.ts index 4fcced7d..21874a15 100644 --- a/src/plugin/actions/key.ts +++ b/src/plugin/actions/key.ts @@ -26,6 +26,10 @@ export class KeyAction extends Action { constructor(source: WillAppear) { super(source); + if (source.payload.controller !== "Keypad") { + throw new Error("Unable to create KeyAction; source event is not a Keypad"); + } + this.#coordinates = !source.payload.isInMultiAction ? Object.freeze(source.payload.coordinates) : undefined; this.#source = source; } diff --git a/src/plugin/actions/service.ts b/src/plugin/actions/service.ts new file mode 100644 index 00000000..a63e7257 --- /dev/null +++ b/src/plugin/actions/service.ts @@ -0,0 +1,261 @@ +import type { DialDown, DialRotate, DialUp, KeyDown, KeyUp, TitleParametersDidChange, TouchTap, WillAppear, WillDisappear } from "../../api"; +import type { IDisposable } from "../../common/disposable"; +import { ActionEvent } from "../../common/events"; +import type { JsonObject } from "../../common/json"; +import { connection } from "../connection"; +import { + DialDownEvent, + DialRotateEvent, + DialUpEvent, + KeyDownEvent, + KeyUpEvent, + TitleParametersDidChangeEvent, + TouchTapEvent, + WillAppearEvent, + WillDisappearEvent +} from "../events"; +import { getManifest } from "../manifest"; +import { onDidReceiveSettings } from "../settings"; +import { ui } from "../ui"; +import { Action } from "./action"; +import { ActionContext } from "./context"; +import { DialAction } from "./dial"; +import { KeyAction } from "./key"; +import type { SingletonAction } from "./singleton-action"; +import { ReadOnlyActionStore, actionStore } from "./store"; + +const manifest = getManifest(); + +/** + * Provides functions, and information, for interacting with Stream Deck actions. + */ +class ActionService extends ReadOnlyActionStore { + /** + * Initializes a new instance of the {@link ActionService} class. + */ + constructor() { + super(); + + // Adds the action to the store. + connection.prependListener("willAppear", (ev) => { + const action = ev.payload.controller === "Encoder" ? new DialAction(ev) : new KeyAction(ev); + actionStore.set(action); + }); + + // Remove the action from the store. + connection.prependListener("willDisappear", (ev) => actionStore.delete(ev.context)); + } + + /** + * Occurs when the user presses a dial (Stream Deck +). See also {@link onDialUp}. + * + * NB: For other action types see {@link onKeyDown}. + * @template T The type of settings associated with the action. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onDialDown(listener: (ev: DialDownEvent) => void): IDisposable { + return connection.disposableOn("dialDown", (ev: DialDown) => { + const action = actionStore.getActionById(ev.context); + if (action?.isDial()) { + listener(new ActionEvent(action, ev)); + } + }); + } + + /** + * Occurs when the user rotates a dial (Stream Deck +). + * @template T The type of settings associated with the action. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onDialRotate(listener: (ev: DialRotateEvent) => void): IDisposable { + return connection.disposableOn("dialRotate", (ev: DialRotate) => { + const action = actionStore.getActionById(ev.context); + if (action?.isDial()) { + listener(new ActionEvent(action, ev)); + } + }); + } + + /** + * Occurs when the user releases a pressed dial (Stream Deck +). See also {@link onDialDown}. + * + * NB: For other action types see {@link onKeyUp}. + * @template T The type of settings associated with the action. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onDialUp(listener: (ev: DialUpEvent) => void): IDisposable { + return connection.disposableOn("dialUp", (ev: DialUp) => { + const action = actionStore.getActionById(ev.context); + if (action?.isDial()) { + listener(new ActionEvent(action, ev)); + } + }); + } + + /** + * Occurs when the user presses a action down. See also {@link onKeyUp}. + * + * NB: For dials / touchscreens see {@link onDialDown}. + * @template T The type of settings associated with the action. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onKeyDown(listener: (ev: KeyDownEvent) => void): IDisposable { + return connection.disposableOn("keyDown", (ev: KeyDown) => { + const action = actionStore.getActionById(ev.context); + if (action?.isKey()) { + listener(new ActionEvent(action, ev)); + } + }); + } + + /** + * Occurs when the user releases a pressed action. See also {@link onKeyDown}. + * + * NB: For dials / touchscreens see {@link onDialUp}. + * @template T The type of settings associated with the action. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onKeyUp(listener: (ev: KeyUpEvent) => void): IDisposable { + return connection.disposableOn("keyUp", (ev: KeyUp) => { + const action = actionStore.getActionById(ev.context); + if (action?.isKey()) { + listener(new ActionEvent(action, ev)); + } + }); + } + + /** + * Occurs when the user updates an action's title settings in the Stream Deck application. See also {@link Action.setTitle}. + * @template T The type of settings associated with the action. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onTitleParametersDidChange(listener: (ev: TitleParametersDidChangeEvent) => void): IDisposable { + return connection.disposableOn("titleParametersDidChange", (ev: TitleParametersDidChange) => { + const action = actionStore.getActionById(ev.context); + if (action) { + listener(new ActionEvent(action, ev)); + } + }); + } + + /** + * Occurs when the user taps the touchscreen (Stream Deck +). + * @template T The type of settings associated with the action. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onTouchTap(listener: (ev: TouchTapEvent) => void): IDisposable { + return connection.disposableOn("touchTap", (ev: TouchTap) => { + const action = actionStore.getActionById(ev.context); + if (action?.isDial()) { + listener(new ActionEvent(action, ev)); + } + }); + } + + /** + * Occurs when an action appears on the Stream Deck due to the user navigating to another page, profile, folder, etc. This also occurs during startup if the action is on the "front + * page". An action refers to _all_ types of actions, e.g. keys, dials, + * @template T The type of settings associated with the action. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onWillAppear(listener: (ev: WillAppearEvent) => void): IDisposable { + return connection.disposableOn("willAppear", (ev: WillAppear) => { + const action = actionStore.getActionById(ev.context); + if (action) { + listener(new ActionEvent(action, ev)); + } + }); + } + + /** + * Occurs when an action disappears from the Stream Deck due to the user navigating to another page, profile, folder, etc. An action refers to _all_ types of actions, e.g. keys, + * dials, touchscreens, pedals, etc. + * @template T The type of settings associated with the action. + * @param listener Function to be invoked when the event occurs. + * @returns A disposable that, when disposed, removes the listener. + */ + public onWillDisappear(listener: (ev: WillDisappearEvent) => void): IDisposable { + return connection.disposableOn("willDisappear", (ev: WillDisappear) => listener(new ActionEvent(new ActionContext(ev), ev))); + } + + /** + * Registers the action with the Stream Deck, routing all events associated with the {@link SingletonAction.manifestId} to the specified {@link action}. + * @param action The action to register. + * @example + * ï¼ action({ UUID: "com.elgato.test.action" }) + * class MyCustomAction extends SingletonAction { + * export function onKeyDown(ev: KeyDownEvent) { + * // Do some awesome thing. + * } + * } + * + * streamDeck.actions.registerAction(new MyCustomAction()); + */ + public registerAction, TSettings extends JsonObject = JsonObject>(action: TAction): void { + if (action.manifestId === undefined) { + throw new Error("The action's manifestId cannot be undefined."); + } + + if (!manifest.Actions.some((a) => a.UUID === action.manifestId)) { + throw new Error(`The action's manifestId was not found within the manifest: ${action.manifestId}`); + } + + // Routes an event to the action, when the applicable listener is defined on the action. + const { manifestId } = action; + const route = >( + fn: (listener: (ev: TEventArgs) => void) => IDisposable, + listener: ((ev: TEventArgs) => Promise | void) | undefined + ): void => { + const boundedListener = listener?.bind(action); + if (boundedListener === undefined) { + return; + } + + fn.bind(action)(async (ev) => { + if (ev.action.manifestId == manifestId) { + await boundedListener(ev); + } + }); + }; + + // Route each of the action events. + route(this.onDialDown, action.onDialDown); + route(this.onDialUp, action.onDialUp); + route(this.onDialRotate, action.onDialRotate); + route(ui.onSendToPlugin, action.onSendToPlugin); + route(onDidReceiveSettings, action.onDidReceiveSettings); + route(this.onKeyDown, action.onKeyDown); + route(this.onKeyUp, action.onKeyUp); + route(ui.onDidAppear, action.onPropertyInspectorDidAppear); + route(ui.onDidDisappear, action.onPropertyInspectorDidDisappear); + route(this.onTitleParametersDidChange, action.onTitleParametersDidChange); + route(this.onTouchTap, action.onTouchTap); + route(this.onWillAppear, action.onWillAppear); + route(this.onWillDisappear, action.onWillDisappear); + } +} + +/** + * Service for interacting with Stream Deck actions. + */ +export const actionService = new ActionService(); + +export { type ActionService }; + +/** + * Event associated with an {@link Action}. + */ +type RoutingEvent = { + /** + * The {@link Action} the event is associated with. + */ + action: Action | ActionContext; +}; diff --git a/src/plugin/actions/store.ts b/src/plugin/actions/store.ts index ddcd30b2..557d4f6e 100644 --- a/src/plugin/actions/store.ts +++ b/src/plugin/actions/store.ts @@ -1,29 +1,18 @@ import { Enumerable } from "../../common/enumerable"; -import { connection } from "../connection"; -import { initializeStore } from "../devices/store"; -import { DialAction } from "./dial"; -import { KeyAction } from "./key"; +import type { DialAction } from "./dial"; +import type { KeyAction } from "./key"; -const __actions = new Map(); - -// Adds the action to the store. -connection.prependListener("willAppear", (ev) => { - const action = ev.payload.controller === "Encoder" ? new DialAction(ev) : new KeyAction(ev); - __actions.set(ev.context, action); -}); - -// Remove the action from the store. -connection.prependListener("willDisappear", (ev) => __actions.delete(ev.context)); +const __items = new Map(); /** - * Provides a store of visible actions. + * Provides a read-only store of Stream Deck devices. */ -export class ActionStore extends Enumerable { +export class ReadOnlyActionStore extends Enumerable { /** - * Initializes a new instance of the {@link ActionStore} class. + * Initializes a new instance of the {@link ReadOnlyActionStore}. */ constructor() { - super(__actions); + super(__items); } /** @@ -32,13 +21,32 @@ export class ActionStore extends Enumerable { * @returns The action, when present; otherwise `undefined`. */ public getActionById(id: string): DialAction | KeyAction | undefined { - return __actions.get(id); + return __items.get(id); } } /** - * Store of visible Stream Deck actions. + * Provides a store of Stream Deck actions. */ -export const actionStore = new ActionStore(); +class ActionStore extends ReadOnlyActionStore { + /** + * Adds the action to the store. + * @param action The action. + */ + public set(action: DialAction | KeyAction): void { + __items.set(action.id, action); + } + + /** + * Deletes the action from the store. + * @param action The action's identifier. + */ + public delete(id: string): void { + __items.delete(id); + } +} -initializeStore(actionStore); +/** + * Singleton instance of the action store. + */ +export const actionStore = new ActionStore(); diff --git a/src/plugin/devices/__mocks__/index.ts b/src/plugin/devices/__mocks__/index.ts new file mode 100644 index 00000000..28c58968 --- /dev/null +++ b/src/plugin/devices/__mocks__/index.ts @@ -0,0 +1,21 @@ +import { DeviceType } from "../../../api/device"; +import { type Device } from "../device"; + +export const deviceService = { + getDeviceById: jest.fn().mockImplementation((deviceId: string) => { + return { + actions: Array.from([]).values(), + id: "DEV1", + name: "Device One", + size: { + columns: 5, + rows: 3 + }, + type: DeviceType.StreamDeckXL, + isConnected: true + } satisfies { + // Public properties only. + [K in keyof Device]: Device[K]; + }; + }) +}; diff --git a/src/plugin/devices/__tests__/service.test.ts b/src/plugin/devices/__tests__/service.test.ts index dcaa1062..c791c096 100644 --- a/src/plugin/devices/__tests__/service.test.ts +++ b/src/plugin/devices/__tests__/service.test.ts @@ -1,21 +1,22 @@ -import { Device, type DeviceService } from ".."; import type { DeviceDidConnectEvent, DeviceDidDisconnectEvent } from "../.."; import { DeviceType, type DeviceDidConnect, type DeviceDidDisconnect } from "../../../api"; import { type connection as Connection } from "../../connection"; +import { Device } from "../device"; +import type { DeviceService } from "../service"; -jest.mock("../../actions/store"); +jest.mock("../../actions/store", () => {}); // Override default mock. jest.mock("../../connection"); jest.mock("../../logging"); jest.mock("../../manifest"); describe("devices", () => { let connection!: typeof Connection; - let devices!: DeviceService; + let deviceService!: DeviceService; beforeEach(async () => { jest.resetModules(); ({ connection } = await require("../../connection")); - ({ devices } = await require("../")); + ({ deviceService } = (await require("../service")) as typeof import("../service")); }); /** @@ -39,7 +40,7 @@ describe("devices", () => { const listener = jest.fn(); // Act. - devices.forEach(listener); + deviceService.forEach(listener); // Assert. expect(listener).toHaveBeenCalledTimes(2); @@ -58,7 +59,7 @@ describe("devices", () => { connection.emit("connected", connection.registrationParameters.info); // Act, assert: registration parameters are included. - expect(devices.length).toBe(1); + expect(deviceService.length).toBe(1); // Act, assert: count increments with new devices. const ev = { @@ -72,7 +73,7 @@ describe("devices", () => { } satisfies DeviceDidConnect; connection.emit("deviceDidConnect", ev); - expect(devices.length).toBe(2); + expect(deviceService.length).toBe(2); // Act, assert: count remains 2 when device disconnected connection.emit("deviceDidDisconnect", { @@ -80,7 +81,7 @@ describe("devices", () => { event: "deviceDidDisconnect" }); - expect(devices.length).toBe(2); + expect(deviceService.length).toBe(2); }); /** @@ -91,9 +92,9 @@ describe("devices", () => { connection.emit("connected", connection.registrationParameters.info); // Assert. - expect(devices.length).toBe(1); + expect(deviceService.length).toBe(1); - const [device] = devices; + const [device] = deviceService; expect(device.id).toBe(connection.registrationParameters.info.devices[0].id); expect(device.isConnected).toBeFalsy(); expect(device.name).toBe(connection.registrationParameters.info.devices[0].name); @@ -120,9 +121,9 @@ describe("devices", () => { }); // Assert. - expect(devices.length).toBe(1); + expect(deviceService.length).toBe(1); - const [device] = devices; + const [device] = deviceService; expect(device.id).toBe("__NEW_DEV__"); expect(device.isConnected).toBeTruthy(); expect(device.name).toBe("New Device"); @@ -151,7 +152,7 @@ describe("devices", () => { }); // Act. - const device = devices.getDeviceById("devices.test.ts.1"); + const device = deviceService.getDeviceById("devices.test.ts.1"); // Assert. expect(device).not.toBeUndefined(); @@ -168,7 +169,7 @@ describe("devices", () => { */ it("unknown identifier", () => { // Arrange, act, assert. - expect(devices.getDeviceById("__unknown")).toBeUndefined(); + expect(deviceService.getDeviceById("__unknown")).toBeUndefined(); }); }); @@ -180,7 +181,7 @@ describe("devices", () => { connection.emit("connected", connection.registrationParameters.info); // Act. - const [device] = devices; + const [device] = deviceService; expect(device.isConnected).toBeFalsy(); connection.emit("deviceDidConnect", { @@ -190,7 +191,7 @@ describe("devices", () => { }); // Assert. - expect(devices.length).toBe(1); + expect(deviceService.length).toBe(1); expect(device.id).toBe(connection.registrationParameters.info.devices[0].id); expect(device.isConnected).toBeTruthy(); @@ -207,7 +208,7 @@ describe("devices", () => { connection.emit("connected", connection.registrationParameters.info); // Act. - const [device] = devices; + const [device] = deviceService; expect(device.isConnected).toBeFalsy(); connection.emit("deviceDidConnect", { @@ -223,7 +224,7 @@ describe("devices", () => { }); // Assert. - expect(devices.length).toBe(1); + expect(deviceService.length).toBe(1); expect(device.id).toBe(connection.registrationParameters.info.devices[0].id); expect(device.isConnected).toBeFalsy(); @@ -240,7 +241,7 @@ describe("devices", () => { connection.emit("connected", connection.registrationParameters.info); // Act. - const [device] = devices; + const [device] = deviceService; expect(device.isConnected).toBeFalsy(); connection.emit("deviceDidDisconnect", { @@ -249,7 +250,7 @@ describe("devices", () => { }); // Assert. - expect(devices.length).toBe(1); + expect(deviceService.length).toBe(1); expect(device.id).toBe(connection.registrationParameters.info.devices[0].id); expect(device.isConnected).toBeFalsy(); @@ -278,7 +279,7 @@ describe("devices", () => { } satisfies DeviceDidConnect; // Act (emit). - const disposable = devices.onDeviceDidConnect(listener); + const disposable = deviceService.onDeviceDidConnect(listener); connection.emit("deviceDidConnect", ev); // Assert (emit). @@ -310,7 +311,7 @@ describe("devices", () => { } satisfies DeviceDidDisconnect; // Act (emit). - const disposable = devices.onDeviceDidDisconnect(listener); + const disposable = deviceService.onDeviceDidDisconnect(listener); connection.emit("deviceDidDisconnect", ev); // Assert (emit). diff --git a/src/plugin/devices/device.ts b/src/plugin/devices/device.ts index e4741706..85b05fae 100644 --- a/src/plugin/devices/device.ts +++ b/src/plugin/devices/device.ts @@ -1,18 +1,13 @@ import type { DeviceInfo, DeviceType, Size } from "../../api"; import type { DialAction } from "../actions/dial"; import type { KeyAction } from "../actions/key"; -import type { ActionStore } from "../actions/store"; +import { actionStore } from "../actions/store"; import { connection } from "../connection"; /** * Provides information about a device. */ export class Device { - /** - * Store of Stream Deck actions. - */ - #actionStore: ActionStore; - /** * Private backing field for {@link Device.isConnected}. */ @@ -33,13 +28,11 @@ export class Device { * @param id Device identifier. * @param info Information about the device. * @param isConnected Determines whether the device is connected. - * @param actionStore Store of Stream Deck actions. */ - constructor(id: string, info: DeviceInfo, isConnected: boolean, actionStore: ActionStore) { + constructor(id: string, info: DeviceInfo, isConnected: boolean) { this.id = id; this.#info = info; this.#isConnected = isConnected; - this.#actionStore = actionStore; // Set connected. connection.prependListener("deviceDidConnect", (ev) => { @@ -62,7 +55,7 @@ export class Device { * @returns Collection of visible actions. */ public get actions(): IterableIterator { - return this.#actionStore.filter((a) => a.device.id === this.id); + return actionStore.filter((a) => a.device.id === this.id); } /** diff --git a/src/plugin/devices/index.ts b/src/plugin/devices/index.ts index 5dce1770..da696b3e 100644 --- a/src/plugin/devices/index.ts +++ b/src/plugin/devices/index.ts @@ -1,3 +1,2 @@ -export { type Device } from "./device"; -export { devices, type DeviceService } from "./service"; -export { deviceStore } from "./store"; +export type { Device } from "./device"; +export type { DeviceService } from "./service"; diff --git a/src/plugin/devices/service.ts b/src/plugin/devices/service.ts index 540ee4fc..efef7973 100644 --- a/src/plugin/devices/service.ts +++ b/src/plugin/devices/service.ts @@ -1,19 +1,39 @@ import type { IDisposable } from "../../common/disposable"; import { connection } from "../connection"; -import { DeviceDidConnectEvent, DeviceDidDisconnectEvent, DeviceEvent } from "../events"; -import { DeviceStore, deviceStore } from "./store"; +import { DeviceEvent, type DeviceDidConnectEvent, type DeviceDidDisconnectEvent } from "../events"; +import { Device } from "./device"; +import { ReadOnlyDeviceStore, deviceStore } from "./store"; /** * Provides functions, and information, for interacting with Stream Deck actions. */ -class DeviceService extends DeviceStore { +class DeviceService extends ReadOnlyDeviceStore { + /** + * Initializes a new instance of the {@link DeviceService}. + */ + constructor() { + super(); + + // Add the devices from registration parameters. + connection.once("connected", (info) => { + info.devices.forEach((dev) => deviceStore.set(new Device(dev.id, dev, false))); + }); + + // Add new devices. + connection.on("deviceDidConnect", ({ device: id, deviceInfo }) => { + if (!deviceStore.getDeviceById(id)) { + deviceStore.set(new Device(id, deviceInfo, true)); + } + }); + } + /** * Occurs when a Stream Deck device is connected. See also {@link DeviceService.onDeviceDidConnect}. * @param listener Function to be invoked when the event occurs. * @returns A disposable that, when disposed, removes the listener. */ public onDeviceDidConnect(listener: (ev: DeviceDidConnectEvent) => void): IDisposable { - return connection.disposableOn("deviceDidConnect", (ev) => listener(new DeviceEvent(ev, deviceStore.getDeviceById(ev.device)!))); + return connection.disposableOn("deviceDidConnect", (ev) => listener(new DeviceEvent(ev, this.getDeviceById(ev.device)!))); } /** @@ -22,13 +42,13 @@ class DeviceService extends DeviceStore { * @returns A disposable that, when disposed, removes the listener. */ public onDeviceDidDisconnect(listener: (ev: DeviceDidDisconnectEvent) => void): IDisposable { - return connection.disposableOn("deviceDidDisconnect", (ev) => listener(new DeviceEvent(ev, deviceStore.getDeviceById(ev.device)!))); + return connection.disposableOn("deviceDidDisconnect", (ev) => listener(new DeviceEvent(ev, this.getDeviceById(ev.device)!))); } } /** * Provides functions, and information, for interacting with Stream Deck actions. */ -export const devices = new DeviceService(); +export const deviceService = new DeviceService(); export { type DeviceService }; diff --git a/src/plugin/devices/store.ts b/src/plugin/devices/store.ts index 2991110a..f8db5634 100644 --- a/src/plugin/devices/store.ts +++ b/src/plugin/devices/store.ts @@ -1,40 +1,17 @@ import { Enumerable } from "../../common/enumerable"; -import type { ActionStore } from "../actions/store"; -import { connection } from "../connection"; -import { Device } from "./device"; +import type { Device } from "./device"; -const __devices = new Map(); -let __actionStore: ActionStore; - -// Add the devices from registration parameters. -connection.once("connected", (info) => { - if (!__actionStore) { - throw new Error("Device store has not yet been initialized"); - } - - info.devices.forEach((dev) => __devices.set(dev.id, new Device(dev.id, dev, false, __actionStore))); -}); - -// Add new devices. -connection.on("deviceDidConnect", ({ device: id, deviceInfo }) => { - if (!__actionStore) { - throw new Error("Device store has not yet been initialized"); - } - - if (!__devices.get(id)) { - __devices.set(id, new Device(id, deviceInfo, true, __actionStore)); - } -}); +const __items = new Map(); /** - * Provides a store of Stream Deck devices. + * Provides a read-only store of Stream Deck devices. */ -export class DeviceStore extends Enumerable { +export class ReadOnlyDeviceStore extends Enumerable { /** - * Initializes a new instance of the {@link DeviceStore} class. + * Initializes a new instance of the {@link ReadOnlyDeviceStore}. */ constructor() { - super(__devices); + super(__items); } /** @@ -43,19 +20,24 @@ export class DeviceStore extends Enumerable { * @returns The Stream Deck device information; otherwise `undefined` if a device with the {@link deviceId} does not exist. */ public getDeviceById(deviceId: string): Device | undefined { - return __devices.get(deviceId); + return __items.get(deviceId); } } /** - * Store of Stream Deck devices. + * Provides a store of Stream Deck devices. */ -export const deviceStore = new DeviceStore(); +class DeviceStore extends ReadOnlyDeviceStore { + /** + * Adds the device to the store. + * @param device The device. + */ + public set(device: Device): void { + __items.set(device.id, device); + } +} /** - * Initializes the device store. - * @param actionStore Store of Stream Deck actions. + * Singleton instance of the device store. */ -export function initializeStore(actionStore: ActionStore): void { - __actionStore = actionStore; -} +export const deviceStore = new DeviceStore(); diff --git a/src/plugin/index.ts b/src/plugin/index.ts index ffee2443..e62b11ae 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -1,9 +1,9 @@ import type { Manifest, RegistrationInfo } from "../api"; import { I18nProvider } from "../common/i18n"; import { registerCreateLogEntryRoute, type Logger } from "../common/logging"; -import { actionService, type ActionService } from "./actions"; +import { actionService, type ActionService } from "./actions/service"; import { connection } from "./connection"; -import { devices, type DeviceService } from "./devices"; +import { deviceService, type DeviceService } from "./devices/service"; import { fileSystemLocaleProvider } from "./i18n"; import { logger } from "./logging"; import { getManifest } from "./manifest"; @@ -36,12 +36,8 @@ export { EventEmitter, EventsOf } from "../common/event-emitter"; export { type JsonObject, type JsonPrimitive, type JsonValue } from "../common/json"; export { LogLevel } from "../common/logging"; export { type MessageRequestOptions, type MessageResponder, type MessageResponse, type RouteConfiguration, type StatusCode } from "../common/messaging"; -export type { Action } from "./actions/action"; -export { action } from "./actions/decorators"; -export { type DialAction, type TriggerDescriptionOptions } from "./actions/dial"; -export { type ImageOptions, type KeyAction, type TitleOptions } from "./actions/key"; -export { SingletonAction } from "./actions/singleton-action"; -export { type Device, type DeviceService } from "./devices"; +export * from "./actions"; +export * from "./devices"; export * from "./events"; export { route, type MessageRequest, type PropertyInspector } from "./ui"; export { type Logger }; @@ -62,7 +58,7 @@ export const streamDeck = { * @returns Devices namespace. */ get devices(): DeviceService { - return devices; + return deviceService; }, /** diff --git a/src/plugin/ui/__tests__/property-inspector.test.ts b/src/plugin/ui/__tests__/property-inspector.test.ts index 07e16dd0..881b363e 100644 --- a/src/plugin/ui/__tests__/property-inspector.test.ts +++ b/src/plugin/ui/__tests__/property-inspector.test.ts @@ -24,7 +24,9 @@ describe("PropertyInspector", () => { }); // Assert. - expect(pi.action).toBe(actionStore.getActionById("key123")); + expect(actionStore.getActionById).toHaveBeenCalledTimes(1); + expect(actionStore.getActionById).toHaveBeenLastCalledWith("key123"); + expect(pi.action).toEqual(actionStore.getActionById("key123")); }); describe("fetch", () => { @@ -87,7 +89,7 @@ describe("PropertyInspector", () => { const spyOnSend = jest.spyOn(connection, "send"); const pi = new PropertyInspector(router, { action: "com.elgato.test.key", - context: "key123", // Mocked in actionStore. + context: "key123", device: "dev123" }); diff --git a/src/plugin/ui/__tests__/route.test.ts b/src/plugin/ui/__tests__/route.test.ts index 23b01d3d..3494814e 100644 --- a/src/plugin/ui/__tests__/route.test.ts +++ b/src/plugin/ui/__tests__/route.test.ts @@ -1,7 +1,9 @@ -import { action, type JsonObject, type MessageRequest } from "../.."; +import type { MessageRequest } from "../"; import type { PluginCommand, SendToPropertyInspector } from "../../../api"; +import type { JsonObject } from "../../../common/json"; import { MessageGateway, MessageResponder } from "../../../common/messaging"; import { PromiseCompletionSource } from "../../../common/promises"; +import { action } from "../../actions"; import { SingletonAction } from "../../actions/singleton-action"; import { actionStore } from "../../actions/store"; import { connection } from "../../connection"; diff --git a/src/plugin/ui/__tests__/router.test.ts b/src/plugin/ui/__tests__/router.test.ts index 0aae65ff..d7c92807 100644 --- a/src/plugin/ui/__tests__/router.test.ts +++ b/src/plugin/ui/__tests__/router.test.ts @@ -1,12 +1,12 @@ -import { MessageRequest, type MessageRequestOptions } from "../.."; +import { MessageRequest } from ".."; import type { DidReceivePropertyInspectorMessage, SendToPropertyInspector } from "../../../api"; +import type { MessageRequestOptions } from "../../../common/messaging"; import type { RawMessageRequest } from "../../../common/messaging/message"; import { MessageResponder } from "../../../common/messaging/responder"; import { PromiseCompletionSource } from "../../../common/promises"; -import { KeyAction } from "../../actions/key"; -import { actionStore, type ActionContext } from "../../actions/store"; +import type { Action } from "../../actions"; +import { actionStore } from "../../actions/store"; import { connection } from "../../connection"; -import { Device } from "../../devices/device"; import { PropertyInspector } from "../property-inspector"; import { getCurrentUI, router } from "../router"; @@ -35,7 +35,7 @@ describe("current UI", () => { // Arrange. connection.emit("propertyInspectorDidAppear", { action: "com.elgato.test.one", - context: "key123", // Mocked in actionStore. + context: "key123", device: "dev123", event: "propertyInspectorDidAppear" }); @@ -46,7 +46,7 @@ describe("current UI", () => { // Assert. expect(current).toBeInstanceOf(PropertyInspector); expect(current).not.toBeUndefined(); - expect(current?.action).toBe(actionStore.getActionById("key123")); + expect(current?.action).toEqual(actionStore.getActionById("key123")); }); /** @@ -63,7 +63,7 @@ describe("current UI", () => { connection.emit("propertyInspectorDidAppear", { action: "com.elgato.test.one", - context: "key123", // Mocked in actionStore. + context: "key123", device: "dev123", event: "propertyInspectorDidAppear" }); @@ -74,7 +74,7 @@ describe("current UI", () => { // Assert. expect(current).toBeInstanceOf(PropertyInspector); expect(current).not.toBeUndefined(); - expect(current?.action).toBe(actionStore.getActionById("key123")); + expect(current?.action).toEqual(actionStore.getActionById("key123")); }); /** @@ -86,7 +86,7 @@ describe("current UI", () => { const context = { action: action.manifestId, context: action.id, - device: undefined! + device: action.device.id }; connection.emit("propertyInspectorDidAppear", { @@ -116,7 +116,7 @@ describe("current UI", () => { const context = { action: action.manifestId, context: action.id, - device: undefined! + device: action.device.id }; connection.emit("propertyInspectorDidAppear", { @@ -155,7 +155,7 @@ describe("current UI", () => { // Arrange. connection.emit("propertyInspectorDidAppear", { action: "com.elgato.test.one", - context: "key123", // Mocked in actionStore. + context: "key123", device: "dev123", event: "propertyInspectorDidAppear" }); @@ -183,7 +183,7 @@ describe("current UI", () => { const spyOnFetch = jest.spyOn(router, "fetch"); connection.emit("propertyInspectorDidAppear", { action: "com.elgato.test.one", - context: "key123", // Mocked in actionStore. + context: "key123", device: "dev123", event: "propertyInspectorDidAppear" }); @@ -215,7 +215,7 @@ describe("router", () => { const spyOnProcess = jest.spyOn(router, "process"); const ev = { action: "com.elgato.test.one", - context: "key123", // Mocked in actionStore. + context: "key123", event: "sendToPlugin", payload: { __type: "request", @@ -261,29 +261,11 @@ describe("router", () => { describe("outbound messages", () => { describe("with ui", () => { - // Mock context. - const context: ActionContext = { - // @ts-expect-error Mocked device. - device: new Device(), - controller: "Keypad", - id: "ABC123", - manifestId: "com.elgato.test.one" - }; - - // Mock action. - const action = new KeyAction(context, { - controller: "Keypad", - coordinates: { - column: 5, - row: 3, - }, - isInMultiAction: false, - settings: {} - }) + let action: Action; beforeAll(() => { jest.useFakeTimers(); - jest.spyOn(actionStore, "getActionById").mockReturnValue(action); + action = actionStore.getActionById("key123")!; }); afterAll(() => jest.useRealTimers()); @@ -370,30 +352,11 @@ describe("router", () => { */ test("without ui", async () => { // Arrange. - const context: ActionContext = { - // @ts-expect-error Mocked device. - device: new Device(), - controller: "Keypad", - id: "proxy-outbound-message-without-ui", - manifestId: "com.elgato.test.one" - }; - - const action = new KeyAction(context, { - controller: "Keypad", - coordinates: { - column: 5, - row: 3 - }, - isInMultiAction: false, - settings: {} - }); - - jest.spyOn(actionStore, "getActionById").mockReturnValue(action); - + const action = actionStore.getActionById("without-ui")!; const ev = { action: action.manifestId, context: action.id, - device: undefined! + device: action.device.id }; connection.emit("propertyInspectorDidAppear", { From 597b89dbb82089e7eed32f9ac6b5490eb1485712 Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Sun, 22 Sep 2024 20:11:14 +0100 Subject: [PATCH 26/29] chore: fix linting --- src/plugin/actions/__tests__/key.test.ts | 2 +- src/plugin/actions/__tests__/service.test.ts | 47 ++++++++++---------- src/plugin/actions/context.ts | 1 - src/plugin/actions/dial.ts | 2 +- src/plugin/actions/key.ts | 2 +- src/plugin/actions/service.ts | 16 ++----- src/plugin/actions/singleton-action.ts | 1 + src/plugin/actions/store.ts | 16 +++---- src/plugin/devices/__mocks__/index.ts | 4 +- src/plugin/devices/__tests__/service.test.ts | 22 ++++----- 10 files changed, 52 insertions(+), 61 deletions(-) diff --git a/src/plugin/actions/__tests__/key.test.ts b/src/plugin/actions/__tests__/key.test.ts index e06286ed..f15517ed 100644 --- a/src/plugin/actions/__tests__/key.test.ts +++ b/src/plugin/actions/__tests__/key.test.ts @@ -65,7 +65,7 @@ describe("KeyAction", () => { }); /** - * Asserts the constructor of {@link DialAction} throws when the event is for a keypad. + * Asserts the constructor of {@link KeyAction} throws when the event is for a keypad. */ it("throws for non keypad", () => { // Arrange. diff --git a/src/plugin/actions/__tests__/service.test.ts b/src/plugin/actions/__tests__/service.test.ts index 7a69af9a..421fbd68 100644 --- a/src/plugin/actions/__tests__/service.test.ts +++ b/src/plugin/actions/__tests__/service.test.ts @@ -31,12 +31,11 @@ import { type WillAppearEvent, type WillDisappearEvent } from "../../events"; -import type { onDidReceiveSettings } from "../../settings"; import type { UIController } from "../../ui"; import { ActionContext } from "../context"; import { DialAction } from "../dial"; import { KeyAction } from "../key"; -import { actionService } from "../service"; +import { actionService, type ActionService } from "../service"; import { SingletonAction } from "../singleton-action"; import { actionStore } from "../store"; @@ -49,7 +48,7 @@ jest.mock("../../manifest"); describe("actions", () => { describe("event emitters", () => { /** - * Asserts {@link onDialDown} is invoked when `dialDown` is emitted. + * Asserts {@link ActionService.onDialDown} is invoked when `dialDown` is emitted. */ it("receives onDialDown", () => { // Arrange. @@ -93,7 +92,7 @@ describe("actions", () => { }); /** - * Asserts {@link onDialRotate} is invoked when `dialRotate` is emitted. + * Asserts {@link ActionService.onDialRotate} is invoked when `dialRotate` is emitted. */ it("receives onDialRotate", () => { // Arrange. @@ -139,7 +138,7 @@ describe("actions", () => { }); /** - * Asserts {@link onDialUp} is invoked when `dialUp` is emitted. + * Asserts {@link ActionService.onDialUp} is invoked when `dialUp` is emitted. */ it("receives onDialUp", () => { // Arrange. @@ -183,7 +182,7 @@ describe("actions", () => { }); /** - * Asserts {@link onKeyDown} is invoked when `keyDown` is emitted. + * Asserts {@link ActionService.onKeyDown} is invoked when `keyDown` is emitted. */ it("receives onKeyDown", () => { // Arrange. @@ -228,7 +227,7 @@ describe("actions", () => { }); /** - * Asserts {@link onKeyUp} is invoked when `keyUp` is emitted. + * Asserts {@link ActionService.onKeyUp} is invoked when `keyUp` is emitted. */ it("receives onKeyUp", () => { // Arrange. @@ -273,7 +272,7 @@ describe("actions", () => { }); /** - * Asserts {@link onTitleParametersDidChange} is invoked when `titleParametersDidChange` is emitted. + * Asserts {@link ActionService.onTitleParametersDidChange} is invoked when `titleParametersDidChange` is emitted. */ it("receives onTitleParametersDidChange", () => { // Arrange. @@ -327,7 +326,7 @@ describe("actions", () => { }); /** - * Asserts {@link onTouchTap} is invoked when `touchTap` is emitted. + * Asserts {@link ActionService.onTouchTap} is invoked when `touchTap` is emitted. */ it("receives onTouchTap", () => { // Arrange. @@ -373,7 +372,7 @@ describe("actions", () => { }); /** - * Asserts {@link onWillAppear} is invoked when `willAppear` is emitted. + * Asserts {@link ActionService.onWillAppear} is invoked when `willAppear` is emitted. */ it("receives onWillAppear", () => { // Arrange. @@ -418,7 +417,7 @@ describe("actions", () => { }); /** - * Asserts {@link onWillDisappear} is invoked when `willDisappear` is emitted. + * Asserts {@link ActionService.onWillDisappear} is invoked when `willDisappear` is emitted. */ it("receives onWillDisappear", () => { // Arrange. @@ -469,7 +468,7 @@ describe("actions", () => { const actions = jest.fn() as unknown as IterableIterator | KeyAction>; /** - * Asserts {@link registerAction} validates the manifest identifier is not undefined. + * Asserts {@link ActionService.registerAction} validates the manifest identifier is not undefined. */ it("validates the manifestId is not undefined", () => { // Arrange. @@ -483,7 +482,7 @@ describe("actions", () => { }); /** - * Asserts {@link registerAction} validates the manifest identifier exists within the manifest. + * Asserts {@link ActionService.registerAction} validates the manifest identifier exists within the manifest. */ it("validates when action does not exist in manifest", () => { // Arrange. @@ -497,7 +496,7 @@ describe("actions", () => { }); /** - * Asserts {@link registerAction} ignores undefined handlers. + * Asserts {@link ActionService.registerAction} ignores undefined handlers. */ it("ignore undefined handlers", () => { // Arrange. @@ -524,7 +523,7 @@ describe("actions", () => { }); /** - * Asserts {@link onDialDown} is routed to the action when `dialDown` is emitted. + * Asserts {@link ActionService.onDialDown} is routed to the action when `dialDown` is emitted. */ it("routes onDialDown", () => { // Arrange. @@ -567,7 +566,7 @@ describe("actions", () => { }); /** - * Asserts {@link onDialRotate} is routed to the action when `dialRotate` is emitted. + * Asserts {@link ActionService.onDialRotate} is routed to the action when `dialRotate` is emitted. */ it("routes onDialRotate", () => { // Arrange. @@ -611,7 +610,7 @@ describe("actions", () => { }); /** - * Asserts {@link onDialUp} is routed to the action when `dialUp` is emitted. + * Asserts {@link ActionService.onDialUp} is routed to the action when `dialUp` is emitted. */ it("routes onDialUp", () => { // Arrange. @@ -688,7 +687,7 @@ describe("actions", () => { }); /** - * Asserts {@link onDidReceiveSettings} is routed to the action when `didReceiveGlobalSettings` is emitted. + * Asserts {@link ActionService.onDidReceiveSettings} is routed to the action when `didReceiveGlobalSettings` is emitted. */ it("routes onDidReceiveGlobalSettings", () => { // Arrange @@ -731,7 +730,7 @@ describe("actions", () => { }); /** - * Asserts {@link onKeyDown} is routed to the action when `keyDown` is emitted. + * Asserts {@link ActionService.onKeyDown} is routed to the action when `keyDown` is emitted. */ it("routes onKeyDown", () => { // Arrange. @@ -774,7 +773,7 @@ describe("actions", () => { }); /** - * Asserts {@link onKeyUp} is routed to the action when `keyUp` is emitted. + * Asserts {@link ActionService.onKeyUp} is routed to the action when `keyUp` is emitted. */ it("routes onKeyUp", () => { // Arrange. @@ -879,7 +878,7 @@ describe("actions", () => { }); /** - * Asserts {@link onTitleParametersDidChange} is routed to the action when `titleParametersDidChange` is emitted. + * Asserts {@link ActionService.onTitleParametersDidChange} is routed to the action when `titleParametersDidChange` is emitted. */ it("routes onTitleParametersDidChange", () => { // Arrange. @@ -931,7 +930,7 @@ describe("actions", () => { }); /** - * Asserts {@link onTouchTap} is routed to the action when `touchTap` is emitted. + * Asserts {@link ActionService.onTouchTap} is routed to the action when `touchTap` is emitted. */ it("routes onTouchTap", () => { // Arrange. @@ -975,7 +974,7 @@ describe("actions", () => { }); /** - * Asserts {@link onWillAppear} is routed to the action when `willAppear` is emitted. + * Asserts {@link ActionService.onWillAppear} is routed to the action when `willAppear` is emitted. */ it("routes onWillAppear", () => { // Arrange. @@ -1018,7 +1017,7 @@ describe("actions", () => { }); /** - * Asserts {@link onWillDisappear} is routed to the action when `willDisappear` is emitted. + * Asserts {@link ActionService.onWillDisappear} is routed to the action when `willDisappear` is emitted. */ it("routes onWillDisappear", () => { // Arrange. diff --git a/src/plugin/actions/context.ts b/src/plugin/actions/context.ts index 48a34bc8..7bbe2bd9 100644 --- a/src/plugin/actions/context.ts +++ b/src/plugin/actions/context.ts @@ -36,7 +36,6 @@ export class ActionContext { * Type of the action. * - `Keypad` is a key. * - `Encoder` is a dial and portion of the touch strip. - * * @returns Controller type. */ public get controller(): Controller { diff --git a/src/plugin/actions/dial.ts b/src/plugin/actions/dial.ts index 0f8c1bea..e7c33764 100644 --- a/src/plugin/actions/dial.ts +++ b/src/plugin/actions/dial.ts @@ -10,7 +10,7 @@ import { Action } from "./action"; */ export class DialAction extends Action { /** - * Private backing field for {@link coordinates}. + * Private backing field for {@link DialAction.coordinates}. */ readonly #coordinates: Readonly; diff --git a/src/plugin/actions/key.ts b/src/plugin/actions/key.ts index 21874a15..7f732a76 100644 --- a/src/plugin/actions/key.ts +++ b/src/plugin/actions/key.ts @@ -10,7 +10,7 @@ import { Action } from "./action"; */ export class KeyAction extends Action { /** - * Private backing field for {@link coordinates}. + * Private backing field for {@link KeyAction.coordinates}. */ readonly #coordinates: Readonly | undefined; diff --git a/src/plugin/actions/service.ts b/src/plugin/actions/service.ts index a63e7257..aa919dc4 100644 --- a/src/plugin/actions/service.ts +++ b/src/plugin/actions/service.ts @@ -47,9 +47,7 @@ class ActionService extends ReadOnlyActionStore { } /** - * Occurs when the user presses a dial (Stream Deck +). See also {@link onDialUp}. - * - * NB: For other action types see {@link onKeyDown}. + * Occurs when the user presses a dial (Stream Deck +). * @template T The type of settings associated with the action. * @param listener Function to be invoked when the event occurs. * @returns A disposable that, when disposed, removes the listener. @@ -79,9 +77,7 @@ class ActionService extends ReadOnlyActionStore { } /** - * Occurs when the user releases a pressed dial (Stream Deck +). See also {@link onDialDown}. - * - * NB: For other action types see {@link onKeyUp}. + * Occurs when the user releases a pressed dial (Stream Deck +). * @template T The type of settings associated with the action. * @param listener Function to be invoked when the event occurs. * @returns A disposable that, when disposed, removes the listener. @@ -96,9 +92,7 @@ class ActionService extends ReadOnlyActionStore { } /** - * Occurs when the user presses a action down. See also {@link onKeyUp}. - * - * NB: For dials / touchscreens see {@link onDialDown}. + * Occurs when the user presses a action down. * @template T The type of settings associated with the action. * @param listener Function to be invoked when the event occurs. * @returns A disposable that, when disposed, removes the listener. @@ -113,9 +107,7 @@ class ActionService extends ReadOnlyActionStore { } /** - * Occurs when the user releases a pressed action. See also {@link onKeyDown}. - * - * NB: For dials / touchscreens see {@link onDialUp}. + * Occurs when the user releases a pressed action. * @template T The type of settings associated with the action. * @param listener Function to be invoked when the event occurs. * @returns A disposable that, when disposed, removes the listener. diff --git a/src/plugin/actions/singleton-action.ts b/src/plugin/actions/singleton-action.ts index 48005bb2..b5917468 100644 --- a/src/plugin/actions/singleton-action.ts +++ b/src/plugin/actions/singleton-action.ts @@ -1,3 +1,4 @@ +import type streamDeck from "../"; import type { JsonObject, JsonValue } from "../../common/json"; import type { DialAction } from "../actions/dial"; import type { KeyAction } from "../actions/key"; diff --git a/src/plugin/actions/store.ts b/src/plugin/actions/store.ts index 557d4f6e..2bd45488 100644 --- a/src/plugin/actions/store.ts +++ b/src/plugin/actions/store.ts @@ -30,19 +30,19 @@ export class ReadOnlyActionStore extends Enumerable { */ class ActionStore extends ReadOnlyActionStore { /** - * Adds the action to the store. - * @param action The action. + * Deletes the action from the store. + * @param id The action's identifier. */ - public set(action: DialAction | KeyAction): void { - __items.set(action.id, action); + public delete(id: string): void { + __items.delete(id); } /** - * Deletes the action from the store. - * @param action The action's identifier. + * Adds the action to the store. + * @param action The action. */ - public delete(id: string): void { - __items.delete(id); + public set(action: DialAction | KeyAction): void { + __items.set(action.id, action); } } diff --git a/src/plugin/devices/__mocks__/index.ts b/src/plugin/devices/__mocks__/index.ts index 28c58968..df033eba 100644 --- a/src/plugin/devices/__mocks__/index.ts +++ b/src/plugin/devices/__mocks__/index.ts @@ -2,10 +2,10 @@ import { DeviceType } from "../../../api/device"; import { type Device } from "../device"; export const deviceService = { - getDeviceById: jest.fn().mockImplementation((deviceId: string) => { + getDeviceById: jest.fn().mockImplementation((id: string) => { return { actions: Array.from([]).values(), - id: "DEV1", + id, name: "Device One", size: { columns: 5, diff --git a/src/plugin/devices/__tests__/service.test.ts b/src/plugin/devices/__tests__/service.test.ts index c791c096..0a474cb8 100644 --- a/src/plugin/devices/__tests__/service.test.ts +++ b/src/plugin/devices/__tests__/service.test.ts @@ -20,7 +20,7 @@ describe("devices", () => { }); /** - * Asserts {@link DeviceCollection} can iterate over each device, and apply the specified callback function using {@link DeviceCollection.forEach}. + * Asserts {@link DeviceService} can iterate over each device, and apply the specified callback function using {@link DeviceService.forEach}. */ it("applies callback with forEach", () => { // Arrange. @@ -52,7 +52,7 @@ describe("devices", () => { }); /** - * Asserts {@link DeviceCollection.count} returns the count of devices. + * Asserts {@link DeviceService.count} returns the count of devices. */ it("counts devices", () => { // Arrange. @@ -85,7 +85,7 @@ describe("devices", () => { }); /** - * Asserts {@link DeviceCollection} tracks devices supplied by Stream Deck as part of the registration parameters. + * Asserts {@link DeviceService} tracks devices supplied by Stream Deck as part of the registration parameters. */ it("adds devices from registration info", () => { // Arrange. @@ -103,7 +103,7 @@ describe("devices", () => { }); /** - * Asserts {@link DeviceCollection} adds devices when they connect. + * Asserts {@link DeviceService} adds devices when they connect. */ it("adds device on deviceDidConnect", () => { // Act. @@ -134,7 +134,7 @@ describe("devices", () => { describe("getDeviceById", () => { /** - * Asserts selecting a known device using {@link DeviceCollection.getDeviceById}. + * Asserts selecting a known device using {@link DeviceService.getDeviceById}. */ it("known identifier", () => { // Arrange. @@ -165,7 +165,7 @@ describe("devices", () => { }); /** - * Asserts selecting an unknown device using {@link DeviceCollection.getDeviceById}. + * Asserts selecting an unknown device using {@link DeviceService.getDeviceById}. */ it("unknown identifier", () => { // Arrange, act, assert. @@ -174,7 +174,7 @@ describe("devices", () => { }); /** - * Asserts {@link DeviceCollection} updates devices when they connect. + * Asserts {@link DeviceService} updates devices when they connect. */ it("updates device on deviceDidConnect", () => { // Arrange. @@ -201,7 +201,7 @@ describe("devices", () => { }); /** - * Asserts {@link DeviceCollection} updates devices when they disconnect. + * Asserts {@link DeviceService} updates devices when they disconnect. */ it("updates device on deviceDidDisconnect", () => { // Arrange. @@ -234,7 +234,7 @@ describe("devices", () => { }); /** - * Asserts {@link DeviceCollection} does not track unknown devices when they disconnect. + * Asserts {@link DeviceService} does not track unknown devices when they disconnect. */ it("ignores unknown devices on deviceDidDisconnect", () => { // Arrange. @@ -260,7 +260,7 @@ describe("devices", () => { }); /** - * Asserts {@link DeviceCollection.onDeviceDidConnect} is invoked when `deviceDidConnect` is emitted. + * Asserts {@link DeviceService.onDeviceDidConnect} is invoked when `deviceDidConnect` is emitted. */ it("receives onDeviceDidConnect", () => { // Arrange @@ -298,7 +298,7 @@ describe("devices", () => { }); /** - * Asserts {@link DeviceCollection.onDeviceDidDisconnect} is invoked when `deviceDidDisconnect` is emitted. + * Asserts {@link DeviceService.onDeviceDidDisconnect} is invoked when `deviceDidDisconnect` is emitted. */ it("receives onDeviceDidDisconnect", () => { // Arrange From a36d43db7bf9d1a191ff8064ae62ae3a27e07f2e Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Tue, 24 Sep 2024 15:42:39 +0100 Subject: [PATCH 27/29] refactor: remove deprecation notice for v1 --- src/plugin/actions/action.ts | 1 - src/plugin/ui/controller.ts | 2 -- src/plugin/ui/property-inspector.ts | 1 - src/ui/plugin.ts | 3 --- 4 files changed, 7 deletions(-) diff --git a/src/plugin/actions/action.ts b/src/plugin/actions/action.ts index 1365a858..730fd818 100644 --- a/src/plugin/actions/action.ts +++ b/src/plugin/actions/action.ts @@ -54,7 +54,6 @@ export class Action extends ActionContext { /** * Sends the {@link payload} to the property inspector. The plugin can also receive information from the property inspector via {@link streamDeck.ui.onSendToPlugin} and {@link SingletonAction.onSendToPlugin} * allowing for bi-directional communication. - * @deprecated Consider using {@link streamDeck.ui.current.fetch} to send requests to the property inspector. * @param payload Payload to send to the property inspector. * @returns `Promise` resolved when {@link payload} has been sent to the property inspector. */ diff --git a/src/plugin/ui/controller.ts b/src/plugin/ui/controller.ts index 451ba9b3..2085123e 100644 --- a/src/plugin/ui/controller.ts +++ b/src/plugin/ui/controller.ts @@ -1,4 +1,3 @@ -import type streamDeck from "../"; import type { DidReceivePropertyInspectorMessage } from "../../api"; import type { IDisposable } from "../../common/disposable"; import type { JsonObject, JsonValue } from "../../common/json"; @@ -56,7 +55,6 @@ class UIController { /** * Occurs when a message was sent to the plugin _from_ the property inspector. The plugin can also send messages _to_ the property inspector using {@link UIController.current.sendMessage} * or {@link Action.sendToPropertyInspector}. - * @deprecated Consider using {@link streamDeck.ui.registerRoute} to receive requests from the property inspector. * @template TPayload The type of the payload received from the property inspector. * @template TSettings The type of settings associated with the action. * @param listener Function to be invoked when the event occurs. diff --git a/src/plugin/ui/property-inspector.ts b/src/plugin/ui/property-inspector.ts index bac870b0..db911e62 100644 --- a/src/plugin/ui/property-inspector.ts +++ b/src/plugin/ui/property-inspector.ts @@ -84,7 +84,6 @@ export class PropertyInspector implements Pick, "fetch"> /** * Sends the {@link payload} to the property inspector. The plugin can also receive information from the property inspector via {@link streamDeck.ui.onSendToPlugin} and {@link SingletonAction.onSendToPlugin} * allowing for bi-directional communication. - * @deprecated Consider using {@link streamDeck.ui.current.fetch} to send requests to the property inspector. * @template T The type of the payload received from the property inspector. * @param payload Payload to send to the property inspector. * @returns `Promise` resolved when {@link payload} has been sent to the property inspector. diff --git a/src/ui/plugin.ts b/src/ui/plugin.ts index 31790c3a..f98d40fe 100644 --- a/src/ui/plugin.ts +++ b/src/ui/plugin.ts @@ -10,7 +10,6 @@ import { type UnscopedMessageHandler, type UnscopedMessageRequest } from "../common/messaging"; -import type streamDeck from "./"; import type { Action } from "./action"; import { connection } from "./connection"; import type { SendToPropertyInspectorEvent } from "./events"; @@ -92,7 +91,6 @@ class PluginController { /** * Occurs when a message was sent to the property inspector _from_ the plugin. The property inspector can also send messages _to_ the plugin using {@link PluginController.sendToPlugin}. - * @deprecated Consider using {@link streamDeck.plugin.registerRoute} to receive requests from the plugin. * @template TPayload The type of the payload received from the property inspector. * @template TSettings The type of settings associated with the action. * @param listener Function to be invoked when the event occurs. @@ -139,7 +137,6 @@ class PluginController { /** * Sends a payload to the plugin. - * @deprecated Consider using {@link streamDeck.plugin.fetch} to send requests to the plugin. * @param payload Payload to send. * @returns Promise completed when the message was sent. */ From 4d0f781edc0c6ada3f896645ebcd83d6fc11961c Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Tue, 24 Sep 2024 15:50:02 +0100 Subject: [PATCH 28/29] refactor: remove deprecation notice for v1 --- src/plugin/actions/singleton-action.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plugin/actions/singleton-action.ts b/src/plugin/actions/singleton-action.ts index b5917468..0c1cc76d 100644 --- a/src/plugin/actions/singleton-action.ts +++ b/src/plugin/actions/singleton-action.ts @@ -96,7 +96,6 @@ export class SingletonAction { /** * Occurs when a message was sent to the plugin _from_ the property inspector. The plugin can also send messages _to_ the property inspector using {@link Action.sendToPropertyInspector}. - * @deprecated Consider using {@link streamDeck.ui.registerRoute} to receive requests from the property inspector. * @param ev The event. */ public onSendToPlugin?(ev: SendToPluginEvent): Promise | void; From 75ec8c242740f78ef85c1acb824d308e745956de Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Tue, 24 Sep 2024 16:43:02 +0100 Subject: [PATCH 29/29] refactor!: remove deviceId from events, export types over classes --- src/common/events/action-event.ts | 6 ------ src/plugin/__tests__/index.test.ts | 1 - src/plugin/__tests__/settings.test.ts | 1 - src/plugin/actions/__tests__/service.test.ts | 21 -------------------- src/plugin/events/index.ts | 2 +- src/plugin/index.ts | 2 +- src/plugin/ui/__tests__/controller.test.ts | 2 -- src/plugin/ui/controller.ts | 3 ++- src/ui/__tests__/events.test.ts | 10 ---------- src/ui/__tests__/settings.test.ts | 1 - src/ui/index.ts | 2 +- src/ui/settings.ts | 1 - 12 files changed, 5 insertions(+), 47 deletions(-) delete mode 100644 src/ui/__tests__/events.test.ts diff --git a/src/common/events/action-event.ts b/src/common/events/action-event.ts index 5463552b..01f30063 100644 --- a/src/common/events/action-event.ts +++ b/src/common/events/action-event.ts @@ -5,11 +5,6 @@ import { Event } from "./event"; * Provides information for an event relating to an action. */ export class ActionWithoutPayloadEvent, TAction> extends Event { - /** - * Device identifier the action is associated with. - */ - public readonly deviceId: string; - /** * Initializes a new instance of the {@link ActionWithoutPayloadEvent} class. * @param action Action that raised the event. @@ -20,7 +15,6 @@ export class ActionWithoutPayloadEvent { const index = (await require("../index")) as typeof import("../index"); // Act, assert. - expect(index.ApplicationEvent).not.toBeUndefined(); expect(index.BarSubType).toBe(BarSubType); expect(index.DeviceType).toBe(DeviceType); expect(index.EventEmitter).toBe(EventEmitter); diff --git a/src/plugin/__tests__/settings.test.ts b/src/plugin/__tests__/settings.test.ts index 9d402bda..ca1c972a 100644 --- a/src/plugin/__tests__/settings.test.ts +++ b/src/plugin/__tests__/settings.test.ts @@ -136,7 +136,6 @@ describe("settings", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[DidReceiveSettingsEvent]>({ action: actionStore.getActionById(ev.context)!, - deviceId: ev.device, payload: ev.payload, type: "didReceiveSettings" }); diff --git a/src/plugin/actions/__tests__/service.test.ts b/src/plugin/actions/__tests__/service.test.ts index 421fbd68..fa110d6e 100644 --- a/src/plugin/actions/__tests__/service.test.ts +++ b/src/plugin/actions/__tests__/service.test.ts @@ -78,7 +78,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[DialDownEvent]>({ action: actionStore.getActionById(ev.context) as DialAction, - deviceId: ev.device, payload: ev.payload, type: "dialDown" }); @@ -124,7 +123,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[DialRotateEvent]>({ action: actionStore.getActionById(ev.context) as DialAction, - deviceId: ev.device, payload: ev.payload, type: "dialRotate" }); @@ -168,7 +166,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[DialUpEvent]>({ action: actionStore.getActionById(ev.context) as DialAction, - deviceId: ev.device, payload: ev.payload, type: "dialUp" }); @@ -213,7 +210,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[KeyDownEvent]>({ action: actionStore.getActionById(ev.context) as KeyAction, - deviceId: ev.device, payload: ev.payload, type: "keyDown" }); @@ -258,7 +254,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[KeyUpEvent]>({ action: actionStore.getActionById(ev.context) as KeyAction, - deviceId: ev.device, payload: ev.payload, type: "keyUp" }); @@ -312,7 +307,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[TitleParametersDidChangeEvent]>({ action: actionStore.getActionById(ev.context) as KeyAction, - deviceId: ev.device, payload: ev.payload, type: "titleParametersDidChange" }); @@ -358,7 +352,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[TouchTapEvent]>({ action: actionStore.getActionById(ev.context) as DialAction, - deviceId: ev.device, payload: ev.payload, type: "touchTap" }); @@ -403,7 +396,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[WillAppearEvent]>({ action: actionStore.getActionById(ev.context) as KeyAction, - deviceId: ev.device, payload: ev.payload, type: "willAppear" }); @@ -448,7 +440,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[WillDisappearEvent]>({ action: new ActionContext(ev), - deviceId: ev.device, payload: ev.payload, type: "willDisappear" }); @@ -559,7 +550,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[DialDownEvent]>({ action: actionStore.getActionById(ev.context) as DialAction, - deviceId: ev.device, payload: ev.payload, type: "dialDown" }); @@ -603,7 +593,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[DialRotateEvent]>({ action: actionStore.getActionById(ev.context) as DialAction, - deviceId: ev.device, payload: ev.payload, type: "dialRotate" }); @@ -645,7 +634,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[DialUpEvent]>({ action: actionStore.getActionById(ev.context) as DialAction, - deviceId: ev.device, payload: ev.payload, type: "dialUp" }); @@ -723,7 +711,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[DidReceiveSettingsEvent]>({ action: actionStore.getActionById(ev.context) as KeyAction, - deviceId: ev.device, payload: ev.payload, type: "didReceiveSettings" }); @@ -766,7 +753,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[KeyDownEvent]>({ action: actionStore.getActionById(ev.context) as KeyAction, - deviceId: ev.device, payload: ev.payload, type: "keyDown" }); @@ -809,7 +795,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[KeyUpEvent]>({ action: actionStore.getActionById(ev.context) as KeyAction, - deviceId: ev.device, payload: ev.payload, type: "keyUp" }); @@ -841,7 +826,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[PropertyInspectorDidAppearEvent]>({ action: actionStore.getActionById(ev.context) as KeyAction, - deviceId: ev.device, type: "propertyInspectorDidAppear" }); }); @@ -872,7 +856,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[PropertyInspectorDidDisappearEvent]>({ action: actionStore.getActionById(ev.context) as KeyAction, - deviceId: ev.device, type: "propertyInspectorDidDisappear" }); }); @@ -923,7 +906,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[TitleParametersDidChangeEvent]>({ action: actionStore.getActionById(ev.context) as KeyAction, - deviceId: ev.device, payload: ev.payload, type: "titleParametersDidChange" }); @@ -967,7 +949,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[TouchTapEvent]>({ action: actionStore.getActionById(ev.context) as DialAction, - deviceId: ev.device, payload: ev.payload, type: "touchTap" }); @@ -1010,7 +991,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[WillAppearEvent]>({ action: actionStore.getActionById(ev.context) as KeyAction, - deviceId: ev.device, payload: ev.payload, type: "willAppear" }); @@ -1053,7 +1033,6 @@ describe("actions", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[WillDisappearEvent]>({ action: new ActionContext(ev), - deviceId: ev.device, payload: ev.payload, type: "willDisappear" }); diff --git a/src/plugin/events/index.ts b/src/plugin/events/index.ts index b3b067a8..c23853d8 100644 --- a/src/plugin/events/index.ts +++ b/src/plugin/events/index.ts @@ -29,7 +29,7 @@ import { DeviceEvent } from "./device-event"; export { DidReceiveGlobalSettingsEvent } from "../../common/events"; export { DidReceiveDeepLinkEvent } from "./deep-link-event"; export { SendToPluginEvent } from "./ui-message-event"; -export { ActionEvent, ActionWithoutPayloadEvent, ApplicationEvent, DeviceEvent, Event }; +export { ApplicationEvent, DeviceEvent, Event }; /** * Event information received from Stream Deck when a monitored application launches. diff --git a/src/plugin/index.ts b/src/plugin/index.ts index e62b11ae..ea8f0e09 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -38,7 +38,7 @@ export { LogLevel } from "../common/logging"; export { type MessageRequestOptions, type MessageResponder, type MessageResponse, type RouteConfiguration, type StatusCode } from "../common/messaging"; export * from "./actions"; export * from "./devices"; -export * from "./events"; +export type * from "./events"; export { route, type MessageRequest, type PropertyInspector } from "./ui"; export { type Logger }; diff --git a/src/plugin/ui/__tests__/controller.test.ts b/src/plugin/ui/__tests__/controller.test.ts index ab5847b2..b8d47df1 100644 --- a/src/plugin/ui/__tests__/controller.test.ts +++ b/src/plugin/ui/__tests__/controller.test.ts @@ -56,7 +56,6 @@ describe("UIController", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[PropertyInspectorDidAppearEvent]>({ action: actionStore.getActionById(ev.context)!, - deviceId: ev.device, type: "propertyInspectorDidAppear" }); @@ -89,7 +88,6 @@ describe("UIController", () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledWith<[PropertyInspectorDidDisappearEvent]>({ action: actionStore.getActionById(ev.context)!, - deviceId: ev.device, type: "propertyInspectorDidDisappear" }); diff --git a/src/plugin/ui/controller.ts b/src/plugin/ui/controller.ts index 2085123e..0b1b6f36 100644 --- a/src/plugin/ui/controller.ts +++ b/src/plugin/ui/controller.ts @@ -1,11 +1,12 @@ import type { DidReceivePropertyInspectorMessage } from "../../api"; import type { IDisposable } from "../../common/disposable"; +import { ActionWithoutPayloadEvent } from "../../common/events/action-event"; import type { JsonObject, JsonValue } from "../../common/json"; import { PUBLIC_PATH_PREFIX, type RouteConfiguration } from "../../common/messaging"; import { Action } from "../actions/action"; import { actionStore } from "../actions/store"; import { connection } from "../connection"; -import { ActionWithoutPayloadEvent, SendToPluginEvent, type PropertyInspectorDidAppearEvent, type PropertyInspectorDidDisappearEvent } from "../events"; +import { SendToPluginEvent, type PropertyInspectorDidAppearEvent, type PropertyInspectorDidDisappearEvent } from "../events"; import { type MessageHandler } from "./message"; import { type PropertyInspector } from "./property-inspector"; import { getCurrentUI, router } from "./router"; diff --git a/src/ui/__tests__/events.test.ts b/src/ui/__tests__/events.test.ts deleted file mode 100644 index f969c56f..00000000 --- a/src/ui/__tests__/events.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { DidReceiveGlobalSettingsEvent } from "../events"; - -describe("events", () => { - /** - * Asserts {@link DidReceiveGlobalSettingsEvent} is exported. - */ - it("exports DidReceiveGlobalSettingsEvent", async () => { - expect(DidReceiveGlobalSettingsEvent).toBe((await require("../../common/events")).DidReceiveGlobalSettingsEvent); - }); -}); diff --git a/src/ui/__tests__/settings.test.ts b/src/ui/__tests__/settings.test.ts index 12615c20..fec728df 100644 --- a/src/ui/__tests__/settings.test.ts +++ b/src/ui/__tests__/settings.test.ts @@ -180,7 +180,6 @@ describe("settings", () => { getSettings, setSettings }, - deviceId: ev.device, payload: ev.payload, type: "didReceiveSettings" }); diff --git a/src/ui/index.ts b/src/ui/index.ts index d76073af..774c0ca2 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -14,7 +14,7 @@ export { EventEmitter } from "../common/event-emitter"; export { type JsonObject, type JsonPrimitive, type JsonValue } from "../common/json"; export { LogLevel, type Logger } from "../common/logging"; export { type MessageRequestOptions, type MessageResponder, type MessageResponse, type RouteConfiguration, type StatusCode } from "../common/messaging"; -export * from "./events"; +export type * from "./events"; export { type MessageHandler, type MessageRequest } from "./plugin"; const streamDeck = { diff --git a/src/ui/settings.ts b/src/ui/settings.ts index 24b53aed..37cc7057 100644 --- a/src/ui/settings.ts +++ b/src/ui/settings.ts @@ -72,7 +72,6 @@ export function onDidReceiveSettings(listener getSettings, setSettings }, - deviceId: ev.device, payload: ev.payload, type: ev.event })