Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: action tracking #49

Merged
merged 30 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4c35a7b
feat: add Enumerable class
GeekyEggo Apr 30, 2024
07d2f3e
Merge branch 'main' into action-tracking
GeekyEggo Sep 14, 2024
9dd8a7c
refactor: split Action into KeyAction, DialAction, and KeyInMultiAction
GeekyEggo Sep 14, 2024
683a163
refactor: devices to use store, add initial tracking of actions
GeekyEggo Sep 14, 2024
c062b08
refactor: update Enumerable to be inheritable
GeekyEggo Sep 14, 2024
5041dc3
feat: allow Enumerable to be constructed from another Enumerable
GeekyEggo Sep 14, 2024
23fe625
feat: update action to include device and coordinates
GeekyEggo Sep 14, 2024
62756b8
refactor: update devices to inherit Enumerable
GeekyEggo Sep 14, 2024
7609ea9
style: fix linting
GeekyEggo Sep 14, 2024
b5951f8
feat: track visible actions on devices
GeekyEggo Sep 14, 2024
180bfa0
feat: update events to use Action instance
GeekyEggo Sep 15, 2024
9d4d306
fix: action type
GeekyEggo Sep 15, 2024
e93680a
feat: simplify action store
GeekyEggo Sep 15, 2024
6185074
feat: add type-checking helpers
GeekyEggo Sep 15, 2024
597cd54
test: fix tests
GeekyEggo Sep 15, 2024
20e9210
test: fix tests
GeekyEggo Sep 15, 2024
f5008b4
test: fix tests
GeekyEggo Sep 15, 2024
bf4cd7e
test: fix tests
GeekyEggo Sep 15, 2024
72406b2
style: linting
GeekyEggo Sep 15, 2024
a32e8ba
refactor: update actions to be a service, allowing for it to be itera…
GeekyEggo Sep 15, 2024
995ca5c
feat: add visible actions to SingletonAction
GeekyEggo Sep 18, 2024
0a9fff3
refactor: merge MultiActionKey in KeyAction
GeekyEggo Sep 18, 2024
8a94788
test: mock ActionStore (WIP)
GeekyEggo Sep 22, 2024
d43ed59
refactor: action and device store
GeekyEggo Sep 22, 2024
e3f3ec2
refactor: improve exports
GeekyEggo Sep 22, 2024
625a396
refactor: decouple stores
GeekyEggo Sep 22, 2024
597b89d
chore: fix linting
GeekyEggo Sep 22, 2024
a36d43d
refactor: remove deprecation notice for v1
GeekyEggo Sep 24, 2024
4d0f781
refactor: remove deprecation notice for v1
GeekyEggo Sep 24, 2024
75ec8c2
refactor!: remove deviceId from events, export types over classes
GeekyEggo Sep 24, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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