Skip to content

Commit

Permalink
feat: action tracking (#49)
Browse files Browse the repository at this point in the history
* feat: add Enumerable class

* refactor: split Action into KeyAction, DialAction, and KeyInMultiAction

* refactor: devices to use store, add initial tracking of actions

* refactor: update Enumerable to be inheritable

* feat: allow Enumerable to be constructed from another Enumerable

* feat: update action to include device and coordinates

* refactor: update devices to inherit Enumerable

* style: fix linting

* feat: track visible actions on devices

* feat: update events to use Action instance

* fix: action type

* feat: simplify action store

* feat: add type-checking helpers

* test: fix tests

* test: fix tests

* test: fix tests

* test: fix tests

* style: linting

* refactor: update actions to be a service, allowing for it to be iterated over

* feat: add visible actions to SingletonAction

* refactor: merge MultiActionKey in KeyAction

* test: mock ActionStore (WIP)

* refactor: action and device store

* refactor: improve exports

* refactor: decouple stores

* chore: fix linting

* refactor: remove deprecation notice for v1

* refactor: remove deprecation notice for v1

* refactor!: remove deviceId from events, export types over classes

---------

Co-authored-by: Richard Herman <[email protected]>
  • Loading branch information
GeekyEggo and GeekyEggo authored Sep 25, 2024
1 parent 9e37a33 commit 6233d1b
Show file tree
Hide file tree
Showing 41 changed files with 2,514 additions and 1,156 deletions.
486 changes: 486 additions & 0 deletions src/common/__tests__/enumerable.test.ts

Large diffs are not rendered by default.

192 changes: 192 additions & 0 deletions src/common/enumerable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* Provides a read-only iterable collection of items.
*/
export class Enumerable<T> {
/**
* Backing function responsible for providing the iterator of items.
*/
readonly #items: () => Iterable<T>;

/**
* Backing function for {@link Enumerable.length}.
*/
readonly #length: () => number;

/**
* Initializes a new instance of the {@link Enumerable} class.
* @param source Source that contains the items.
* @returns The enumerable.
*/
constructor(source: Enumerable<T> | Map<unknown, T> | Set<T> | T[]) {
if (source instanceof Enumerable) {
this.#items = source.#items;
this.#length = source.#length;
} else if (Array.isArray(source)) {
this.#items = (): Iterable<T> => source;
this.#length = (): number => source.length;
} else {
this.#items = (): IterableIterator<T> => source.values();
this.#length = (): number => source.size;
}
}

/**
* Gets the number of items in the enumerable.
* @returns The number of items.
*/
public get length(): number {
return this.#length();
}

/**
* Gets the iterator for the enumerable.
* @yields The items.
*/
public *[Symbol.iterator](): IterableIterator<T> {
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.
* @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<T> {
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<U>(mapper: (value: T) => U): Iterable<U> {
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<R>(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<R>(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;
}
}
6 changes: 0 additions & 6 deletions src/common/events/action-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,6 @@ import { Event } from "./event";
* Provides information for an event relating to an action.
*/
export class ActionWithoutPayloadEvent<TSource extends Extract<PluginEvent, ActionIdentifier & DeviceIdentifier>, TAction> extends Event<TSource> {
/**
* 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.
Expand All @@ -20,7 +15,6 @@ export class ActionWithoutPayloadEvent<TSource extends Extract<PluginEvent, Acti
source: TSource
) {
super(source);
this.deviceId = source.device;
}
}

Expand Down
12 changes: 11 additions & 1 deletion src/plugin/__mocks__/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
11 changes: 4 additions & 7 deletions src/plugin/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -28,17 +27,17 @@ describe("index", () => {
*/
it("exports namespaces", async () => {
// Arrange.
const actions = 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");
const system = await require("../system");
const { ui } = await require("../ui");

// Act, assert.
expect(streamDeck.actions).toBe(actions);
expect(streamDeck.devices).toBe(devices);
expect(streamDeck.actions).toBe(actionService);
expect(streamDeck.devices).toBe(deviceService);
expect(streamDeck.manifest).toBe(getManifest());
expect(streamDeck.profiles).toBe(profiles);
expect(streamDeck.settings).toBe(settings);
Expand Down Expand Up @@ -73,8 +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);
expect(index.EventEmitter).toBe(EventEmitter);
Expand Down
11 changes: 6 additions & 5 deletions src/plugin/__tests__/settings.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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";

jest.mock("../connection");
jest.mock("../logging");
jest.mock("../manifest");
jest.mock("../actions/store");

describe("settings", () => {
describe("sending", () => {
Expand Down Expand Up @@ -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: {
Expand All @@ -133,8 +135,7 @@ describe("settings", () => {
// Assert (emit).
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith<[DidReceiveSettingsEvent<Settings>]>({
action: new Action(ev),
deviceId: ev.device,
action: actionStore.getActionById(ev.context)!,
payload: ev.payload,
type: "didReceiveSettings"
});
Expand Down
62 changes: 62 additions & 0 deletions src/plugin/actions/__mocks__/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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
},
type: DeviceType.StreamDeck
} as unknown as Device);

export const actionStore = {
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 new KeyAction({
action: "com.elgato.test.key",
context: id,
device: "device123",
event: "willAppear",
payload: {
controller: "Keypad",
coordinates: {
column: 1,
row: 2
},
isInMultiAction: false,
settings: {}
}
});
})
};

export { ReadOnlyActionStore };
Loading

0 comments on commit 6233d1b

Please sign in to comment.