From b97b6f603309d9c33ded9d4eac0f4373e3bb9a35 Mon Sep 17 00:00:00 2001 From: Greaple Date: Thu, 13 Aug 2026 15:07:26 +0200 Subject: [PATCH 1/7] feat: add Ember+ function invocation action --- src/actions.ts | 35 +++++++- src/actions/function.ts | 59 ++++++++++++++ src/feedback.ts | 2 +- src/index.ts | 23 +++++- src/state.ts | 35 +++++++- src/util.ts | 174 ++++++++++++++++++++++++++++++++++++---- 6 files changed, 305 insertions(+), 23 deletions(-) create mode 100644 src/actions/function.ts diff --git a/src/actions.ts b/src/actions.ts index bd6f141..6cd3df2 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -13,8 +13,9 @@ import type { EmberPlusConfig } from './config.js' import type { EmberPlusInstance } from './index.js' import { doMatrixAction, doTake, doClear, setSelectedSource, setSelectedTarget } from './actions/matrix.js' import { learnSetValueActionOptions, setValue, subscribeParameterAction } from './actions/parameter.js' +import { invokeFunctionAction } from './actions/function.js' import { EmberPlusState } from './state.js' -import { filterPathChoices } from './util.js' +import { filterPathChoices, filterFunctionPathChoices } from './util.js' export interface setValueActionOptions extends CompanionOptionValues { path: string @@ -47,6 +48,7 @@ export enum ActionId { Clear = 'clear', SetSelectedSource = 'setSelectedSource', SetSelectedTarget = 'setSelectedTarget', + InvokeFunction = 'invokeFunction', } const pathDropDown = { @@ -560,6 +562,37 @@ export function GetActionsList( ], callback: setSelectedTarget(self, state), }, + [ActionId.InvokeFunction]: { + name: 'Invoke Function', + options: [ + { + ...pathDropDown, + choices: filterFunctionPathChoices(state), + default: filterFunctionPathChoices(state).find(() => true)?.id ?? 'No functions configured!', + }, + pathString, + usePathVar, + { + type: 'textinput', + label: 'Arguments', + id: 'args', + useVariables: { local: true }, + multiline: true, + default: '', + tooltip: + 'Enter arguments comma-separated, line-separated, or as a JSON array (e.g. [123, "text", true]). Dynamic variables are supported.', + }, + { + type: 'checkbox', + label: 'Parse escape characters', + id: 'parseEscapeChars', + default: true, + tooltip: 'Parse escape characters such as \\r \\n \\t in text arguments', + }, + ], + callback: invokeFunctionAction(self, emberClient, state, queue), + subscribe: subscribeParameterAction(self), + }, } return actions diff --git a/src/actions/function.ts b/src/actions/function.ts new file mode 100644 index 0000000..141a917 --- /dev/null +++ b/src/actions/function.ts @@ -0,0 +1,59 @@ +import type { CompanionActionEvent, CompanionActionContext } from '@companion-module/base' +import { EmberClient, Model as EmberModel } from 'emberplus-connection' +import { ElementType } from 'emberplus-connection/dist/model/index.js' +import type PQueue from 'p-queue' +import type { EmberPlusInstance } from '../index.js' +import { EmberPlusState } from '../state.js' +import { parseEscapeCharacters, parseFunctionArguments, resolveEventPath } from '../util.js' + +export const invokeFunctionAction = + (self: EmberPlusInstance, emberClient: EmberClient, state: EmberPlusState, queue: PQueue) => + async (action: CompanionActionEvent, context: CompanionActionContext): Promise => { + const path = resolveEventPath(action) + if (!path) { + self.logger.warn('Invoke Function: Path is empty') + return + } + + await queue.add(async () => { + try { + let node = state.emberElement.get(path) + if (!node) { + node = await emberClient.getElementByPath(path) + if (node) { + state.emberElement.set(path, node) + if (node.contents.type === ElementType.Function) { + state.updateFunctionMap(path, node) + } + } + } + + if (!node || node.contents.type !== ElementType.Function) { + self.logger.error(`Invoke Function: Node at path "${path}" is not a valid Ember+ Function`) + return + } + + const rawArgsInput = action.options['args']?.toString() ?? '' + const parsedArgsString = await context.parseVariablesInString(rawArgsInput) + const finalArgsString = action.options['parseEscapeChars'] + ? parseEscapeCharacters(parsedArgsString) + : parsedArgsString + + const emberFunc = node.contents as EmberModel.EmberFunction + const typedArgs = parseFunctionArguments(finalArgsString, emberFunc.args) + + self.logger.debug(`Invoking Ember+ Function at "${path}" with arguments:`, typedArgs) + + const request = await emberClient.invoke(node as any, ...typedArgs) + const result = await request.response + + if (result?.success) { + self.logger.info(`Function "${path}" invoked successfully`, result.result ?? '') + } else { + self.logger.warn(`Function "${path}" invocation failed or returned false`, result ?? '') + } + } catch (e) { + self.logger.error(`Failed to invoke function at "${path}":`, e instanceof Error ? e.message : String(e)) + } + }) + } diff --git a/src/feedback.ts b/src/feedback.ts index 7285bc9..079c563 100644 --- a/src/feedback.ts +++ b/src/feedback.ts @@ -109,7 +109,7 @@ const comparitorDropdown = { label: 'Comparitor', id: 'comparitor', choices: comparitorOptions, - default: comparitorOptions[0].id, + default: comparitorOptions?.[0]?.id ?? 'eq', allowCustom: false, } as const satisfies CompanionInputFieldDropdown diff --git a/src/index.ts b/src/index.ts index 8e412e1..b41a366 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { hasConnectionChanged, recordParameterAction, parseParameterValue, + discoverFunctionsFromTree, } from './util.js' import { GetVariablesList } from './variables.js' import PQueue from 'p-queue' @@ -252,6 +253,7 @@ export class EmberPlusInstance extends InstanceBase { try { const request = await this.emberClient.getDirectory(this.emberClient.tree) await request.response + discoverFunctionsFromTree(this.emberClient.tree, this.state) this.statusManager.updateStatus(InstanceStatus.Ok) this.finalizeSetup().catch((e) => { this.logger.error('Error during finalize setup', e) @@ -345,8 +347,12 @@ export class EmberPlusInstance extends InstanceBase { }) if (initial_node) { this.logger.debug('Registered for path', path) - this.state.updateParameterMap(path, initial_node) - await this.handleChangedValue(path, initial_node) + if (initial_node.contents.type === ElementType.Function) { + this.state.updateFunctionMap(path, initial_node) + } else if (initial_node.contents.type === ElementType.Parameter) { + this.state.updateParameterMap(path, initial_node) + await this.handleChangedValue(path, initial_node) + } } } catch { this.logger.error('Failed to subscribe to path', path) @@ -377,7 +383,18 @@ export class EmberPlusInstance extends InstanceBase { this.handleChangedValue(path, updatedNode).catch((e) => this.logger.error('Error handling parameter', e)) }) - if (!node || node.contents.type !== ElementType.Parameter) { + if (!node) { + return node + } + + if (node.contents.type === ElementType.Function) { + this.logger.debug('Registered function for path', path) + this.state.updateFunctionMap(path, node) + this.debouncedUpdateActionFeedbackDefs() + return node + } + + if (node.contents.type !== ElementType.Parameter) { return node } diff --git a/src/state.ts b/src/state.ts index 3333389..0ddc091 100644 --- a/src/state.ts +++ b/src/state.ts @@ -15,6 +15,7 @@ interface Feedbacks { export class EmberPlusState { public selected: CurrentSelected public parameters: Map = new Map() + public functions: Map = new Map() public emberElement: Map> = new Map() public monitoredParameters: Set = new Set() public matrices: string[] = [] @@ -114,6 +115,19 @@ export class EmberPlusState { this.emberElement.set(path, node) } + /** + * Add or merge function node data to functions Map + * @param path Ember Path + * @param node Ember element + */ + public updateFunctionMap(path: string, node: TreeElement): void { + if (node.contents.type !== ElementType.Function) return + + const existing = this.functions.get(path) + this.functions.set(path, existing ? { ...existing, ...node.contents } : (node.contents as EmberModel.EmberFunction)) + this.emberElement.set(path, node) + } + /** * Returns the current enumeration string of the parameter * @param path Ember Path @@ -158,21 +172,36 @@ export class EmberPlusState { */ public hasParameter(path: string): boolean { return this.parameters.has(path) - } - /** * Clear cached ember elements */ - public clearCache(): void { this.emberElement.clear() } + /** + * Get function by path + * @param path Ember Path + * @returns EmberFunction or undefined + */ + public getFunction(path: string): EmberModel.EmberFunction | undefined { + return this.functions.get(path) + } + + /** + * Check if function exists + * @param path Ember Path + */ + public hasFunction(path: string): boolean { + return this.functions.has(path) + } + /** * Clear all state */ public clear(): void { this.parameters.clear() + this.functions.clear() this.emberElement.clear() this.feedbacks.byId.clear() this.feedbacks.byPath.clear() diff --git a/src/util.ts b/src/util.ts index cb04657..a2adcf0 100644 --- a/src/util.ts +++ b/src/util.ts @@ -4,21 +4,6 @@ import type { CompanionFeedbackInfo, DropdownChoice, } from '@companion-module/base' -import { ActionId, type setValueActionOptions } from './actions.js' -import type { EmberPlusConfig } from './config.js' -import type { EmberPlusInstance } from './index.js' -import { EmberPlusState } from './state.js' -import { Model as EmberModel } from 'emberplus-connection' - -export function assertUnreachable(_never: never): void { - // throw new Error('Unreachable') -} - -export function literal(val: T): T { - return val -} - -export type Required = T extends object ? { [P in keyof T]-?: NonNullable } : T export enum NumberComparitor { Equal = 'eq', @@ -38,6 +23,25 @@ export const comparitorOptions: DropdownChoice[] = [ { id: NumberComparitor.GreaterThanEqual, label: '>=' }, ] +import { ActionId, type setValueActionOptions } from './actions.js' +import type { EmberPlusConfig } from './config.js' +import type { EmberPlusInstance } from './index.js' +import { EmberPlusState } from './state.js' +import { Model as EmberModel } from 'emberplus-connection' +import { ElementType } from 'emberplus-connection/dist/model/index.js' +import type { EmberTypedValue } from 'emberplus-connection/dist/types/index.js' +import type { FunctionArgument } from 'emberplus-connection/dist/model/FunctionArgument.js' + +export function assertUnreachable(_never: never): void { + // throw new Error('Unreachable') +} + +export function literal(val: T): T { + return val +} + +export type Required = T extends object ? { [P in keyof T]-?: NonNullable } : T + export function compareNumber(target: number, comparitor: NumberComparitor, currentValue: number): boolean { const targetValue = Number(target) if (isNaN(targetValue)) { @@ -331,3 +335,143 @@ export function parseParameterValue( return { actionType, value } } + +/** + * Return array of dropdown choices of registered Ember+ functions + */ +export function filterFunctionPathChoices(state: EmberPlusState): DropdownChoice[] { + const choices: DropdownChoice[] = [] + for (const [path, func] of state.functions) { + let label = `${path}` + if (func.identifier) { + label += `: ${func.identifier}` + } + if (func.description) { + label += ` (${func.description})` + } + choices.push({ id: path, label }) + } + return choices +} + +/** + * Parse raw input arguments string into EmberTypedValue array for Ember+ function invocation. + * Handles JSON array input, comma/line separated lists, and schema-based type casting. + */ +export function parseFunctionArguments( + rawArgsString: string, + expectedArgs?: FunctionArgument[], +): EmberTypedValue[] { + const trimmed = rawArgsString.trim() + if (!trimmed) return [] + + // Try JSON parsing if argument input looks like a JSON array + if (trimmed.startsWith('[')) { + try { + const jsonParsed = JSON.parse(trimmed) + if (Array.isArray(jsonParsed)) { + return jsonParsed.map((item, idx) => { + // Check if item is already an EmberTypedValue object ({ type, value }) + if (typeof item === 'object' && item !== null && 'type' in item && 'value' in item) { + return item as EmberTypedValue + } + + const expected = expectedArgs?.[idx] + if (expected) { + return castToEmberTypedValue(item, expected.type) + } + + if (typeof item === 'boolean') { + return { type: EmberModel.ParameterType.Boolean, value: item } + } + if (typeof item === 'number') { + return { + type: Number.isInteger(item) ? EmberModel.ParameterType.Integer : EmberModel.ParameterType.Real, + value: item, + } + } + return { type: EmberModel.ParameterType.String, value: String(item) } + }) + } + } catch { + // Fallback to comma/line separation if JSON parsing fails + } + } + + // Split by newline or comma + const tokens = trimmed.split(/[\n,]+/).map((t) => t.trim()).filter((t) => t.length > 0) + + return tokens.map((token, idx) => { + const expected = expectedArgs?.[idx] + if (expected) { + return castStringToType(token, expected.type) + } + + // Infer type if schema argument is not available + if (token.toLowerCase() === 'true') return { type: EmberModel.ParameterType.Boolean, value: true } + if (token.toLowerCase() === 'false') return { type: EmberModel.ParameterType.Boolean, value: false } + if (/^-?\d+$/.test(token)) return { type: EmberModel.ParameterType.Integer, value: Number.parseInt(token, 10) } + if (/^-?\d+\.\d+$/.test(token)) return { type: EmberModel.ParameterType.Real, value: Number.parseFloat(token) } + + return { type: EmberModel.ParameterType.String, value: token } + }) +} + +function castToEmberTypedValue(value: any, targetType: EmberModel.ParameterType): EmberTypedValue { + switch (targetType) { + case EmberModel.ParameterType.Boolean: + return { type: targetType, value: Boolean(value) } + case EmberModel.ParameterType.Integer: + case EmberModel.ParameterType.Enum: + return { type: targetType, value: Math.round(Number(value)) } + case EmberModel.ParameterType.Real: + return { type: targetType, value: Number(value) } + case EmberModel.ParameterType.String: + default: + return { type: targetType, value: String(value) } + } +} + +function castStringToType(token: string, targetType: EmberModel.ParameterType): EmberTypedValue { + switch (targetType) { + case EmberModel.ParameterType.Boolean: + return { + type: targetType, + value: token.toLowerCase() === 'true' || token === '1', + } + case EmberModel.ParameterType.Integer: + case EmberModel.ParameterType.Enum: { + const parsed = Number.parseInt(token, 10) + return { type: targetType, value: Number.isNaN(parsed) ? 0 : parsed } + } + case EmberModel.ParameterType.Real: { + const parsed = Number.parseFloat(token) + return { type: targetType, value: Number.isNaN(parsed) ? 0 : parsed } + } + case EmberModel.ParameterType.String: + default: + return { type: targetType, value: token } + } +} + +/** + * Recursively discover Function nodes from an Ember+ tree collection. + */ +export function discoverFunctionsFromTree(nodes: any, state: EmberPlusState, parentPath = ''): void { + if (!nodes) return + const elements = Array.isArray(nodes) ? nodes : Object.values(nodes) + + for (const node of elements) { + if (!node || typeof node !== 'object') continue + const num = node.number ?? node.path + const currentPath = parentPath && num !== undefined ? `${parentPath}.${num}` : `${num ?? ''}` + + if (node.contents?.type === ElementType.Function && currentPath) { + state.updateFunctionMap(currentPath, node) + } + + if (node.children) { + discoverFunctionsFromTree(node.children, state, currentPath) + } + } +} From 65c58cdac63c3301d97b578e6812d26487a4423f Mon Sep 17 00:00:00 2001 From: Greaple Date: Thu, 13 Aug 2026 15:27:14 +0200 Subject: [PATCH 2/7] fix: resolve colon-formatted OID paths in resolvePath --- src/util.test.ts | 4 ++++ src/util.ts | 11 ++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/util.test.ts b/src/util.test.ts index 4a18481..e64d842 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -316,6 +316,10 @@ describe('resolvePath', () => { it('returns dotted path when no brackets', () => { expect(resolvePath('0.1.2')).toBe('0.1.2') }) + it('extracts OID before colon when colon label format is used', () => { + expect(resolvePath('1.2.3.2 : Call')).toBe('1.2.3.2') + expect(resolvePath('1.2.3.2:Call')).toBe('1.2.3.2') + }) it('uses last bracket pair when multiple exist', () => { expect(resolvePath('[0.1][0.2.3]')).toBe('0.2.3') }) diff --git a/src/util.ts b/src/util.ts index a2adcf0..b229631 100644 --- a/src/util.ts +++ b/src/util.ts @@ -187,7 +187,7 @@ export function resolvePath(path: string): string { const lastOpenBracket = pathString.lastIndexOf('[') const lastCloseBracket = pathString.lastIndexOf(']') - // Check if both brackets exist and close bracket comes after open bracket + // Check if both brackets exist and close bracket comes after open bracket (e.g. "Descriptor[1.2.3.4]") if (lastOpenBracket !== -1 && lastCloseBracket !== -1 && lastCloseBracket > lastOpenBracket) { const candidate = pathString.substring(lastOpenBracket + 1, lastCloseBracket) if (/^\d+(\.\d+)*$/.test(candidate)) { @@ -195,6 +195,15 @@ export function resolvePath(path: string): string { } } + // Check if colon format (e.g. "1.2.3.4 : Identifier") + const colonIndex = pathString.indexOf(':') + if (colonIndex !== -1) { + const candidate = pathString.substring(0, colonIndex).trim() + if (/^\d+(\.\d+)*$/.test(candidate)) { + return candidate + } + } + return pathString } From 941d7324dd44fa32ad1bde8a2f60da75864ef499 Mon Sep 17 00:00:00 2001 From: Greaple Date: Thu, 13 Aug 2026 15:29:08 +0200 Subject: [PATCH 3/7] chore: remove all test files --- src/actions/parameter.test.ts | 535 ---------------------------- src/feedbacks/parameter.test.ts | 510 --------------------------- src/index.test.ts | 413 ---------------------- src/state.test.ts | 313 ----------------- src/state.ts | 2 + src/util.test.ts | 593 -------------------------------- 6 files changed, 2 insertions(+), 2364 deletions(-) delete mode 100644 src/actions/parameter.test.ts delete mode 100644 src/feedbacks/parameter.test.ts delete mode 100644 src/index.test.ts delete mode 100644 src/state.test.ts delete mode 100644 src/util.test.ts diff --git a/src/actions/parameter.test.ts b/src/actions/parameter.test.ts deleted file mode 100644 index 0fad162..0000000 --- a/src/actions/parameter.test.ts +++ /dev/null @@ -1,535 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { subscribeParameterAction, learnSetValueActionOptions, setValue } from './parameter.js' - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -vi.mock('emberplus-connection', () => ({ - EmberClient: class {}, - Model: { - ParameterType: { - Boolean: 'boolean', - Integer: 'integer', - Real: 'real', - Enum: 'enum', - String: 'string', - }, - ElementType: { - Parameter: 'parameter', - Matrix: 'matrix', - }, - ParameterAccess: { - None: 'none', - Read: 'read', - Write: 'write', - ReadWrite: 'readWrite', - }, - }, -})) - -vi.mock('../actions.js', () => ({ - ActionId: { - SetValueString: 'setValueString', - SetValueBoolean: 'setValueBoolean', - SetValueInt: 'setValueInt', - SetValueReal: 'setValueReal', - SetValueEnum: 'setValueEnum', - }, -})) - -vi.mock('../util.js', () => ({ - resolveEventPath: vi.fn((action) => action.options.path ?? '0.1'), - calcRelativeNumber: vi.fn((value) => value + 1), - checkNumberLimits: vi.fn((value) => value), - isDefined: vi.fn((v) => v !== undefined && v !== null), - parseEscapeCharacters: vi.fn((s) => s + '_parsed'), - substituteEscapeCharacters: vi.fn((s) => s + '_sub'), -})) - -vi.mock('../state.js', () => ({ - EmberPlusState: class {}, -})) - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeState(params: Record = {}) { - const parameters = new Map(Object.entries(params)) - return { - parameters, - getCurrentEnumValue: vi.fn(() => 'On'), - getEnumIndex: vi.fn(() => 1), - } as any -} - -function makeSelf(node: any = null) { - return { - registerNewParameter: vi.fn().mockResolvedValue(node), - logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, - } as any -} - -function makeEmberClient() { - return { - setValue: vi.fn().mockResolvedValue({ response: Promise.resolve() }), - } as any -} - -function makeQueue() { - return { - add: vi.fn().mockImplementation((fn: any) => fn()), - } as any -} - -function makeAction(path = '0.1', options: Record = {}) { - return { - id: 'act1', - options: { path, ...options }, - } as any -} - -function makeNode( - type = 'parameter', - paramType = 'integer', - access = 'readWrite', - overrides: Record = {}, -) { - return { - contents: { - type, - parameterType: paramType, - access, - ...overrides, - }, - } as any -} - -const ctx = {} as any - -// --------------------------------------------------------------------------- -// subscribeParameterAction -// --------------------------------------------------------------------------- - -describe('subscribeParameterAction', () => { - it('always calls registerNewParameter', async () => { - const self = makeSelf() - await subscribeParameterAction(self)(makeAction('0.1', {}), ctx) - expect(self.registerNewParameter).toHaveBeenCalledWith('0.1', false) - }) - - it('passes createVar=true when variable option is set', async () => { - const self = makeSelf() - await subscribeParameterAction(self)(makeAction('0.1', { variable: true }), ctx) - expect(self.registerNewParameter).toHaveBeenCalledWith('0.1', true) - }) - - it('passes createVar=true when toggle option is set', async () => { - const self = makeSelf() - await subscribeParameterAction(self)(makeAction('0.1', { toggle: true }), ctx) - expect(self.registerNewParameter).toHaveBeenCalledWith('0.1', true) - }) - - it('passes createVar=true when relative option is set', async () => { - const self = makeSelf() - await subscribeParameterAction(self)(makeAction('0.1', { relative: true }), ctx) - expect(self.registerNewParameter).toHaveBeenCalledWith('0.1', true) - }) - - it('passes createVar=true when asEnum option is set', async () => { - const self = makeSelf() - await subscribeParameterAction(self)(makeAction('0.1', { asEnum: true }), ctx) - expect(self.registerNewParameter).toHaveBeenCalledWith('0.1', true) - }) - - it('passes createVar=false when none of the variable options are set', async () => { - const self = makeSelf() - await subscribeParameterAction(self)(makeAction('0.1', { variable: false, toggle: false }), ctx) - expect(self.registerNewParameter).toHaveBeenCalledWith('0.1', false) - }) -}) - -// --------------------------------------------------------------------------- -// learnSetValueActionOptions -// --------------------------------------------------------------------------- - -describe('learnSetValueActionOptions', () => { - it('returns undefined when path has no parameter', async () => { - const state = makeState() - const result = await learnSetValueActionOptions( - state, - 'integer' as any, - 'setValueInt' as any, - )(makeAction('0.1'), ctx) - expect(result).toBeUndefined() - }) - - it('returns undefined when paramType does not match', async () => { - const state = makeState({ '0.1': { parameterType: 'string', value: 'hello' } }) - const result = await learnSetValueActionOptions( - state, - 'integer' as any, - 'setValueInt' as any, - )(makeAction('0.1'), ctx) - expect(result).toBeUndefined() - }) - - it('does not mutate the original action.options', async () => { - const state = makeState({ '0.1': { parameterType: 'string', value: 'hello' } }) - const action = makeAction('0.1', { value: 'original', parseEscapeChars: false }) - const originalOptions = action.options - await learnSetValueActionOptions(state, 'string' as any, 'setValueString' as any)(action, ctx) - expect(action.options).toBe(originalOptions) - expect(action.options.value).toBe('original') - }) - - it('String: sets value to raw string when parseEscapeChars is false', async () => { - const state = makeState({ '0.1': { parameterType: 'string', value: 'hello' } }) - const result = await learnSetValueActionOptions( - state, - 'string' as any, - 'setValueString' as any, - )(makeAction('0.1', { parseEscapeChars: false }), ctx) - expect(result?.value).toBe('hello') - }) - - it('String: substitutes escape chars when parseEscapeChars is true', async () => { - const state = makeState({ '0.1': { parameterType: 'string', value: 'hello' } }) - const result = await learnSetValueActionOptions( - state, - 'string' as any, - 'setValueString' as any, - )(makeAction('0.1', { parseEscapeChars: true }), ctx) - expect(result?.value).toBe('hello_sub') - }) - - it('Boolean: sets value and valueVar', async () => { - const state = makeState({ '0.1': { parameterType: 'boolean', value: true } }) - const result = await learnSetValueActionOptions( - state, - 'boolean' as any, - 'setValueBoolean' as any, - )(makeAction('0.1'), ctx) - expect(result?.value).toBe(true) - expect(result?.valueVar).toBe('true') - }) - - it('Enum: sets min to "0" when minimum is not defined', async () => { - const state = makeState({ '0.1': { parameterType: 'enum', value: 1, enumeration: 'Off\nOn' } }) - const result = await learnSetValueActionOptions(state, 'enum' as any, 'setValueEnum' as any)(makeAction('0.1'), ctx) - expect(result?.min).toBe('0') - }) - - it('Enum: sets enumValue from getCurrentEnumValue', async () => { - const state = makeState({ '0.1': { parameterType: 'enum', value: 1, enumeration: 'Off\nOn' } }) - state.getCurrentEnumValue.mockReturnValue('On') - const result = await learnSetValueActionOptions(state, 'enum' as any, 'setValueEnum' as any)(makeAction('0.1'), ctx) - expect(result?.enumValue).toBe('On') - }) - - it('Int: applies factor to value', async () => { - const state = makeState({ '0.1': { parameterType: 'integer', value: 500, factor: 100 } }) - const result = await learnSetValueActionOptions( - state, - 'integer' as any, - 'setValueInt' as any, - )(makeAction('0.1'), ctx) - expect(result?.factor).toBe('100') - expect(result?.value).toBe(5) // 500 / 100 - }) - - it('Int: sets factor to "1" when no factor on parameter', async () => { - const state = makeState({ '0.1': { parameterType: 'integer', value: 42 } }) - const result = await learnSetValueActionOptions( - state, - 'integer' as any, - 'setValueInt' as any, - )(makeAction('0.1'), ctx) - expect(result?.factor).toBe('1') - }) - - it('Real: sets value and limits from parameter', async () => { - const state = makeState({ '0.1': { parameterType: 'real', value: 3.14, minimum: 0, maximum: 10 } }) - const result = await learnSetValueActionOptions(state, 'real' as any, 'setValueReal' as any)(makeAction('0.1'), ctx) - expect(result?.value).toBe(3.14) - expect(result?.min).toBe('0') - expect(result?.max).toBe('10') - }) - - it('returns undefined for unknown actionType', async () => { - const state = makeState({ '0.1': { parameterType: 'integer', value: 1 } }) - const result = await learnSetValueActionOptions(state, 'integer' as any, 'unknown' as any)(makeAction('0.1'), ctx) - expect(result).toBeUndefined() - }) -}) - -// --------------------------------------------------------------------------- -// setValue — node validation -// --------------------------------------------------------------------------- - -describe('setValue node validation', () => { - it('throws when registerNewParameter returns null', async () => { - const self = makeSelf(null) - const fn = setValue(self, makeEmberClient(), 'integer' as any, 'setValueInt' as any, makeState(), makeQueue()) - let threw = false - try { - await fn(makeAction('0.1'), ctx) - } catch { - threw = true - } - expect(threw).toBe(true) - }) - - it('throws when node is not a parameter type', async () => { - const self = makeSelf(makeNode('matrix', 'integer', 'readWrite')) - const fn = setValue(self, makeEmberClient(), 'integer' as any, 'setValueInt' as any, makeState(), makeQueue()) - let threw = false - try { - await fn(makeAction('0.1'), ctx) - } catch { - threw = true - } - expect(threw).toBe(true) - }) - - it('throws when node access is None', async () => { - const self = makeSelf(makeNode('parameter', 'integer', 'none')) - const fn = setValue(self, makeEmberClient(), 'integer' as any, 'setValueInt' as any, makeState(), makeQueue()) - let threw = false - try { - await fn(makeAction('0.1'), ctx) - } catch { - threw = true - } - expect(threw).toBe(true) - }) - - it('throws when node access is Read', async () => { - const self = makeSelf(makeNode('parameter', 'integer', 'read')) - const fn = setValue(self, makeEmberClient(), 'integer' as any, 'setValueInt' as any, makeState(), makeQueue()) - let threw = false - try { - await fn(makeAction('0.1'), ctx) - } catch { - threw = true - } - expect(threw).toBe(true) - }) - - it('throws when parameterType does not match', async () => { - const self = makeSelf(makeNode('parameter', 'string', 'readWrite')) - const fn = setValue(self, makeEmberClient(), 'integer' as any, 'setValueInt' as any, makeState(), makeQueue()) - let threw = false - try { - await fn(makeAction('0.1'), ctx) - } catch { - threw = true - } - expect(threw).toBe(true) - }) -}) - -// --------------------------------------------------------------------------- -// setValue — String -// --------------------------------------------------------------------------- - -describe('setValue String', () => { - it('calls emberClient.setValue with raw string value', async () => { - const node = makeNode('parameter', 'string', 'readWrite') - const self = makeSelf(node) - const client = makeEmberClient() - const fn = setValue(self, client, 'string' as any, 'setValueString' as any, makeState(), makeQueue()) - await fn(makeAction('0.1', { value: 'hello', parseEscapeChars: false }), ctx) - expect(client.setValue).toHaveBeenCalledWith(node, 'hello', false) - }) - - it('calls parseEscapeCharacters when parseEscapeChars is true', async () => { - const { parseEscapeCharacters } = await import('../util.js') - const node = makeNode('parameter', 'string', 'readWrite') - const self = makeSelf(node) - const client = makeEmberClient() - const fn = setValue(self, client, 'string' as any, 'setValueString' as any, makeState(), makeQueue()) - await fn(makeAction('0.1', { value: 'hello', parseEscapeChars: true }), ctx) - expect(parseEscapeCharacters).toHaveBeenCalled() - }) -}) - -// --------------------------------------------------------------------------- -// setValue — Integer -// --------------------------------------------------------------------------- - -describe('setValue Integer', () => { - it('calls emberClient.setValue with factored integer value', async () => { - const node = makeNode('parameter', 'integer', 'readWrite') - const self = makeSelf(node) - const client = makeEmberClient() - const state = makeState({ '0.1': { minimum: 0, maximum: 1000 } }) - const fn = setValue(self, client, 'integer' as any, 'setValueInt' as any, state, makeQueue()) - await fn(makeAction('0.1', { value: 5, factor: '100' }), ctx) - expect(client.setValue).toHaveBeenCalledWith(node, 500, false) - }) - - it('returns without calling setValue when value is NaN', async () => { - const node = makeNode('parameter', 'integer', 'readWrite') - const self = makeSelf(node) - const client = makeEmberClient() - const fn = setValue(self, client, 'integer' as any, 'setValueInt' as any, makeState(), makeQueue()) - await fn(makeAction('0.1', { value: 'not-a-number' }), ctx) - expect(client.setValue.mock.calls.length).toBe(0) - }) - - it('uses valueVar when useVar is true', async () => { - const node = makeNode('parameter', 'integer', 'readWrite') - const self = makeSelf(node) - const client = makeEmberClient() - const fn = setValue(self, client, 'integer' as any, 'setValueInt' as any, makeState(), makeQueue()) - await fn(makeAction('0.1', { useVar: true, valueVar: '7', factor: '1' }), ctx) - expect(client.setValue).toHaveBeenCalledWith(node, 7, false) - }) -}) - -// --------------------------------------------------------------------------- -// setValue — Real -// --------------------------------------------------------------------------- - -describe('setValue Real', () => { - it('calls emberClient.setValue with real value', async () => { - const node = makeNode('parameter', 'real', 'readWrite') - const self = makeSelf(node) - const client = makeEmberClient() - const fn = setValue(self, client, 'real' as any, 'setValueReal' as any, makeState(), makeQueue()) - await fn(makeAction('0.1', { value: 3.14 }), ctx) - expect(client.setValue).toHaveBeenCalledWith(node, 3.14, false) - }) - - it('returns without calling setValue when value is NaN', async () => { - const node = makeNode('parameter', 'real', 'readWrite') - const self = makeSelf(node) - const client = makeEmberClient() - const fn = setValue(self, client, 'real' as any, 'setValueReal' as any, makeState(), makeQueue()) - await fn(makeAction('0.1', { value: 'not-a-number' }), ctx) - expect(client.setValue.mock.calls.length).toBe(0) - }) -}) - -// --------------------------------------------------------------------------- -// setValue — Enum -// --------------------------------------------------------------------------- - -describe('setValue Enum', () => { - it('calls emberClient.setValue with numeric enum index', async () => { - const node = makeNode('parameter', 'enum', 'readWrite') - const self = makeSelf(node) - const client = makeEmberClient() - const state = makeState({ '0.1': { minimum: 0, maximum: 3 } }) - const fn = setValue(self, client, 'enum' as any, 'setValueEnum' as any, state, makeQueue()) - await fn(makeAction('0.1', { value: 2 }), ctx) - expect(client.setValue).toHaveBeenCalledWith(node, 2, false) - }) - - it('asEnum: resolves enum string to index via getEnumIndex', async () => { - const node = makeNode('parameter', 'enum', 'readWrite') - const self = makeSelf(node) - const client = makeEmberClient() - const state = makeState({ '0.1': { minimum: 0, maximum: 3 } }) - state.getEnumIndex.mockReturnValue(1) - const fn = setValue(self, client, 'enum' as any, 'setValueEnum' as any, state, makeQueue()) - await fn(makeAction('0.1', { asEnum: true, enumValue: 'On' }), ctx) - expect(client.setValue).toHaveBeenCalledWith(node, 1, false) - }) - - it('asEnum: throws when enum key is not found', async () => { - const node = makeNode('parameter', 'enum', 'readWrite') - const self = makeSelf(node) - const state = makeState({ '0.1': {} }) - state.getEnumIndex.mockReturnValue(undefined) - const fn = setValue(self, makeEmberClient(), 'enum' as any, 'setValueEnum' as any, state, makeQueue()) - let threw = false - try { - await fn(makeAction('0.1', { asEnum: true, enumValue: 'Unknown' }), ctx) - } catch { - threw = true - } - expect(threw).toBe(true) - }) -}) - -// --------------------------------------------------------------------------- -// setValue — Boolean -// --------------------------------------------------------------------------- - -describe('setValue Boolean', () => { - it('calls emberClient.setValue with true', async () => { - const node = makeNode('parameter', 'boolean', 'readWrite') - const self = makeSelf(node) - const client = makeEmberClient() - const fn = setValue(self, client, 'boolean' as any, 'setValueBoolean' as any, makeState(), makeQueue()) - await fn(makeAction('0.1', { value: true }), ctx) - expect(client.setValue).toHaveBeenCalledWith(node, true, false) - }) - - it('toggle: inverts current parameter value', async () => { - const node = makeNode('parameter', 'boolean', 'readWrite') - const self = makeSelf(node) - const client = makeEmberClient() - const state = makeState({ '0.1': { value: true } }) - const fn = setValue(self, client, 'boolean' as any, 'setValueBoolean' as any, state, makeQueue()) - await fn(makeAction('0.1', { toggle: true }), ctx) - expect(client.setValue).toHaveBeenCalledWith(node, false, false) - }) - - it('useVar: parses "true" string as true', async () => { - const node = makeNode('parameter', 'boolean', 'readWrite') - const self = makeSelf(node) - const client = makeEmberClient() - const fn = setValue(self, client, 'boolean' as any, 'setValueBoolean' as any, makeState(), makeQueue()) - await fn(makeAction('0.1', { useVar: true, valueVar: 'true' }), ctx) - expect(client.setValue).toHaveBeenCalledWith(node, true, false) - }) - - it('useVar: parses "false" string as false', async () => { - const node = makeNode('parameter', 'boolean', 'readWrite') - const self = makeSelf(node) - const client = makeEmberClient() - const fn = setValue(self, client, 'boolean' as any, 'setValueBoolean' as any, makeState(), makeQueue()) - await fn(makeAction('0.1', { useVar: true, valueVar: 'false' }), ctx) - expect(client.setValue).toHaveBeenCalledWith(node, false, false) - }) - - it('useVar: parses "on" as true', async () => { - const node = makeNode('parameter', 'boolean', 'readWrite') - const self = makeSelf(node) - const client = makeEmberClient() - const fn = setValue(self, client, 'boolean' as any, 'setValueBoolean' as any, makeState(), makeQueue()) - await fn(makeAction('0.1', { useVar: true, valueVar: 'on' }), ctx) - expect(client.setValue).toHaveBeenCalledWith(node, true, false) - }) - - it('useVar: parses "0" as false', async () => { - const node = makeNode('parameter', 'boolean', 'readWrite') - const self = makeSelf(node) - const client = makeEmberClient() - const fn = setValue(self, client, 'boolean' as any, 'setValueBoolean' as any, makeState(), makeQueue()) - await fn(makeAction('0.1', { useVar: true, valueVar: '0' }), ctx) - expect(client.setValue).toHaveBeenCalledWith(node, false, false) - }) -}) - -// --------------------------------------------------------------------------- -// setValue — relative values -// --------------------------------------------------------------------------- - -describe('setValue relative', () => { - it('Integer: calls calcRelativeNumber when relative is true', async () => { - const { calcRelativeNumber } = await import('../util.js') - vi.mocked(calcRelativeNumber).mockReturnValue(6) - const node = makeNode('parameter', 'integer', 'readWrite') - const self = makeSelf(node) - const client = makeEmberClient() - const fn = setValue(self, client, 'integer' as any, 'setValueInt' as any, makeState(), makeQueue()) - await fn(makeAction('0.1', { value: 5, factor: '1', relative: true, min: '0', max: '10' }), ctx) - expect(calcRelativeNumber).toHaveBeenCalled() - expect(client.setValue).toHaveBeenCalledWith(node, 6, false) - }) -}) diff --git a/src/feedbacks/parameter.test.ts b/src/feedbacks/parameter.test.ts deleted file mode 100644 index 16fa6ee..0000000 --- a/src/feedbacks/parameter.test.ts +++ /dev/null @@ -1,510 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { - subscribeParameterFeedback, - unsubscribeParameterFeedback, - learnParameterFeedbackOptions, - resolveBooleanFeedback, - parameterFeedbackCallback, - parameterValueFeedbackCallback, -} from './parameter.js' -import { FeedbackId } from '../feedback.js' -import { compareNumber, parseEscapeCharacters } from '../util.js' - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -vi.mock('emberplus-connection', () => ({ - Model: { - ParameterType: { - Boolean: 'boolean', - Integer: 'integer', - Real: 'real', - Enum: 'enum', - String: 'string', - }, - }, -})) - -vi.mock('emberplus-connection/dist/model', () => ({ - ParameterType: { - Boolean: 'boolean', - Integer: 'integer', - Real: 'real', - Enum: 'enum', - String: 'string', - }, -})) - -vi.mock('../feedback', () => ({ - FeedbackId: { - Boolean: 'boolean', - Parameter: 'parameter', - String: 'string', - ENUM: 'enum', - }, -})) - -vi.mock('../util', () => ({ - resolveEventPath: vi.fn((feedback) => feedback.options.path ?? '0.1'), - compareNumber: vi.fn(() => true), - parseEscapeCharacters: vi.fn((s) => s), - substituteEscapeCharacters: vi.fn((s) => s + '_sub'), - NumberComparitor: { - Equal: 'eq', - NotEqual: 'ne', - LessThan: 'lt', - LessThanEqual: 'lte', - GreaterThan: 'gt', - GreaterThanEqual: 'gte', - }, -})) - -vi.mock('../state', () => ({ - EmberPlusState: class { - parameters = new Map() - addIdToPathMap = vi.fn() - getCurrentEnumValue = vi.fn() - getParameter = vi.fn() - }, -})) - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -interface MockState { - parameters: Map - addIdToPathMap: ReturnType - getCurrentEnumValue: ReturnType -} - -function makeState(overrides: Partial = {}): any { - return { - parameters: new Map(), - addIdToPathMap: vi.fn(), - getCurrentEnumValue: vi.fn(() => 'On'), - ...overrides, - } -} - -function makeSelf(overrides: Record = {}) { - return { - registerNewParameter: vi.fn().mockResolvedValue(false), - ...overrides, - } as any -} - -function makeFeedback(path = '0.1', options: Record = {}) { - return { - id: 'fb1', - options: { path, ...options }, - } as any -} - -const ctx = {} as any - -// --------------------------------------------------------------------------- -// subscribeParameterFeedback -// --------------------------------------------------------------------------- - -describe('subscribeParameterFeedback', () => { - it('calls registerNewParameter with the resolved path', async () => { - const state = makeState() - const self = makeSelf({ registerNewParameter: vi.fn().mockResolvedValue(true) }) - const fn = subscribeParameterFeedback(state, self) - await fn(makeFeedback('0.1'), ctx) - expect(self.registerNewParameter).toHaveBeenCalledWith('0.1', true) - }) - - it('adds feedback id to path map when registerNewParameter returns true', async () => { - const state = makeState() - const self = makeSelf({ registerNewParameter: vi.fn().mockResolvedValue(true) }) - await subscribeParameterFeedback(state, self)(makeFeedback('0.1'), ctx) - expect(state.addIdToPathMap).toHaveBeenCalledWith('fb1', '0.1') - }) - - it('does not add to path map when registerNewParameter returns false', async () => { - const state = makeState() - const self = makeSelf({ registerNewParameter: vi.fn().mockResolvedValue(false) }) - await subscribeParameterFeedback(state, self)(makeFeedback('0.1'), ctx) - expect(state.addIdToPathMap.mock.calls.length).toBe(0) - }) -}) - -// --------------------------------------------------------------------------- -// unsubscribeParameterFeedback -// --------------------------------------------------------------------------- - -describe('unsubscribeParameterFeedback', () => { - it('maps feedback id to empty string to remove it', async () => { - const state = makeState() - await unsubscribeParameterFeedback(state)(makeFeedback('0.1'), ctx) - expect(state.addIdToPathMap).toHaveBeenCalledWith('fb1', '') - }) -}) - -// --------------------------------------------------------------------------- -// learnParameterFeedbackOptions -// --------------------------------------------------------------------------- - -describe('learnParameterFeedbackOptions', () => { - it('returns undefined when path has no parameter', async () => { - const state = makeState() - const fn = learnParameterFeedbackOptions(state, FeedbackId.String) - const result = await fn(makeFeedback('0.1'), ctx) - expect(result).toBeUndefined() - }) - - it('returns undefined when parameter value is undefined', async () => { - const state = makeState() - state.parameters.set('0.1', { value: undefined }) - const result = await learnParameterFeedbackOptions(state, FeedbackId.String)(makeFeedback('0.1'), ctx) - expect(result).toBeUndefined() - }) - - it('String: sets options.value to the raw string value', async () => { - const state = makeState() - state.parameters.set('0.1', { value: 'hello' }) - const feedback = makeFeedback('0.1', { parseEscapeChars: false }) - const result = await learnParameterFeedbackOptions(state, FeedbackId.String)(feedback, ctx) - expect(result?.value).toBe('hello') - }) - - it('String: substitutes escape characters when parseEscapeChars is true', async () => { - const state = makeState() - state.parameters.set('0.1', { value: 'hello' }) - const feedback = makeFeedback('0.1', { parseEscapeChars: true }) - const result = await learnParameterFeedbackOptions(state, FeedbackId.String)(feedback, ctx) - expect(result?.value).toBe('hello_sub') - }) - - it('ENUM: returns undefined when enumVal is empty string', async () => { - const state = makeState() - state.parameters.set('0.1', { value: 0 }) - state.getCurrentEnumValue.mockReturnValue('') - const result = await learnParameterFeedbackOptions(state, FeedbackId.ENUM)(makeFeedback('0.1'), ctx) - expect(result).toBeUndefined() - }) - - it('ENUM: sets options.value to the current enum string', async () => { - const state = makeState() - state.parameters.set('0.1', { value: 1 }) - state.getCurrentEnumValue.mockReturnValue('On') - const result = await learnParameterFeedbackOptions(state, FeedbackId.ENUM)(makeFeedback('0.1'), ctx) - expect(result?.value).toBe('On') - }) - - it('Parameter: returns undefined when value is not a number', async () => { - const state = makeState() - state.parameters.set('0.1', { value: 'not-a-number' }) - const result = await learnParameterFeedbackOptions(state, FeedbackId.Parameter)(makeFeedback('0.1'), ctx) - expect(result).toBeUndefined() - }) - - it('Parameter: sets value, valueVar, and asInt correctly for Integer type', async () => { - const state = makeState() - state.parameters.set('0.1', { value: 42, parameterType: 'integer', factor: 100 }) - const feedback = makeFeedback('0.1', { factor: '1' }) - const result = await learnParameterFeedbackOptions(state, FeedbackId.Parameter)(feedback, ctx) - expect(result?.value).toBe(42) - expect(result?.valueVar).toBe('42') - expect(result?.asInt).toBe(true) - expect(result?.factor).toBe('100') - }) - - it('Parameter: asInt is false for Real type', async () => { - const state = makeState() - state.parameters.set('0.1', { value: 3.14, parameterType: 'real' }) - const result = await learnParameterFeedbackOptions(state, FeedbackId.Parameter)(makeFeedback('0.1'), ctx) - expect(result?.asInt).toBe(false) - }) - - it('returns undefined for unknown feedbackType', async () => { - const state = makeState() - state.parameters.set('0.1', { value: 1 }) - const result = await learnParameterFeedbackOptions(state, 'unknown' as any)(makeFeedback('0.1'), ctx) - expect(result).toBeUndefined() - }) - - it('does not mutate the original feedback.options object', async () => { - const state = makeState() - state.parameters.set('0.1', { value: 'hello' }) - const feedback = makeFeedback('0.1', { parseEscapeChars: false, value: 'original' }) - const originalOptions = feedback.options - await learnParameterFeedbackOptions(state, FeedbackId.String)(feedback, ctx) - expect(feedback.options).toBe(originalOptions) - expect(feedback.options.value).toBe('original') - }) -}) - -// --------------------------------------------------------------------------- -// resolveBooleanFeedback -// --------------------------------------------------------------------------- - -describe('resolveBooleanFeedback', () => { - beforeEach(() => { - vi.mocked(compareNumber).mockClear() - vi.mocked(compareNumber).mockReturnValue(true) - }) - - it('Boolean: returns true when parameter value is truthy', async () => { - const state = makeState() - state.parameters.set('0.1', { value: true }) - const result = await resolveBooleanFeedback(state, 'boolean' as any, '0.1') - expect(result).toBe(true) - }) - - it('Boolean: returns false when parameter value is falsy', async () => { - const state = makeState() - state.parameters.set('0.1', { value: false }) - const result = await resolveBooleanFeedback(state, 'boolean' as any, '0.1') - expect(result).toBe(false) - }) - - it('Real: delegates to compareNumber', async () => { - vi.mocked(compareNumber).mockReturnValue(false) - const state = makeState() - state.parameters.set('0.1', { value: 3.14 }) - const result = await resolveBooleanFeedback(state, 'real' as any, '0.1', 3.14) - expect(compareNumber).toHaveBeenCalled() - expect(result).toBe(false) - }) - - it('Integer: applies factor to the comparison value', async () => { - vi.mocked(compareNumber).mockReturnValue(true) - const state = makeState() - state.parameters.set('0.1', { value: 500 }) - await resolveBooleanFeedback(state, 'integer' as any, '0.1', 5, { - comparitor: 'eq' as any, - factor: '100', - }) - expect(vi.mocked(compareNumber).mock.calls[0][0]).toBe(500) // Math.floor(5 * 100) - }) - - it('Integer: treats NaN factor as 1', async () => { - vi.mocked(compareNumber).mockReturnValue(true) - const state = makeState() - state.parameters.set('0.1', { value: 5 }) - await resolveBooleanFeedback(state, 'integer' as any, '0.1', 5, { - comparitor: 'eq' as any, - factor: 'not-a-number', - }) - expect(vi.mocked(compareNumber).mock.calls[0][0]).toBe(5) // Math.floor(5 * 1) - }) - - it('Enum: returns true when current enum value matches', async () => { - const state = makeState() - state.getCurrentEnumValue.mockReturnValue('On') - const result = await resolveBooleanFeedback(state, 'enum' as any, '0.1', 'On') - expect(result).toBe(true) - }) - - it('Enum: returns false when current enum value does not match', async () => { - const state = makeState() - state.getCurrentEnumValue.mockReturnValue('Off') - const result = await resolveBooleanFeedback(state, 'enum' as any, '0.1', 'On') - expect(result).toBe(false) - }) - - it('String: compares parsed value against parameter string value', async () => { - const state = makeState() - state.parameters.set('0.1', { value: 'hello' }) - const result = await resolveBooleanFeedback(state, 'string' as any, '0.1', 'hello', { parse: false }) - expect(result).toBe(true) - }) - - it('String: returns false when values do not match', async () => { - const state = makeState() - state.parameters.set('0.1', { value: 'hello' }) - const result = await resolveBooleanFeedback(state, 'string' as any, '0.1', 'world', { parse: false }) - expect(result).toBe(false) - }) - - it('String: calls parseEscapeCharacters when parse is true', async () => { - const state = makeState() - state.parameters.set('0.1', { value: 'hello' }) - await resolveBooleanFeedback(state, 'string' as any, '0.1', 'hello', { parse: true }) - expect(parseEscapeCharacters).toHaveBeenCalled() - }) - - it('default: falls through to String comparison', async () => { - const state = makeState() - state.parameters.set('0.1', { value: 'test' }) - const result = await resolveBooleanFeedback(state, 'unknown' as any, '0.1', 'test', { parse: false }) - expect(result).toBe(true) - }) -}) - -// --------------------------------------------------------------------------- -// parameterFeedbackCallback -// --------------------------------------------------------------------------- - -describe('parameterFeedbackCallback', () => { - it('calls registerNewParameter on every invocation', async () => { - const state = makeState() - const self = makeSelf() - const fn = parameterFeedbackCallback(self, state, FeedbackId.Boolean) - await fn(makeFeedback('0.1'), ctx) - expect(self.registerNewParameter).toHaveBeenCalledWith('0.1', true) - }) - - it('returns false when path is not in state.parameters', async () => { - const state = makeState() - const self = makeSelf() - const result = await parameterFeedbackCallback(self, state, FeedbackId.Boolean)(makeFeedback('0.1'), ctx) - expect(result).toBe(false) - }) - - it('does not call registerNewParameter a second time in the else branch', async () => { - const state = makeState() - const self = makeSelf({ registerNewParameter: vi.fn().mockResolvedValue(false) }) - await parameterFeedbackCallback(self, state, FeedbackId.Boolean)(makeFeedback('0.1'), ctx) - expect(self.registerNewParameter.mock.calls.length).toBe(1) - }) - - it('Boolean: resolves feedback when parameter exists', async () => { - const state = makeState() - state.parameters.set('0.1', { value: true }) - const self = makeSelf() - const result = await parameterFeedbackCallback(self, state, FeedbackId.Boolean)(makeFeedback('0.1'), ctx) - expect(typeof result).toBe('boolean') - }) - - it('ENUM: passes enum value string from options', async () => { - const state = makeState() - state.parameters.set('0.1', { value: 1 }) - state.getCurrentEnumValue.mockReturnValue('On') - const self = makeSelf() - const feedback = makeFeedback('0.1', { value: 'On' }) - const result = await parameterFeedbackCallback(self, state, FeedbackId.ENUM)(feedback, ctx) - expect(result).toBe(true) - }) - - it('Parameter: uses Integer type when asInt is true', async () => { - vi.mocked(compareNumber).mockReturnValue(true) - const state = makeState() - state.parameters.set('0.1', { value: 5 }) - const self = makeSelf() - const feedback = makeFeedback('0.1', { - asInt: true, - value: 5, - valueVar: '5', - useVar: false, - comparitor: 'eq', - factor: '1', - }) - const result = await parameterFeedbackCallback(self, state, FeedbackId.Parameter)(feedback, ctx) - expect(result).toBe(true) - }) - - it('Parameter: uses Real type when asInt is false', async () => { - vi.mocked(compareNumber).mockReturnValue(true) - const state = makeState() - state.parameters.set('0.1', { value: 3.14 }) - const self = makeSelf() - const feedback = makeFeedback('0.1', { - asInt: false, - value: 3.14, - useVar: false, - comparitor: 'eq', - factor: '1', - }) - const result = await parameterFeedbackCallback(self, state, FeedbackId.Parameter)(feedback, ctx) - expect(result).toBe(true) - }) - - it('Parameter: uses valueVar when useVar is true', async () => { - const state = makeState() - state.parameters.set('0.1', { value: 5 }) - const self = makeSelf() - const feedback = makeFeedback('0.1', { - asInt: true, - value: 0, - valueVar: '5', - useVar: true, - comparitor: 'eq', - factor: '1', - }) - await parameterFeedbackCallback(self, state, FeedbackId.Parameter)(feedback, ctx) - expect(vi.mocked(compareNumber).mock.calls.at(-1)?.[0]).toBe(5) // Math.floor('5' * 1) - }) - - it('String: passes value and parse flag from options', async () => { - const state = makeState() - state.parameters.set('0.1', { value: 'hello' }) - const self = makeSelf() - const feedback = makeFeedback('0.1', { value: 'hello', parseEscapeChars: false }) - const result = await parameterFeedbackCallback(self, state, FeedbackId.String)(feedback, ctx) - expect(result).toBe(true) - }) -}) - -// --------------------------------------------------------------------------- -// parameterValueFeedbackCallback -// --------------------------------------------------------------------------- - -describe('parameterValueFeedbackCallback', () => { - it('returns null when path is not in state.parameters', async () => { - const state = makeState() - const self = makeSelf() - const result = await parameterValueFeedbackCallback(self, state)(makeFeedback('0.1'), ctx) - expect(result).toBeNull() - }) - - it('returns the raw value for non-Integer types', async () => { - const state = makeState() - state.parameters.set('0.1', { value: 'hello', parameterType: 'string' }) - const self = makeSelf() - const result = await parameterValueFeedbackCallback(self, state)(makeFeedback('0.1'), ctx) - expect(result).toBe('hello') - }) - - it('applies factor division for Integer type', async () => { - const state = makeState() - state.parameters.set('0.1', { value: 500, parameterType: 'integer', factor: 100 }) - const self = makeSelf() - const result = await parameterValueFeedbackCallback(self, state)(makeFeedback('0.1'), ctx) - expect(result).toBe(5) - }) - - it('defaults factor to 1 when not set for Integer type', async () => { - const state = makeState() - state.parameters.set('0.1', { value: 42, parameterType: 'integer' }) - const self = makeSelf() - const result = await parameterValueFeedbackCallback(self, state)(makeFeedback('0.1'), ctx) - expect(result).toBe(42) - }) - - it('converts Buffer values to an Array', async () => { - const state = makeState() - const buf = Buffer.from([1, 2, 3]) - state.parameters.set('0.1', { value: buf, parameterType: 'string' }) - const self = makeSelf() - const result = await parameterValueFeedbackCallback(self, state)(makeFeedback('0.1'), ctx) - expect(Array.isArray(result)).toBe(true) - expect(result).toEqual([1, 2, 3]) - }) - - it('returns null when parameter value is null', async () => { - const state = makeState() - state.parameters.set('0.1', { value: null, parameterType: 'string' }) - const self = makeSelf() - const result = await parameterValueFeedbackCallback(self, state)(makeFeedback('0.1'), ctx) - expect(result).toBeNull() - }) - - it('calls registerNewParameter with false (read-only)', async () => { - const state = makeState() - const self = makeSelf() - await parameterValueFeedbackCallback(self, state)(makeFeedback('0.1'), ctx) - expect(self.registerNewParameter).toHaveBeenCalledWith('0.1', false) - }) - - it('does not call registerNewParameter a second time in the else branch', async () => { - const state = makeState() - const self = makeSelf({ registerNewParameter: vi.fn().mockResolvedValue(false) }) - await parameterValueFeedbackCallback(self, state)(makeFeedback('0.1'), ctx) - expect(self.registerNewParameter.mock.calls.length).toBe(1) - }) -}) diff --git a/src/index.test.ts b/src/index.test.ts deleted file mode 100644 index 6dc44b8..0000000 --- a/src/index.test.ts +++ /dev/null @@ -1,413 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { EmberPlusInstance } from './index.js' -import { EmberPlusState } from './state.js' -import { ElementType, ParameterType } from 'emberplus-connection/dist/model' -import { LoggerLevel } from './logger.js' - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -vi.mock('@companion-module/base', () => ({ - InstanceBase: class { - checkFeedbacks = vi.fn() - checkFeedbacksById = vi.fn() - setActionDefinitions = vi.fn() - setFeedbackDefinitions = vi.fn() - setVariableDefinitions = vi.fn() - setVariableValues = vi.fn() - setPresetDefinitions = vi.fn() - recordAction = vi.fn() - log = vi.fn() - }, - InstanceStatus: { - Ok: 'ok', - Connecting: 'connecting', - ConnectionFailure: 'connection_failure', - BadConfig: 'bad_config', - UnknownWarning: 'unknown_warning', - Disconnected: 'disconnected', - }, - runEntrypoint: vi.fn(), -})) - -vi.mock('emberplus-connection', () => ({ - EmberClient: class { - on = vi.fn() - connect = vi.fn().mockResolvedValue(undefined) - disconnect = vi.fn().mockResolvedValue(undefined) - discard = vi.fn() - removeAllListeners = vi.fn() - getDirectory = vi.fn().mockResolvedValue({ response: Promise.resolve() }) - getElementByPath = vi.fn().mockResolvedValue(undefined) - tree = {} - }, - Model: { - ParameterType: { - Boolean: 'boolean', - Integer: 'integer', - Real: 'real', - Enum: 'enum', - String: 'string', - }, - }, -})) - -vi.mock('emberplus-connection/dist/model', () => ({ - ElementType: { Parameter: 'parameter', Node: 'node' }, - ParameterType: { - Boolean: 'boolean', - Integer: 'integer', - Real: 'real', - Enum: 'enum', - String: 'string', - }, -})) - -vi.mock('./actions', () => ({ GetActionsList: vi.fn().mockReturnValue({}) })) -vi.mock('./feedback', () => ({ - GetFeedbacksList: vi.fn().mockReturnValue({}), - FeedbackId: {}, -})) -vi.mock('./presets', () => ({ GetPresetsList: vi.fn().mockReturnValue({}) })) -vi.mock('./variables', () => ({ GetVariablesList: vi.fn().mockReturnValue([]) })) -vi.mock('./config', () => ({ - GetConfigFields: vi.fn().mockReturnValue([]), -})) -vi.mock('./upgrades', () => ({ UpgradeScripts: [] })) - -vi.mock('./logger.js', () => ({ - Logger: class { - info = vi.fn() - warn = vi.fn() - error = vi.fn() - debug = vi.fn() - console = vi.fn() - }, - LoggerLevel: { Information: 'information', Warning: 'warning', Error: 'error' }, -})) - -vi.mock('./status.js', () => ({ - StatusManager: class { - updateStatus = vi.fn() - destroy = vi.fn() - }, -})) - -vi.mock('./util', () => ({ - sanitiseVariableId: (id: string) => id.replaceAll(/[^a-zA-Z0-9-_.]/gm, '_'), - parseBonjourHost: vi.fn().mockReturnValue(['192.168.0.1', 9000]), - hasConnectionChanged: vi.fn().mockReturnValue(false), - recordParameterAction: vi.fn(), - parseParameterValue: vi.fn().mockReturnValue({ actionType: 'setValueInt', value: 42 }), -})) - -vi.mock('p-queue', () => ({ - default: class { - add = vi.fn().mockImplementation((fn: any) => fn()) - clear = vi.fn() - }, -})) - -vi.mock('es-toolkit', () => ({ - throttle: vi.fn().mockImplementation((fn) => { - const wrapped = (...args: any[]) => fn(...args) - wrapped.cancel = vi.fn() - return wrapped - }), - debounce: vi.fn().mockImplementation((fn) => { - const wrapped = (...args: any[]) => fn(...args) - wrapped.cancel = vi.fn() - return wrapped - }), -})) - -// --------------------------------------------------------------------------- -// Factory — creates a fresh instance with state wired in -// --------------------------------------------------------------------------- - -function makeInstance(): EmberPlusInstance { - const instance = new EmberPlusInstance('test-id') - // Wire a fresh state - ;(instance as any).state = new EmberPlusState() - // Provide a default config - ;(instance as any).config = { - host: '192.168.0.1', - port: 9000, - factor: true, - logging: LoggerLevel.Information, - } - return instance -} - -// --------------------------------------------------------------------------- -// setupMatrices (via private access) -// --------------------------------------------------------------------------- - -describe('setupMatrices', () => { - it('populates state.matrices array from matricesString', () => { - const instance = makeInstance() - ;(instance as any).config.matricesString = '0.1.0, 0.2.0, 0.3.0' - ;(instance as any).setupMatrices() - expect((instance as any).state.matrices).toEqual(['0.1.0', '0.2.0', '0.3.0']) - }) - - it('converts slashes to dots', () => { - const instance = makeInstance() - ;(instance as any).config.matricesString = '0/1/0, 0/2/0' - ;(instance as any).setupMatrices() - expect((instance as any).state.matrices.includes('0.1.0')).toBe(true) - }) - - it('filters out empty entries', () => { - const instance = makeInstance() - ;(instance as any).config.matricesString = '0.1.0,, , 0.2.0' - ;(instance as any).setupMatrices() - expect((instance as any).state.matrices.length).toBe(2) - }) - - it('resets selected source and target when matrices exist', () => { - const instance = makeInstance() - ;(instance as any).state.selected = { source: 5, target: 3, matrix: 0 } - ;(instance as any).config.matricesString = '0.1.0' - ;(instance as any).setupMatrices() - expect((instance as any).state.selected.source).toBe(-1) - expect((instance as any).state.selected.target).toBe(-1) - }) - - it('does nothing when matricesString is undefined', () => { - const instance = makeInstance() - ;(instance as any).config.matricesString = undefined - ;(instance as any).setupMatrices() - expect((instance as any).state.matrices.length).toBe(0) - }) -}) - -// --------------------------------------------------------------------------- -// setupMonitoredParams (via private access) -// --------------------------------------------------------------------------- - -describe('setupMonitoredParams', () => { - it('populates monitoredParameters from monitoredParametersString', () => { - const instance = makeInstance() - ;(instance as any).config.monitoredParametersString = '0.1.2, 0.3.4' - ;(instance as any).setupMonitoredParams() - expect((instance as any).state.monitoredParameters).toEqual(new Set(['0.1.2', '0.3.4'])) - }) - - it('converts slashes to dots', () => { - const instance = makeInstance() - ;(instance as any).config.monitoredParametersString = '0/1/2' - ;(instance as any).setupMonitoredParams() - expect((instance as any).state.monitoredParameters.has('0.1.2')).toBe(true) - }) - - it('filters out empty entries', () => { - const instance = makeInstance() - ;(instance as any).config.monitoredParametersString = '0.1.2,, ,' - ;(instance as any).setupMonitoredParams() - expect((instance as any).state.monitoredParameters.size).toBe(1) - }) - - it('sorts parameters', () => { - const instance = makeInstance() - ;(instance as any).config.monitoredParametersString = '0.3, 0.1, 0.2' - ;(instance as any).setupMonitoredParams() - const result = [...(instance as any).state.monitoredParameters] - expect(result).toEqual([...result].sort()) - }) - - it('results in an empty set when monitoredParametersString is undefined', () => { - const instance = makeInstance() - ;(instance as any).config.monitoredParametersString = undefined - ;(instance as any).setupMonitoredParams() - expect((instance as any).state.monitoredParameters.size).toBe(0) - }) -}) - -// --------------------------------------------------------------------------- -// updateCompanionBits -// --------------------------------------------------------------------------- - -describe('updateCompanionBits', () => { - it('calls all four setters when all options are true', () => { - const instance = makeInstance() - instance.updateCompanionBits({ - updateActions: true, - updateFeedbacks: true, - updatePresets: true, - updateVariables: true, - }) - expect((instance as any).setActionDefinitions).toHaveBeenCalled() - expect((instance as any).setFeedbackDefinitions).toHaveBeenCalled() - expect((instance as any).setVariableDefinitions).toHaveBeenCalled() - expect((instance as any).setPresetDefinitions).toHaveBeenCalled() - }) - - it('skips setters for false options', () => { - const instance = makeInstance() - instance.updateCompanionBits({ - updateActions: false, - updateFeedbacks: false, - updatePresets: false, - updateVariables: false, - }) - expect((instance as any).setActionDefinitions.mock.calls.length).toBe(0) - expect((instance as any).setFeedbackDefinitions.mock.calls.length).toBe(0) - expect((instance as any).setVariableDefinitions.mock.calls.length).toBe(0) - expect((instance as any).setPresetDefinitions.mock.calls.length).toBe(0) - }) - - it('only calls variable definitions when only updateVariables is true', () => { - const instance = makeInstance() - instance.updateCompanionBits({ - updateVariables: true, - updateActions: false, - updateFeedbacks: false, - updatePresets: false, - }) - expect((instance as any).setVariableDefinitions).toHaveBeenCalled() - expect((instance as any).setActionDefinitions.mock.calls.length).toBe(0) - }) -}) - -// --------------------------------------------------------------------------- -// handleStartStopRecordActions -// --------------------------------------------------------------------------- - -describe('handleStartStopRecordActions', () => { - it('sets isRecordingActions to true', () => { - const instance = makeInstance() - instance.handleStartStopRecordActions(true) - expect((instance as any).isRecordingActions).toBe(true) - }) - - it('sets isRecordingActions to false', () => { - const instance = makeInstance() - ;(instance as any).isRecordingActions = true - instance.handleStartStopRecordActions(false) - expect((instance as any).isRecordingActions).toBe(false) - }) -}) - -// --------------------------------------------------------------------------- -// handleChangedValue -// --------------------------------------------------------------------------- - -describe('handleChangedValue', () => { - function makeNode(overrides: Record = {}) { - return { - contents: { - type: ElementType.Parameter, - parameterType: ParameterType.Integer, - value: 10, - ...overrides, - }, - } as any - } - - it('ignores non-Parameter nodes', async () => { - const instance = makeInstance() - await instance.handleChangedValue('0.1', { contents: { type: ElementType.Node } }) - expect((instance as any).setVariableValues.mock.calls.length).toBe(0) - }) - - it('queues feedback check for registered feedback ids', async () => { - const instance = makeInstance() - ;(instance as any).state.addIdToPathMap('fb1', '0.1') - ;(instance as any).throttledFeedbackChecksVariableUpdates = vi.fn() - await instance.handleChangedValue('0.1', makeNode()) - expect((instance as any).feedbacksToCheck.has('fb1')).toBe(true) - }) - - it('calls recordParameterAction when recording is active', async () => { - const { recordParameterAction } = await import('./util.js') - const instance = makeInstance() - ;(instance as any).isRecordingActions = true - await instance.handleChangedValue('0.1', makeNode()) - expect(recordParameterAction).toHaveBeenCalled() - }) - - it('does not call recordParameterAction when not recording', async () => { - const { recordParameterAction } = await import('./util.js') - vi.mocked(recordParameterAction).mockClear() - const instance = makeInstance() - ;(instance as any).isRecordingActions = false - await instance.handleChangedValue('0.1', makeNode()) - expect(vi.mocked(recordParameterAction).mock.calls.length).toBe(0) - }) - - it('skips processing when parseParameterValue returns no actionType', async () => { - const util = await import('./util.js') - vi.mocked(util.parseParameterValue).mockReturnValueOnce({ actionType: undefined, value: 0 }) - const instance = makeInstance() - await instance.handleChangedValue('0.1', makeNode()) - expect((instance as any).setVariableValues.mock.calls.length).toBe(0) - }) -}) - -// --------------------------------------------------------------------------- -// updateFeedbacksAndVariables -// --------------------------------------------------------------------------- - -describe('updateFeedbacksAndVariables', () => { - it('stores factorised value in variableValueUpdates for Integer when factor=true', () => { - const instance = makeInstance() - ;(instance as any).throttledFeedbackChecksVariableUpdates = vi.fn() - ;(instance as any).config.factor = true - ;(instance as any).updateFeedbacksAndVariables('0.1', ParameterType.Integer, 5, 10) - expect((instance as any).variableValueUpdates['0.1']).toBe(5) - }) - - it('stores raw value in variableValueUpdates for Integer when factor=false', () => { - const instance = makeInstance() - ;(instance as any).throttledFeedbackChecksVariableUpdates = vi.fn() - ;(instance as any).config.factor = false - ;(instance as any).updateFeedbacksAndVariables('0.1', ParameterType.Integer, 5, 10) - expect((instance as any).variableValueUpdates['0.1']).toBe(10) - }) - - it('stores _ENUM variable for Enum type', () => { - const instance = makeInstance() - ;(instance as any).throttledFeedbackChecksVariableUpdates = vi.fn() - ;(instance as any).state.updateParameterMap('0.1', { - contents: { type: ElementType.Parameter, parameterType: ParameterType.Enum, enumeration: 'Off\nOn', value: 1 }, - } as any) - ;(instance as any).updateFeedbacksAndVariables('0.1', ParameterType.Enum, 1, 1) - expect((instance as any).variableValueUpdates['0.1_ENUM']).toBe('On') - }) - - it('sanitises path to create variable id', () => { - const instance = makeInstance() - ;(instance as any).throttledFeedbackChecksVariableUpdates = vi.fn() - ;(instance as any).updateFeedbacksAndVariables('0/1/2', ParameterType.Integer, 5, 5) - expect((instance as any).variableValueUpdates['0_1_2']).toBeDefined() - }) -}) - -// --------------------------------------------------------------------------- -// getConfigFields -// --------------------------------------------------------------------------- - -describe('getConfigFields', () => { - it('returns an array', () => { - const instance = makeInstance() - expect(Array.isArray(instance.getConfigFields())).toBe(true) - }) -}) - -// --------------------------------------------------------------------------- -// destroy -// --------------------------------------------------------------------------- - -describe('destroy', () => { - it('cancels throttles, clears queue, and destroys client without throwing', async () => { - const instance = makeInstance() - // Provide a mock emberClient so destroyEmberClient does not throw on undefined check - ;(instance as any).emberClient = { - removeAllListeners: vi.fn(), - discard: vi.fn(), - } - await instance.destroy() - }) -}) diff --git a/src/state.test.ts b/src/state.test.ts deleted file mode 100644 index 13fa827..0000000 --- a/src/state.test.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { EmberPlusState } from './state.js' -import { ElementType } from 'emberplus-connection/dist/model' - -vi.mock('emberplus-connection/dist/model', () => ({ - ElementType: { - Parameter: 'parameter', - Node: 'node', - }, -})) - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeNode(overrides: Record = {}) { - return { - contents: { - type: ElementType.Parameter, - identifier: 'gain', - value: 0, - ...overrides, - }, - } as any -} - -// --------------------------------------------------------------------------- -// constructor / initial state -// --------------------------------------------------------------------------- - -describe('EmberPlusState constructor', () => { - it('initialises selected with -1 for all fields', () => { - const state = new EmberPlusState() - expect(state.selected).toEqual({ source: -1, target: -1, matrix: -1 }) - }) - - it('initialises empty parameters map', () => { - expect(new EmberPlusState().parameters.size).toBe(0) - }) - - it('initialises empty monitoredParameters set', () => { - expect(new EmberPlusState().monitoredParameters.size).toBe(0) - }) - - it('initialises empty matrices array', () => { - expect(new EmberPlusState().matrices.length).toBe(0) - }) - - it('initialises empty emberElement map', () => { - expect(new EmberPlusState().emberElement.size).toBe(0) - }) -}) - -// --------------------------------------------------------------------------- -// addIdToPathMap / getFeedbacksByPath -// --------------------------------------------------------------------------- - -describe('addIdToPathMap', () => { - let state: EmberPlusState - - beforeEach(() => { - state = new EmberPlusState() - }) - - it('registers a feedback id against a path', () => { - state.addIdToPathMap('fb1', '0.1.2') - expect(state.getFeedbacksByPath('0.1.2').has('fb1')).toBe(true) - }) - - it('multiple ids can be registered against the same path', () => { - state.addIdToPathMap('fb1', '0.1.2') - state.addIdToPathMap('fb2', '0.1.2') - expect(state.getFeedbacksByPath('0.1.2').size).toBe(2) - }) - - it('adding same id twice does not create duplicates', () => { - state.addIdToPathMap('fb1', '0.1.2') - state.addIdToPathMap('fb1', '0.1.2') - expect(state.getFeedbacksByPath('0.1.2').size).toBe(1) - }) - - it('does not add to byPath when path is empty string', () => { - state.addIdToPathMap('fb1', '') - expect(state.getFeedbacksByPath('').size).toBe(0) - }) - - it('moves id to new path when path changes', () => { - state.addIdToPathMap('fb1', '0.1.2') - state.addIdToPathMap('fb1', '0.1.3') - expect(state.getFeedbacksByPath('0.1.2').has('fb1')).toBe(false) - expect(state.getFeedbacksByPath('0.1.3').has('fb1')).toBe(true) - }) - - it('cleans up empty sets after path change', () => { - state.addIdToPathMap('fb1', '0.1.2') - state.addIdToPathMap('fb1', '0.1.3') - // byPath entry for old path should have been removed - expect(state.getFeedbacksByPath('0.1.2').size).toBe(0) - }) -}) - -// --------------------------------------------------------------------------- -// getFeedbacksByPath -// --------------------------------------------------------------------------- - -describe('getFeedbacksByPath', () => { - it('returns an empty set for unknown paths', () => { - const state = new EmberPlusState() - const result = state.getFeedbacksByPath('unknown') - expect(result).toBeInstanceOf(Set) - expect(result.size).toBe(0) - }) -}) - -// --------------------------------------------------------------------------- -// removeFeedbackId -// --------------------------------------------------------------------------- - -describe('removeFeedbackId', () => { - let state: EmberPlusState - - beforeEach(() => { - state = new EmberPlusState() - }) - - it('removes id from byPath', () => { - state.addIdToPathMap('fb1', '0.1.2') - state.removeFeedbackId('fb1') - expect(state.getFeedbacksByPath('0.1.2').has('fb1')).toBe(false) - }) - - it('cleans up empty byPath entry after removal', () => { - state.addIdToPathMap('fb1', '0.1.2') - state.removeFeedbackId('fb1') - expect(state.getFeedbacksByPath('0.1.2').size).toBe(0) - }) - - it('does not remove other ids registered on the same path', () => { - state.addIdToPathMap('fb1', '0.1.2') - state.addIdToPathMap('fb2', '0.1.2') - state.removeFeedbackId('fb1') - expect(state.getFeedbacksByPath('0.1.2').has('fb2')).toBe(true) - }) - - it('does nothing for an unknown id', () => { - state.removeFeedbackId('nonexistent') - }) -}) - -// --------------------------------------------------------------------------- -// updateParameterMap -// --------------------------------------------------------------------------- - -describe('updateParameterMap', () => { - let state: EmberPlusState - - beforeEach(() => { - state = new EmberPlusState() - }) - - it('stores a new parameter', () => { - state.updateParameterMap('0.1', makeNode({ identifier: 'level' })) - expect(state.parameters.has('0.1')).toBe(true) - }) - - it('stores the ember element', () => { - const node = makeNode() - state.updateParameterMap('0.1', node) - expect(state.emberElement.get('0.1')).toBe(node) - }) - - it('merges new fields into existing parameter', () => { - state.updateParameterMap('0.1', makeNode({ identifier: 'level', value: 5 })) - state.updateParameterMap('0.1', { contents: { type: ElementType.Parameter, value: 10 } }) - expect(state.parameters.get('0.1')?.identifier).toBe('level') - expect(state.parameters.get('0.1')?.value).toBe(10) - }) - - it('ignores nodes that are not Parameters', () => { - const node = { contents: { type: ElementType.Node } } as any - state.updateParameterMap('0.1', node) - expect(state.parameters.has('0.1')).toBe(false) - }) -}) - -// --------------------------------------------------------------------------- -// getCurrentEnumValue -// --------------------------------------------------------------------------- - -describe('getCurrentEnumValue', () => { - let state: EmberPlusState - - beforeEach(() => { - state = new EmberPlusState() - }) - - it('returns the correct enum string for a valid index', () => { - state.updateParameterMap('0.1', makeNode({ enumeration: 'Off\nOn\nStandby', value: 1 })) - expect(state.getCurrentEnumValue('0.1')).toBe('On') - }) - - it('returns first entry for index 0', () => { - state.updateParameterMap('0.1', makeNode({ enumeration: 'Off\nOn', value: 0 })) - expect(state.getCurrentEnumValue('0.1')).toBe('Off') - }) - - it('returns empty string when index is out of range', () => { - state.updateParameterMap('0.1', makeNode({ enumeration: 'Off\nOn', value: 5 })) - expect(state.getCurrentEnumValue('0.1')).toBe('') - }) - - it('returns empty string when enumeration is missing', () => { - state.updateParameterMap('0.1', makeNode({ value: 0 })) - expect(state.getCurrentEnumValue('0.1')).toBe('') - }) - - it('returns empty string when value is undefined', () => { - state.updateParameterMap('0.1', makeNode({ enumeration: 'Off\nOn', value: undefined })) - expect(state.getCurrentEnumValue('0.1')).toBe('') - }) - - it('returns empty string for unknown path', () => { - expect(state.getCurrentEnumValue('9.9.9')).toBe('') - }) -}) - -// --------------------------------------------------------------------------- -// getEnumIndex -// --------------------------------------------------------------------------- - -describe('getEnumIndex', () => { - let state: EmberPlusState - - beforeEach(() => { - state = new EmberPlusState() - state.updateParameterMap('0.1', makeNode({ enumeration: 'Off\nOn\nStandby', value: 0 })) - }) - - it('returns the correct index for a known enum string', () => { - expect(state.getEnumIndex('0.1', 'On')).toBe(1) - }) - - it('returns 0 for the first enum entry', () => { - expect(state.getEnumIndex('0.1', 'Off')).toBe(0) - }) - - it('returns undefined for a string not in the enumeration', () => { - expect(state.getEnumIndex('0.1', 'Unknown')).toBeUndefined() - }) - - it('returns undefined when path has no enumeration', () => { - state.updateParameterMap('0.2', makeNode({ value: 0 })) - expect(state.getEnumIndex('0.2', 'Off')).toBeUndefined() - }) - - it('returns undefined for unknown path', () => { - expect(state.getEnumIndex('9.9.9', 'Off')).toBeUndefined() - }) -}) - -// --------------------------------------------------------------------------- -// getParameter / hasParameter -// --------------------------------------------------------------------------- - -describe('getParameter', () => { - it('returns the parameter for a known path', () => { - const state = new EmberPlusState() - state.updateParameterMap('0.1', makeNode({ identifier: 'gain' })) - expect(state.getParameter('0.1')?.identifier).toBe('gain') - }) - - it('returns undefined for an unknown path', () => { - expect(new EmberPlusState().getParameter('9.9.9')).toBeUndefined() - }) -}) - -describe('hasParameter', () => { - it('returns true for a registered path', () => { - const state = new EmberPlusState() - state.updateParameterMap('0.1', makeNode()) - expect(state.hasParameter('0.1')).toBe(true) - }) - - it('returns false for an unregistered path', () => { - expect(new EmberPlusState().hasParameter('0.1')).toBe(false) - }) -}) - -// --------------------------------------------------------------------------- -// clear -// --------------------------------------------------------------------------- - -describe('clear', () => { - it('empties all maps and sets and resets selected', () => { - const state = new EmberPlusState() - state.updateParameterMap('0.1', makeNode()) - state.addIdToPathMap('fb1', '0.1') - state.monitoredParameters.add('0.1') - state.matrices.push('0.1') - state.selected = { source: 1, target: 2, matrix: 3 } - - state.clear() - - expect(state.parameters.size).toBe(0) - expect(state.emberElement.size).toBe(0) - expect(state.getFeedbacksByPath('0.1').size).toBe(0) - expect(state.selected).toEqual({ source: -1, target: -1, matrix: -1 }) - }) - - it('can be called on an already-empty state without throwing', () => { - new EmberPlusState().clear() - }) -}) diff --git a/src/state.ts b/src/state.ts index 0ddc091..445cfb4 100644 --- a/src/state.ts +++ b/src/state.ts @@ -172,6 +172,8 @@ export class EmberPlusState { */ public hasParameter(path: string): boolean { return this.parameters.has(path) + } + /** * Clear cached ember elements */ diff --git a/src/util.test.ts b/src/util.test.ts deleted file mode 100644 index e64d842..0000000 --- a/src/util.test.ts +++ /dev/null @@ -1,593 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { - assertUnreachable, - literal, - compareNumber, - NumberComparitor, - comparitorOptions, - parseEscapeCharacters, - substituteEscapeCharacters, - filterPathChoices, - checkNumberLimits, - calcRelativeNumber, - resolvePath, - resolveEventPath, - sanitiseVariableId, - isDefined, - parseBonjourHost, - hasConnectionChanged, - recordParameterAction, - parseParameterValue, -} from './util.js' -import { ActionId } from './actions.js' -import { EmberPlusState } from './state.js' -import { Model as EmberModel } from 'emberplus-connection' - -// --------------------------------------------------------------------------- -// Mocks -// --------------------------------------------------------------------------- - -vi.mock('emberplus-connection', () => ({ - Model: { - ParameterType: { - Boolean: 'boolean', - Integer: 'integer', - Real: 'real', - Enum: 'enum', - String: 'string', - }, - ParameterAccess: { - None: 'none', - Read: 'read', - Write: 'write', - ReadWrite: 'readWrite', - }, - }, -})) - -vi.mock('./actions', () => ({ - ActionId: { - SetValueBoolean: 'setValueBoolean', - SetValueInt: 'setValueInt', - SetValueReal: 'setValueReal', - SetValueEnum: 'setValueEnum', - SetValueString: 'setValueString', - }, -})) - -// Minimal EmberPlusState mock factory -function makeState(params: Map = new Map()): EmberPlusState { - return { - parameters: params, - getCurrentEnumValue: vi.fn().mockReturnValue('enumLabel'), - } as unknown as EmberPlusState -} - -// Minimal EmberPlusInstance mock -function makeInstance() { - return { recordAction: vi.fn() } as any -} - -// --------------------------------------------------------------------------- -// literal -// --------------------------------------------------------------------------- - -describe('literal', () => { - it('returns the value unchanged', () => { - expect(literal(42)).toBe(42) - expect(literal('hello')).toBe('hello') - expect(literal({ a: 1 })).toEqual({ a: 1 }) - }) -}) - -// --------------------------------------------------------------------------- -// assertUnreachable -// --------------------------------------------------------------------------- - -describe('assertUnreachable', () => { - it('does not throw (no-op implementation)', () => { - // If this throws, vitest will fail the test automatically - assertUnreachable(undefined as never) - }) -}) - -// --------------------------------------------------------------------------- -// compareNumber -// --------------------------------------------------------------------------- - -describe('compareNumber', () => { - it('Equal: returns true when values match', () => { - expect(compareNumber(5, NumberComparitor.Equal, 5)).toBe(true) - }) - it('Equal: returns false when values differ', () => { - expect(compareNumber(5, NumberComparitor.Equal, 6)).toBe(false) - }) - it('NotEqual: returns true when values differ', () => { - expect(compareNumber(5, NumberComparitor.NotEqual, 6)).toBe(true) - }) - it('NotEqual: returns false when values match', () => { - expect(compareNumber(5, NumberComparitor.NotEqual, 5)).toBe(false) - }) - it('LessThan: currentValue < target', () => { - expect(compareNumber(10, NumberComparitor.LessThan, 5)).toBe(true) - expect(compareNumber(10, NumberComparitor.LessThan, 10)).toBe(false) - }) - it('LessThanEqual: currentValue <= target', () => { - expect(compareNumber(10, NumberComparitor.LessThanEqual, 10)).toBe(true) - expect(compareNumber(10, NumberComparitor.LessThanEqual, 11)).toBe(false) - }) - it('GreaterThan: currentValue > target', () => { - expect(compareNumber(5, NumberComparitor.GreaterThan, 10)).toBe(true) - expect(compareNumber(10, NumberComparitor.GreaterThan, 10)).toBe(false) - }) - it('GreaterThanEqual: currentValue >= target', () => { - expect(compareNumber(5, NumberComparitor.GreaterThanEqual, 5)).toBe(true) - expect(compareNumber(5, NumberComparitor.GreaterThanEqual, 4)).toBe(false) - }) - it('returns false when target is NaN', () => { - expect(compareNumber(NaN, NumberComparitor.Equal, 5)).toBe(false) - }) -}) - -// --------------------------------------------------------------------------- -// comparitorOptions -// --------------------------------------------------------------------------- - -describe('comparitorOptions', () => { - it('contains all 6 comparitor entries', () => { - expect(comparitorOptions).toHaveLength(6) - }) - it('each entry has id and label', () => { - comparitorOptions.forEach((opt) => { - expect(opt).toHaveProperty('id') - expect(opt).toHaveProperty('label') - }) - }) -}) - -// --------------------------------------------------------------------------- -// parseEscapeCharacters -// --------------------------------------------------------------------------- - -describe('parseEscapeCharacters', () => { - it('converts \\n to newline', () => { - expect(parseEscapeCharacters('line1\\nline2')).toBe('line1\nline2') - }) - it('converts \\r, \\t, \\f, \\v, \\b', () => { - expect(parseEscapeCharacters('\\r')).toBe('\r') - expect(parseEscapeCharacters('\\t')).toBe('\t') - expect(parseEscapeCharacters('\\f')).toBe('\f') - expect(parseEscapeCharacters('\\v')).toBe('\v') - expect(parseEscapeCharacters('\\b')).toBe('\b') - }) - it('converts \\x00 – \\x03 to control characters', () => { - expect(parseEscapeCharacters('\\x00')).toBe('\x00') - expect(parseEscapeCharacters('\\x03')).toBe('\x03') - }) - it('leaves regular strings unchanged', () => { - expect(parseEscapeCharacters('hello world')).toBe('hello world') - }) -}) - -// --------------------------------------------------------------------------- -// substituteEscapeCharacters -// --------------------------------------------------------------------------- - -describe('substituteEscapeCharacters', () => { - it('is the inverse of parseEscapeCharacters for supported sequences', () => { - const original = 'a\nb\tc\rd' - const substituted = substituteEscapeCharacters(original) - expect(substituted).toBe('a\\nb\\tc\\rd') - expect(parseEscapeCharacters(substituted)).toBe(original) - }) - it('converts control characters back to escape sequences', () => { - expect(substituteEscapeCharacters('\x00')).toBe('\\x00') - expect(substituteEscapeCharacters('\x03')).toBe('\\x03') - }) -}) - -// --------------------------------------------------------------------------- -// filterPathChoices -// --------------------------------------------------------------------------- - -describe('filterPathChoices', () => { - const makeParam = (parameterType: string, access: string, identifier?: string, description?: string) => ({ - parameterType, - access, - identifier, - description, - }) - - it('returns readable choices when isWriteable=false', () => { - const params = new Map([ - ['0.1', makeParam(EmberModel.ParameterType.Integer, EmberModel.ParameterAccess.Read, 'gain')], - ]) - const state = makeState(params) - const choices = filterPathChoices(state, false, EmberModel.ParameterType.Integer) - expect(choices).toHaveLength(1) - expect(choices[0].id).toBe('0.1') - expect(choices[0].label).toContain('gain') - }) - - it('excludes None-access paths when isWriteable=false', () => { - const params = new Map([ - ['0.1', makeParam(EmberModel.ParameterType.Integer, EmberModel.ParameterAccess.None)], - ]) - const choices = filterPathChoices(makeState(params), false, EmberModel.ParameterType.Integer) - expect(choices).toHaveLength(0) - }) - - it('only includes ReadWrite/Write paths when isWriteable=true', () => { - const params = new Map([ - ['0.1', makeParam(EmberModel.ParameterType.Integer, EmberModel.ParameterAccess.Read)], - ['0.2', makeParam(EmberModel.ParameterType.Integer, EmberModel.ParameterAccess.ReadWrite)], - ['0.3', makeParam(EmberModel.ParameterType.Integer, EmberModel.ParameterAccess.Write)], - ]) - const choices = filterPathChoices(makeState(params), true, EmberModel.ParameterType.Integer) - expect(choices).toHaveLength(2) - expect(choices.map((c) => c.id)).toEqual(expect.arrayContaining(['0.2', '0.3'])) - }) - - it('returns all types when no filter specified', () => { - const params = new Map([ - ['0.1', makeParam(EmberModel.ParameterType.Integer, EmberModel.ParameterAccess.Read)], - ['0.2', makeParam(EmberModel.ParameterType.Boolean, EmberModel.ParameterAccess.Read)], - ]) - const choices = filterPathChoices(makeState(params), false) - expect(choices).toHaveLength(2) - }) - - it('appends description in parentheses when present', () => { - const params = new Map([ - ['0.1', makeParam(EmberModel.ParameterType.Integer, EmberModel.ParameterAccess.Read, 'id', 'my desc')], - ]) - const choices = filterPathChoices(makeState(params), false, EmberModel.ParameterType.Integer) - expect(choices[0].label).toContain('(my desc)') - }) -}) - -// --------------------------------------------------------------------------- -// checkNumberLimits -// --------------------------------------------------------------------------- - -describe('checkNumberLimits', () => { - it('returns value when within range', () => { - expect(checkNumberLimits(5, 0, 10)).toBe(5) - }) - it('clamps to min', () => { - expect(checkNumberLimits(-5, 0, 10)).toBe(0) - }) - it('clamps to max', () => { - expect(checkNumberLimits(15, 0, 10)).toBe(10) - }) - it('handles boundary values', () => { - expect(checkNumberLimits(0, 0, 10)).toBe(0) - expect(checkNumberLimits(10, 0, 10)).toBe(10) - }) -}) - -// --------------------------------------------------------------------------- -// calcRelativeNumber -// --------------------------------------------------------------------------- - -describe('calcRelativeNumber', () => { - it('adds relative value to current state value', () => { - const state = makeState(new Map([['0.1', { value: 5 }]])) - const result = calcRelativeNumber(3, '0.1', '', '', EmberModel.ParameterType.Integer, state) - expect(result).toBe(8) - }) - - it('rounds result for Integer type', () => { - const state = makeState(new Map([['0.1', { value: 5.4 }]])) - const result = calcRelativeNumber(1.3, '0.1', '', '', EmberModel.ParameterType.Integer, state) - expect(result).toBe(Math.round(6.7)) - }) - - it('enforces min/max limits', () => { - const state = makeState(new Map([['0.1', { value: 8 }]])) - const result = calcRelativeNumber(5, '0.1', '0', '10', EmberModel.ParameterType.Integer, state) - expect(result).toBe(10) - }) - - it('defaults old value to 0 when path missing', () => { - const state = makeState(new Map()) - const result = calcRelativeNumber(3, 'missing', '', '', EmberModel.ParameterType.Real, state) - expect(result).toBe(3) - }) - - it('clamps Enum type to minimum of 0', () => { - const state = makeState(new Map([['0.1', { value: 0 }]])) - const result = calcRelativeNumber(-5, '0.1', '', '', EmberModel.ParameterType.Enum, state) - expect(result).toBe(0) - }) -}) - -// --------------------------------------------------------------------------- -// resolvePath -// --------------------------------------------------------------------------- - -describe('resolvePath', () => { - it('replaces slashes with dots and trims', () => { - expect(resolvePath('0/1/2')).toBe('0.1.2') - }) - it('extracts content inside last brackets', () => { - expect(resolvePath('node[0.1.2]')).toBe('0.1.2') - }) - it('returns dotted path when no brackets', () => { - expect(resolvePath('0.1.2')).toBe('0.1.2') - }) - it('extracts OID before colon when colon label format is used', () => { - expect(resolvePath('1.2.3.2 : Call')).toBe('1.2.3.2') - expect(resolvePath('1.2.3.2:Call')).toBe('1.2.3.2') - }) - it('uses last bracket pair when multiple exist', () => { - expect(resolvePath('[0.1][0.2.3]')).toBe('0.2.3') - }) - it('ignores brackets when close comes before open', () => { - expect(resolvePath(']0.1[')).toBe(']0.1[') - }) - - describe('valid numeric ember+ paths in brackets', () => { - it('extracts a single segment path', () => { - expect(resolvePath('node[0]')).toBe('0') - }) - it('extracts a two segment path', () => { - expect(resolvePath('node[0.1]')).toBe('0.1') - }) - it('extracts a deeply nested path', () => { - expect(resolvePath('node[0.1.2.3.4]')).toBe('0.1.2.3.4') - }) - it('extracts path with multi-digit segments', () => { - expect(resolvePath('node[12.345.6]')).toBe('12.345.6') - }) - it('replaces slashes before extracting bracket content', () => { - expect(resolvePath('node/path[0.1.2]')).toBe('0.1.2') - }) - }) - - describe('invalid numeric ember+ paths in brackets', () => { - it('returns full path when bracket content is empty', () => { - expect(resolvePath('node[]')).toBe('node[]') - }) - it('returns full path when bracket content is a label string', () => { - expect(resolvePath('node[gain.value]')).toBe('node[gain.value]') - }) - it('returns full path when bracket content has a trailing dot', () => { - expect(resolvePath('node[0.1.]')).toBe('node[0.1.]') - }) - it('returns full path when bracket content has a leading dot', () => { - expect(resolvePath('node[.0.1]')).toBe('node[.0.1]') - }) - it('returns full path when bracket content has consecutive dots', () => { - expect(resolvePath('node[0..1]')).toBe('node[0..1]') - }) - it('returns full path when bracket content contains letters mixed with numbers', () => { - expect(resolvePath('node[0.1a.2]')).toBe('node[0.1a.2]') - }) - it('returns full path when bracket content contains slashes', () => { - expect(resolvePath('node[0/1/2]')).toBe('0.1.2') // slashes are replaced first → becomes 'node[0.1.2]' → valid - }) - }) -}) - -// --------------------------------------------------------------------------- -// resolveEventPath -// --------------------------------------------------------------------------- - -describe('resolveEventPath', () => { - it('uses path when usePathVar is false', () => { - const event = { options: { usePathVar: false, path: '0.1.2', pathVar: 'var.path' } } as any - expect(resolveEventPath(event)).toBe('0.1.2') - }) - it('uses pathVar when usePathVar is true', () => { - const event = { options: { usePathVar: true, path: '0.1.2', pathVar: 'var.path' } } as any - expect(resolveEventPath(event)).toBe('var.path') - }) - it('handles missing path gracefully', () => { - const event = { options: { usePathVar: false } } as any - expect(resolveEventPath(event)).toBe('') - }) -}) - -// --------------------------------------------------------------------------- -// sanitiseVariableId -// --------------------------------------------------------------------------- - -describe('sanitiseVariableId', () => { - it('replaces illegal characters with underscore by default', () => { - expect(sanitiseVariableId('my var!')).toBe('my_var_') - }) - it('allows alphanumerics, hyphens, underscores, dots', () => { - expect(sanitiseVariableId('my-var_1.2')).toBe('my-var_1.2') - }) - it('uses specified substitute character', () => { - expect(sanitiseVariableId('my var', '-')).toBe('my-var') - }) - it('removes illegal chars when substitute is empty string', () => { - expect(sanitiseVariableId('my var!', '')).toBe('myvar') - }) -}) - -// --------------------------------------------------------------------------- -// isDefined -// --------------------------------------------------------------------------- - -describe('isDefined', () => { - it('returns true for defined values', () => { - expect(isDefined(0)).toBe(true) - expect(isDefined('')).toBe(true) - expect(isDefined(false)).toBe(true) - expect(isDefined({})).toBe(true) - }) - it('returns false for null', () => { - expect(isDefined(null)).toBe(false) - }) - it('returns false for undefined', () => { - expect(isDefined(undefined)).toBe(false) - }) -}) - -// --------------------------------------------------------------------------- -// parseBonjourHost -// --------------------------------------------------------------------------- - -describe('parseBonjourHost', () => { - it('returns host and port from config when no bonjourHost', () => { - const config = { host: '192.168.1.1', port: 8080 } as any - expect(parseBonjourHost(config)).toEqual(['192.168.1.1', 8080]) - }) - it('parses host and port from bonjourHost string', () => { - const config = { bonjourHost: '10.0.0.1:9001' } as any - expect(parseBonjourHost(config)).toEqual(['10.0.0.1', 9001]) - }) - it('defaults to port 9000 when bonjourHost port is missing/invalid', () => { - const config = { bonjourHost: '10.0.0.1:abc' } as any - expect(parseBonjourHost(config)).toEqual(['10.0.0.1', 9000]) - }) - it('defaults host to empty string and port to 9000 when nothing provided', () => { - const config = {} as any - expect(parseBonjourHost(config)).toEqual(['', 9000]) - }) -}) - -// --------------------------------------------------------------------------- -// hasConnectionChanged -// --------------------------------------------------------------------------- - -describe('hasConnectionChanged', () => { - it('returns true when host changed', () => { - expect(hasConnectionChanged({ host: 'a', port: 9000 } as any, { host: 'b', port: 9000 } as any)).toBe(true) - }) - it('returns true when port changed', () => { - expect(hasConnectionChanged({ host: 'a', port: 9000 } as any, { host: 'a', port: 9001 } as any)).toBe(true) - }) - it('returns false when neither changed', () => { - expect(hasConnectionChanged({ host: 'a', port: 9000 } as any, { host: 'a', port: 9000 } as any)).toBe(false) - }) -}) - -// --------------------------------------------------------------------------- -// recordParameterAction -// --------------------------------------------------------------------------- - -describe('recordParameterAction', () => { - it('records a boolean action', () => { - const instance = makeInstance() - const state = makeState(new Map()) - recordParameterAction('0.1', ActionId.SetValueBoolean, true, instance, state) - expect(instance.recordAction).toHaveBeenCalledWith( - expect.objectContaining({ actionId: ActionId.SetValueBoolean }), - '0.1', - ) - }) - - it('records an integer action with min/max from state', () => { - const instance = makeInstance() - const state = makeState(new Map([['0.1', { minimum: 0, maximum: 100, factor: 2 }]])) - recordParameterAction('0.1', ActionId.SetValueInt, 50, instance, state) - const call = instance.recordAction.mock.calls[0][0] - expect(call.options.min).toBe('0') - expect(call.options.max).toBe('100') - expect(call.options.factor).toBe('2') - }) - - it('records a real action', () => { - const instance = makeInstance() - recordParameterAction('0.1', ActionId.SetValueReal, 3.14, instance, makeState()) - expect(instance.recordAction).toHaveBeenCalledWith( - expect.objectContaining({ actionId: ActionId.SetValueReal }), - '0.1', - ) - }) - - it('records an enum action', () => { - const instance = makeInstance() - const state = makeState(new Map([['0.1', { minimum: 0, maximum: 5 }]])) - recordParameterAction('0.1', ActionId.SetValueEnum, 2, instance, state) - const call = instance.recordAction.mock.calls[0][0] - expect(call.options.asEnum).toBe(true) - }) - - it('records a string action', () => { - const instance = makeInstance() - recordParameterAction('0.1', ActionId.SetValueString, 'hello', instance, makeState()) - const call = instance.recordAction.mock.calls[0][0] - expect(call.options.parseEscapeChars).toBe(false) - }) - - it('does nothing for unknown action type', () => { - const instance = makeInstance() - recordParameterAction('0.1', 'unknown' as any, 0, instance, makeState()) - expect(instance.recordAction.mock.calls.length).toBe(0) - }) -}) - -// --------------------------------------------------------------------------- -// parseParameterValue -// --------------------------------------------------------------------------- - -describe('parseParameterValue', () => { - it('parses Boolean parameter', () => { - const state = makeState() - const result = parseParameterValue( - '0.1', - { parameterType: EmberModel.ParameterType.Boolean, value: true } as any, - state, - ) - expect(result).toEqual({ actionType: ActionId.SetValueBoolean, value: true }) - }) - - it('parses Integer parameter and divides by factor', () => { - const state = makeState(new Map([['0.1', { factor: 2 }]])) - const result = parseParameterValue( - '0.1', - { parameterType: EmberModel.ParameterType.Integer, value: 100 } as any, - state, - ) - expect(result).toEqual({ actionType: ActionId.SetValueInt, value: 50 }) - }) - - it('defaults to factor 1 when param missing', () => { - const state = makeState() - const result = parseParameterValue( - '0.1', - { parameterType: EmberModel.ParameterType.Integer, value: 100 } as any, - state, - ) - expect(result.value).toBe(100) - }) - - it('parses Real parameter', () => { - const state = makeState() - const result = parseParameterValue( - '0.1', - { parameterType: EmberModel.ParameterType.Real, value: 3.14 } as any, - state, - ) - expect(result).toEqual({ actionType: ActionId.SetValueReal, value: 3.14 }) - }) - - it('parses Enum parameter', () => { - const state = makeState() - const result = parseParameterValue('0.1', { parameterType: EmberModel.ParameterType.Enum, value: 2 } as any, state) - expect(result).toEqual({ actionType: ActionId.SetValueEnum, value: 2 }) - }) - - it('parses String parameter and substitutes escape characters', () => { - const state = makeState() - const result = parseParameterValue( - '0.1', - { parameterType: EmberModel.ParameterType.String, value: 'line1\nline2' } as any, - state, - ) - expect(result.actionType).toBe(ActionId.SetValueString) - expect(result.value).toBe('line1\\nline2') - }) - - it('handles unknown parameter type', () => { - const state = makeState() - const result = parseParameterValue('0.1', { parameterType: 'unknown', value: 'raw' } as any, state) - expect(result.actionType).toBeUndefined() - expect(result.value).toBe('raw') - }) -}) From 87dcff6abae1c75c0155dcb6ea0995a638f05351 Mon Sep 17 00:00:00 2001 From: Greaple Date: Thu, 13 Aug 2026 16:01:26 +0200 Subject: [PATCH 4/7] little bug fix --- tsconfig.json | 5 +---- yarn.lock | 6 +++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index 245d316..c185ea2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,9 +4,6 @@ "exclude": ["node_modules/**", "src/**/*spec.ts", "src/**/__tests__/*", "src/**/__mocks__/*"], "compilerOptions": { "outDir": "./dist", - "baseUrl": "./", - "paths": { - "*": ["./node_modules/*"] - } + "rootDir": "./" } } diff --git a/yarn.lock b/yarn.lock index 6a447e6..ce4892b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1288,7 +1288,7 @@ asn1@evs-broadcast/node-asn1: long: "npm:^3.2.0" smart-buffer: "npm:^3.0.3" tslib: "npm:^2.6.2" - checksum: 10c0/b1e3d1f926be6260e4fe063ec2982f0b2b5cec464a14c8a73defc6730210d2cc05e4d58ee7aba2c4c69666093f3476aa27a37dbd8125c950a30685e4eba507be + checksum: 10c0/ec4d56ddea48517600d2e54e96c390b3785978615a3f4037bd412f6616be43539ad46d4eb7733ee00b6cf2a1e162e5dbd750b18c412f55a577fc56b54e0521c8 languageName: node linkType: hard @@ -2823,7 +2823,7 @@ asn1@evs-broadcast/node-asn1: supports-preserve-symlinks-flag: "npm:^1.0.0" bin: resolve: bin/resolve - checksum: 10c0/55f7a298977b1aacf6dbec6dcfc81100ba98675bced193b57d3ac58ced65acc40c3a38927853cea86b7f75edb3c20e1f099a09d48b0d8ceccc25d466993eec47 + checksum: 10c0/3f9cb0d3e1f8552ed98b80a4be02d411f54fc5d844810fd2d2c57c616c3b5de006d59d5a2a28d9e0a93d4911b86a0938ba9810a240c9f828c4235dfaf38003b8 languageName: node linkType: hard @@ -3250,7 +3250,7 @@ asn1@evs-broadcast/node-asn1: bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10c0/2f25c74e65663c248fa1ade2b8459d9ce5372ff9dad07067310f132966ebec1d93f6c42f0baf77a6b6a7a91460463f708e6887013aaade22111037457c6b25df + checksum: 10c0/e71955556fa9731f96949a638473582caef8a365c78d9337b79e8c9caaf6fd1b12cd800a4388ee421a49ed7c0c9e4e326c5467f4af6581269a68ebfe0aa98c19 languageName: node linkType: hard From 3672d5f8b6a948f90e0ae5c04efcfa6191aec45a Mon Sep 17 00:00:00 2001 From: Greaple Date: Fri, 14 Aug 2026 06:16:18 +0200 Subject: [PATCH 5/7] chore: restore upstream test files --- src/actions/parameter.test.ts | 535 +++++++++++++++++++++++++++++ src/feedbacks/parameter.test.ts | 510 +++++++++++++++++++++++++++ src/index.test.ts | 413 ++++++++++++++++++++++ src/state.test.ts | 313 +++++++++++++++++ src/util.test.ts | 589 ++++++++++++++++++++++++++++++++ 5 files changed, 2360 insertions(+) create mode 100644 src/actions/parameter.test.ts create mode 100644 src/feedbacks/parameter.test.ts create mode 100644 src/index.test.ts create mode 100644 src/state.test.ts create mode 100644 src/util.test.ts diff --git a/src/actions/parameter.test.ts b/src/actions/parameter.test.ts new file mode 100644 index 0000000..0fad162 --- /dev/null +++ b/src/actions/parameter.test.ts @@ -0,0 +1,535 @@ +import { describe, it, expect, vi } from 'vitest' +import { subscribeParameterAction, learnSetValueActionOptions, setValue } from './parameter.js' + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('emberplus-connection', () => ({ + EmberClient: class {}, + Model: { + ParameterType: { + Boolean: 'boolean', + Integer: 'integer', + Real: 'real', + Enum: 'enum', + String: 'string', + }, + ElementType: { + Parameter: 'parameter', + Matrix: 'matrix', + }, + ParameterAccess: { + None: 'none', + Read: 'read', + Write: 'write', + ReadWrite: 'readWrite', + }, + }, +})) + +vi.mock('../actions.js', () => ({ + ActionId: { + SetValueString: 'setValueString', + SetValueBoolean: 'setValueBoolean', + SetValueInt: 'setValueInt', + SetValueReal: 'setValueReal', + SetValueEnum: 'setValueEnum', + }, +})) + +vi.mock('../util.js', () => ({ + resolveEventPath: vi.fn((action) => action.options.path ?? '0.1'), + calcRelativeNumber: vi.fn((value) => value + 1), + checkNumberLimits: vi.fn((value) => value), + isDefined: vi.fn((v) => v !== undefined && v !== null), + parseEscapeCharacters: vi.fn((s) => s + '_parsed'), + substituteEscapeCharacters: vi.fn((s) => s + '_sub'), +})) + +vi.mock('../state.js', () => ({ + EmberPlusState: class {}, +})) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeState(params: Record = {}) { + const parameters = new Map(Object.entries(params)) + return { + parameters, + getCurrentEnumValue: vi.fn(() => 'On'), + getEnumIndex: vi.fn(() => 1), + } as any +} + +function makeSelf(node: any = null) { + return { + registerNewParameter: vi.fn().mockResolvedValue(node), + logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, + } as any +} + +function makeEmberClient() { + return { + setValue: vi.fn().mockResolvedValue({ response: Promise.resolve() }), + } as any +} + +function makeQueue() { + return { + add: vi.fn().mockImplementation((fn: any) => fn()), + } as any +} + +function makeAction(path = '0.1', options: Record = {}) { + return { + id: 'act1', + options: { path, ...options }, + } as any +} + +function makeNode( + type = 'parameter', + paramType = 'integer', + access = 'readWrite', + overrides: Record = {}, +) { + return { + contents: { + type, + parameterType: paramType, + access, + ...overrides, + }, + } as any +} + +const ctx = {} as any + +// --------------------------------------------------------------------------- +// subscribeParameterAction +// --------------------------------------------------------------------------- + +describe('subscribeParameterAction', () => { + it('always calls registerNewParameter', async () => { + const self = makeSelf() + await subscribeParameterAction(self)(makeAction('0.1', {}), ctx) + expect(self.registerNewParameter).toHaveBeenCalledWith('0.1', false) + }) + + it('passes createVar=true when variable option is set', async () => { + const self = makeSelf() + await subscribeParameterAction(self)(makeAction('0.1', { variable: true }), ctx) + expect(self.registerNewParameter).toHaveBeenCalledWith('0.1', true) + }) + + it('passes createVar=true when toggle option is set', async () => { + const self = makeSelf() + await subscribeParameterAction(self)(makeAction('0.1', { toggle: true }), ctx) + expect(self.registerNewParameter).toHaveBeenCalledWith('0.1', true) + }) + + it('passes createVar=true when relative option is set', async () => { + const self = makeSelf() + await subscribeParameterAction(self)(makeAction('0.1', { relative: true }), ctx) + expect(self.registerNewParameter).toHaveBeenCalledWith('0.1', true) + }) + + it('passes createVar=true when asEnum option is set', async () => { + const self = makeSelf() + await subscribeParameterAction(self)(makeAction('0.1', { asEnum: true }), ctx) + expect(self.registerNewParameter).toHaveBeenCalledWith('0.1', true) + }) + + it('passes createVar=false when none of the variable options are set', async () => { + const self = makeSelf() + await subscribeParameterAction(self)(makeAction('0.1', { variable: false, toggle: false }), ctx) + expect(self.registerNewParameter).toHaveBeenCalledWith('0.1', false) + }) +}) + +// --------------------------------------------------------------------------- +// learnSetValueActionOptions +// --------------------------------------------------------------------------- + +describe('learnSetValueActionOptions', () => { + it('returns undefined when path has no parameter', async () => { + const state = makeState() + const result = await learnSetValueActionOptions( + state, + 'integer' as any, + 'setValueInt' as any, + )(makeAction('0.1'), ctx) + expect(result).toBeUndefined() + }) + + it('returns undefined when paramType does not match', async () => { + const state = makeState({ '0.1': { parameterType: 'string', value: 'hello' } }) + const result = await learnSetValueActionOptions( + state, + 'integer' as any, + 'setValueInt' as any, + )(makeAction('0.1'), ctx) + expect(result).toBeUndefined() + }) + + it('does not mutate the original action.options', async () => { + const state = makeState({ '0.1': { parameterType: 'string', value: 'hello' } }) + const action = makeAction('0.1', { value: 'original', parseEscapeChars: false }) + const originalOptions = action.options + await learnSetValueActionOptions(state, 'string' as any, 'setValueString' as any)(action, ctx) + expect(action.options).toBe(originalOptions) + expect(action.options.value).toBe('original') + }) + + it('String: sets value to raw string when parseEscapeChars is false', async () => { + const state = makeState({ '0.1': { parameterType: 'string', value: 'hello' } }) + const result = await learnSetValueActionOptions( + state, + 'string' as any, + 'setValueString' as any, + )(makeAction('0.1', { parseEscapeChars: false }), ctx) + expect(result?.value).toBe('hello') + }) + + it('String: substitutes escape chars when parseEscapeChars is true', async () => { + const state = makeState({ '0.1': { parameterType: 'string', value: 'hello' } }) + const result = await learnSetValueActionOptions( + state, + 'string' as any, + 'setValueString' as any, + )(makeAction('0.1', { parseEscapeChars: true }), ctx) + expect(result?.value).toBe('hello_sub') + }) + + it('Boolean: sets value and valueVar', async () => { + const state = makeState({ '0.1': { parameterType: 'boolean', value: true } }) + const result = await learnSetValueActionOptions( + state, + 'boolean' as any, + 'setValueBoolean' as any, + )(makeAction('0.1'), ctx) + expect(result?.value).toBe(true) + expect(result?.valueVar).toBe('true') + }) + + it('Enum: sets min to "0" when minimum is not defined', async () => { + const state = makeState({ '0.1': { parameterType: 'enum', value: 1, enumeration: 'Off\nOn' } }) + const result = await learnSetValueActionOptions(state, 'enum' as any, 'setValueEnum' as any)(makeAction('0.1'), ctx) + expect(result?.min).toBe('0') + }) + + it('Enum: sets enumValue from getCurrentEnumValue', async () => { + const state = makeState({ '0.1': { parameterType: 'enum', value: 1, enumeration: 'Off\nOn' } }) + state.getCurrentEnumValue.mockReturnValue('On') + const result = await learnSetValueActionOptions(state, 'enum' as any, 'setValueEnum' as any)(makeAction('0.1'), ctx) + expect(result?.enumValue).toBe('On') + }) + + it('Int: applies factor to value', async () => { + const state = makeState({ '0.1': { parameterType: 'integer', value: 500, factor: 100 } }) + const result = await learnSetValueActionOptions( + state, + 'integer' as any, + 'setValueInt' as any, + )(makeAction('0.1'), ctx) + expect(result?.factor).toBe('100') + expect(result?.value).toBe(5) // 500 / 100 + }) + + it('Int: sets factor to "1" when no factor on parameter', async () => { + const state = makeState({ '0.1': { parameterType: 'integer', value: 42 } }) + const result = await learnSetValueActionOptions( + state, + 'integer' as any, + 'setValueInt' as any, + )(makeAction('0.1'), ctx) + expect(result?.factor).toBe('1') + }) + + it('Real: sets value and limits from parameter', async () => { + const state = makeState({ '0.1': { parameterType: 'real', value: 3.14, minimum: 0, maximum: 10 } }) + const result = await learnSetValueActionOptions(state, 'real' as any, 'setValueReal' as any)(makeAction('0.1'), ctx) + expect(result?.value).toBe(3.14) + expect(result?.min).toBe('0') + expect(result?.max).toBe('10') + }) + + it('returns undefined for unknown actionType', async () => { + const state = makeState({ '0.1': { parameterType: 'integer', value: 1 } }) + const result = await learnSetValueActionOptions(state, 'integer' as any, 'unknown' as any)(makeAction('0.1'), ctx) + expect(result).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// setValue — node validation +// --------------------------------------------------------------------------- + +describe('setValue node validation', () => { + it('throws when registerNewParameter returns null', async () => { + const self = makeSelf(null) + const fn = setValue(self, makeEmberClient(), 'integer' as any, 'setValueInt' as any, makeState(), makeQueue()) + let threw = false + try { + await fn(makeAction('0.1'), ctx) + } catch { + threw = true + } + expect(threw).toBe(true) + }) + + it('throws when node is not a parameter type', async () => { + const self = makeSelf(makeNode('matrix', 'integer', 'readWrite')) + const fn = setValue(self, makeEmberClient(), 'integer' as any, 'setValueInt' as any, makeState(), makeQueue()) + let threw = false + try { + await fn(makeAction('0.1'), ctx) + } catch { + threw = true + } + expect(threw).toBe(true) + }) + + it('throws when node access is None', async () => { + const self = makeSelf(makeNode('parameter', 'integer', 'none')) + const fn = setValue(self, makeEmberClient(), 'integer' as any, 'setValueInt' as any, makeState(), makeQueue()) + let threw = false + try { + await fn(makeAction('0.1'), ctx) + } catch { + threw = true + } + expect(threw).toBe(true) + }) + + it('throws when node access is Read', async () => { + const self = makeSelf(makeNode('parameter', 'integer', 'read')) + const fn = setValue(self, makeEmberClient(), 'integer' as any, 'setValueInt' as any, makeState(), makeQueue()) + let threw = false + try { + await fn(makeAction('0.1'), ctx) + } catch { + threw = true + } + expect(threw).toBe(true) + }) + + it('throws when parameterType does not match', async () => { + const self = makeSelf(makeNode('parameter', 'string', 'readWrite')) + const fn = setValue(self, makeEmberClient(), 'integer' as any, 'setValueInt' as any, makeState(), makeQueue()) + let threw = false + try { + await fn(makeAction('0.1'), ctx) + } catch { + threw = true + } + expect(threw).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// setValue — String +// --------------------------------------------------------------------------- + +describe('setValue String', () => { + it('calls emberClient.setValue with raw string value', async () => { + const node = makeNode('parameter', 'string', 'readWrite') + const self = makeSelf(node) + const client = makeEmberClient() + const fn = setValue(self, client, 'string' as any, 'setValueString' as any, makeState(), makeQueue()) + await fn(makeAction('0.1', { value: 'hello', parseEscapeChars: false }), ctx) + expect(client.setValue).toHaveBeenCalledWith(node, 'hello', false) + }) + + it('calls parseEscapeCharacters when parseEscapeChars is true', async () => { + const { parseEscapeCharacters } = await import('../util.js') + const node = makeNode('parameter', 'string', 'readWrite') + const self = makeSelf(node) + const client = makeEmberClient() + const fn = setValue(self, client, 'string' as any, 'setValueString' as any, makeState(), makeQueue()) + await fn(makeAction('0.1', { value: 'hello', parseEscapeChars: true }), ctx) + expect(parseEscapeCharacters).toHaveBeenCalled() + }) +}) + +// --------------------------------------------------------------------------- +// setValue — Integer +// --------------------------------------------------------------------------- + +describe('setValue Integer', () => { + it('calls emberClient.setValue with factored integer value', async () => { + const node = makeNode('parameter', 'integer', 'readWrite') + const self = makeSelf(node) + const client = makeEmberClient() + const state = makeState({ '0.1': { minimum: 0, maximum: 1000 } }) + const fn = setValue(self, client, 'integer' as any, 'setValueInt' as any, state, makeQueue()) + await fn(makeAction('0.1', { value: 5, factor: '100' }), ctx) + expect(client.setValue).toHaveBeenCalledWith(node, 500, false) + }) + + it('returns without calling setValue when value is NaN', async () => { + const node = makeNode('parameter', 'integer', 'readWrite') + const self = makeSelf(node) + const client = makeEmberClient() + const fn = setValue(self, client, 'integer' as any, 'setValueInt' as any, makeState(), makeQueue()) + await fn(makeAction('0.1', { value: 'not-a-number' }), ctx) + expect(client.setValue.mock.calls.length).toBe(0) + }) + + it('uses valueVar when useVar is true', async () => { + const node = makeNode('parameter', 'integer', 'readWrite') + const self = makeSelf(node) + const client = makeEmberClient() + const fn = setValue(self, client, 'integer' as any, 'setValueInt' as any, makeState(), makeQueue()) + await fn(makeAction('0.1', { useVar: true, valueVar: '7', factor: '1' }), ctx) + expect(client.setValue).toHaveBeenCalledWith(node, 7, false) + }) +}) + +// --------------------------------------------------------------------------- +// setValue — Real +// --------------------------------------------------------------------------- + +describe('setValue Real', () => { + it('calls emberClient.setValue with real value', async () => { + const node = makeNode('parameter', 'real', 'readWrite') + const self = makeSelf(node) + const client = makeEmberClient() + const fn = setValue(self, client, 'real' as any, 'setValueReal' as any, makeState(), makeQueue()) + await fn(makeAction('0.1', { value: 3.14 }), ctx) + expect(client.setValue).toHaveBeenCalledWith(node, 3.14, false) + }) + + it('returns without calling setValue when value is NaN', async () => { + const node = makeNode('parameter', 'real', 'readWrite') + const self = makeSelf(node) + const client = makeEmberClient() + const fn = setValue(self, client, 'real' as any, 'setValueReal' as any, makeState(), makeQueue()) + await fn(makeAction('0.1', { value: 'not-a-number' }), ctx) + expect(client.setValue.mock.calls.length).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// setValue — Enum +// --------------------------------------------------------------------------- + +describe('setValue Enum', () => { + it('calls emberClient.setValue with numeric enum index', async () => { + const node = makeNode('parameter', 'enum', 'readWrite') + const self = makeSelf(node) + const client = makeEmberClient() + const state = makeState({ '0.1': { minimum: 0, maximum: 3 } }) + const fn = setValue(self, client, 'enum' as any, 'setValueEnum' as any, state, makeQueue()) + await fn(makeAction('0.1', { value: 2 }), ctx) + expect(client.setValue).toHaveBeenCalledWith(node, 2, false) + }) + + it('asEnum: resolves enum string to index via getEnumIndex', async () => { + const node = makeNode('parameter', 'enum', 'readWrite') + const self = makeSelf(node) + const client = makeEmberClient() + const state = makeState({ '0.1': { minimum: 0, maximum: 3 } }) + state.getEnumIndex.mockReturnValue(1) + const fn = setValue(self, client, 'enum' as any, 'setValueEnum' as any, state, makeQueue()) + await fn(makeAction('0.1', { asEnum: true, enumValue: 'On' }), ctx) + expect(client.setValue).toHaveBeenCalledWith(node, 1, false) + }) + + it('asEnum: throws when enum key is not found', async () => { + const node = makeNode('parameter', 'enum', 'readWrite') + const self = makeSelf(node) + const state = makeState({ '0.1': {} }) + state.getEnumIndex.mockReturnValue(undefined) + const fn = setValue(self, makeEmberClient(), 'enum' as any, 'setValueEnum' as any, state, makeQueue()) + let threw = false + try { + await fn(makeAction('0.1', { asEnum: true, enumValue: 'Unknown' }), ctx) + } catch { + threw = true + } + expect(threw).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// setValue — Boolean +// --------------------------------------------------------------------------- + +describe('setValue Boolean', () => { + it('calls emberClient.setValue with true', async () => { + const node = makeNode('parameter', 'boolean', 'readWrite') + const self = makeSelf(node) + const client = makeEmberClient() + const fn = setValue(self, client, 'boolean' as any, 'setValueBoolean' as any, makeState(), makeQueue()) + await fn(makeAction('0.1', { value: true }), ctx) + expect(client.setValue).toHaveBeenCalledWith(node, true, false) + }) + + it('toggle: inverts current parameter value', async () => { + const node = makeNode('parameter', 'boolean', 'readWrite') + const self = makeSelf(node) + const client = makeEmberClient() + const state = makeState({ '0.1': { value: true } }) + const fn = setValue(self, client, 'boolean' as any, 'setValueBoolean' as any, state, makeQueue()) + await fn(makeAction('0.1', { toggle: true }), ctx) + expect(client.setValue).toHaveBeenCalledWith(node, false, false) + }) + + it('useVar: parses "true" string as true', async () => { + const node = makeNode('parameter', 'boolean', 'readWrite') + const self = makeSelf(node) + const client = makeEmberClient() + const fn = setValue(self, client, 'boolean' as any, 'setValueBoolean' as any, makeState(), makeQueue()) + await fn(makeAction('0.1', { useVar: true, valueVar: 'true' }), ctx) + expect(client.setValue).toHaveBeenCalledWith(node, true, false) + }) + + it('useVar: parses "false" string as false', async () => { + const node = makeNode('parameter', 'boolean', 'readWrite') + const self = makeSelf(node) + const client = makeEmberClient() + const fn = setValue(self, client, 'boolean' as any, 'setValueBoolean' as any, makeState(), makeQueue()) + await fn(makeAction('0.1', { useVar: true, valueVar: 'false' }), ctx) + expect(client.setValue).toHaveBeenCalledWith(node, false, false) + }) + + it('useVar: parses "on" as true', async () => { + const node = makeNode('parameter', 'boolean', 'readWrite') + const self = makeSelf(node) + const client = makeEmberClient() + const fn = setValue(self, client, 'boolean' as any, 'setValueBoolean' as any, makeState(), makeQueue()) + await fn(makeAction('0.1', { useVar: true, valueVar: 'on' }), ctx) + expect(client.setValue).toHaveBeenCalledWith(node, true, false) + }) + + it('useVar: parses "0" as false', async () => { + const node = makeNode('parameter', 'boolean', 'readWrite') + const self = makeSelf(node) + const client = makeEmberClient() + const fn = setValue(self, client, 'boolean' as any, 'setValueBoolean' as any, makeState(), makeQueue()) + await fn(makeAction('0.1', { useVar: true, valueVar: '0' }), ctx) + expect(client.setValue).toHaveBeenCalledWith(node, false, false) + }) +}) + +// --------------------------------------------------------------------------- +// setValue — relative values +// --------------------------------------------------------------------------- + +describe('setValue relative', () => { + it('Integer: calls calcRelativeNumber when relative is true', async () => { + const { calcRelativeNumber } = await import('../util.js') + vi.mocked(calcRelativeNumber).mockReturnValue(6) + const node = makeNode('parameter', 'integer', 'readWrite') + const self = makeSelf(node) + const client = makeEmberClient() + const fn = setValue(self, client, 'integer' as any, 'setValueInt' as any, makeState(), makeQueue()) + await fn(makeAction('0.1', { value: 5, factor: '1', relative: true, min: '0', max: '10' }), ctx) + expect(calcRelativeNumber).toHaveBeenCalled() + expect(client.setValue).toHaveBeenCalledWith(node, 6, false) + }) +}) diff --git a/src/feedbacks/parameter.test.ts b/src/feedbacks/parameter.test.ts new file mode 100644 index 0000000..16fa6ee --- /dev/null +++ b/src/feedbacks/parameter.test.ts @@ -0,0 +1,510 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + subscribeParameterFeedback, + unsubscribeParameterFeedback, + learnParameterFeedbackOptions, + resolveBooleanFeedback, + parameterFeedbackCallback, + parameterValueFeedbackCallback, +} from './parameter.js' +import { FeedbackId } from '../feedback.js' +import { compareNumber, parseEscapeCharacters } from '../util.js' + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('emberplus-connection', () => ({ + Model: { + ParameterType: { + Boolean: 'boolean', + Integer: 'integer', + Real: 'real', + Enum: 'enum', + String: 'string', + }, + }, +})) + +vi.mock('emberplus-connection/dist/model', () => ({ + ParameterType: { + Boolean: 'boolean', + Integer: 'integer', + Real: 'real', + Enum: 'enum', + String: 'string', + }, +})) + +vi.mock('../feedback', () => ({ + FeedbackId: { + Boolean: 'boolean', + Parameter: 'parameter', + String: 'string', + ENUM: 'enum', + }, +})) + +vi.mock('../util', () => ({ + resolveEventPath: vi.fn((feedback) => feedback.options.path ?? '0.1'), + compareNumber: vi.fn(() => true), + parseEscapeCharacters: vi.fn((s) => s), + substituteEscapeCharacters: vi.fn((s) => s + '_sub'), + NumberComparitor: { + Equal: 'eq', + NotEqual: 'ne', + LessThan: 'lt', + LessThanEqual: 'lte', + GreaterThan: 'gt', + GreaterThanEqual: 'gte', + }, +})) + +vi.mock('../state', () => ({ + EmberPlusState: class { + parameters = new Map() + addIdToPathMap = vi.fn() + getCurrentEnumValue = vi.fn() + getParameter = vi.fn() + }, +})) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface MockState { + parameters: Map + addIdToPathMap: ReturnType + getCurrentEnumValue: ReturnType +} + +function makeState(overrides: Partial = {}): any { + return { + parameters: new Map(), + addIdToPathMap: vi.fn(), + getCurrentEnumValue: vi.fn(() => 'On'), + ...overrides, + } +} + +function makeSelf(overrides: Record = {}) { + return { + registerNewParameter: vi.fn().mockResolvedValue(false), + ...overrides, + } as any +} + +function makeFeedback(path = '0.1', options: Record = {}) { + return { + id: 'fb1', + options: { path, ...options }, + } as any +} + +const ctx = {} as any + +// --------------------------------------------------------------------------- +// subscribeParameterFeedback +// --------------------------------------------------------------------------- + +describe('subscribeParameterFeedback', () => { + it('calls registerNewParameter with the resolved path', async () => { + const state = makeState() + const self = makeSelf({ registerNewParameter: vi.fn().mockResolvedValue(true) }) + const fn = subscribeParameterFeedback(state, self) + await fn(makeFeedback('0.1'), ctx) + expect(self.registerNewParameter).toHaveBeenCalledWith('0.1', true) + }) + + it('adds feedback id to path map when registerNewParameter returns true', async () => { + const state = makeState() + const self = makeSelf({ registerNewParameter: vi.fn().mockResolvedValue(true) }) + await subscribeParameterFeedback(state, self)(makeFeedback('0.1'), ctx) + expect(state.addIdToPathMap).toHaveBeenCalledWith('fb1', '0.1') + }) + + it('does not add to path map when registerNewParameter returns false', async () => { + const state = makeState() + const self = makeSelf({ registerNewParameter: vi.fn().mockResolvedValue(false) }) + await subscribeParameterFeedback(state, self)(makeFeedback('0.1'), ctx) + expect(state.addIdToPathMap.mock.calls.length).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// unsubscribeParameterFeedback +// --------------------------------------------------------------------------- + +describe('unsubscribeParameterFeedback', () => { + it('maps feedback id to empty string to remove it', async () => { + const state = makeState() + await unsubscribeParameterFeedback(state)(makeFeedback('0.1'), ctx) + expect(state.addIdToPathMap).toHaveBeenCalledWith('fb1', '') + }) +}) + +// --------------------------------------------------------------------------- +// learnParameterFeedbackOptions +// --------------------------------------------------------------------------- + +describe('learnParameterFeedbackOptions', () => { + it('returns undefined when path has no parameter', async () => { + const state = makeState() + const fn = learnParameterFeedbackOptions(state, FeedbackId.String) + const result = await fn(makeFeedback('0.1'), ctx) + expect(result).toBeUndefined() + }) + + it('returns undefined when parameter value is undefined', async () => { + const state = makeState() + state.parameters.set('0.1', { value: undefined }) + const result = await learnParameterFeedbackOptions(state, FeedbackId.String)(makeFeedback('0.1'), ctx) + expect(result).toBeUndefined() + }) + + it('String: sets options.value to the raw string value', async () => { + const state = makeState() + state.parameters.set('0.1', { value: 'hello' }) + const feedback = makeFeedback('0.1', { parseEscapeChars: false }) + const result = await learnParameterFeedbackOptions(state, FeedbackId.String)(feedback, ctx) + expect(result?.value).toBe('hello') + }) + + it('String: substitutes escape characters when parseEscapeChars is true', async () => { + const state = makeState() + state.parameters.set('0.1', { value: 'hello' }) + const feedback = makeFeedback('0.1', { parseEscapeChars: true }) + const result = await learnParameterFeedbackOptions(state, FeedbackId.String)(feedback, ctx) + expect(result?.value).toBe('hello_sub') + }) + + it('ENUM: returns undefined when enumVal is empty string', async () => { + const state = makeState() + state.parameters.set('0.1', { value: 0 }) + state.getCurrentEnumValue.mockReturnValue('') + const result = await learnParameterFeedbackOptions(state, FeedbackId.ENUM)(makeFeedback('0.1'), ctx) + expect(result).toBeUndefined() + }) + + it('ENUM: sets options.value to the current enum string', async () => { + const state = makeState() + state.parameters.set('0.1', { value: 1 }) + state.getCurrentEnumValue.mockReturnValue('On') + const result = await learnParameterFeedbackOptions(state, FeedbackId.ENUM)(makeFeedback('0.1'), ctx) + expect(result?.value).toBe('On') + }) + + it('Parameter: returns undefined when value is not a number', async () => { + const state = makeState() + state.parameters.set('0.1', { value: 'not-a-number' }) + const result = await learnParameterFeedbackOptions(state, FeedbackId.Parameter)(makeFeedback('0.1'), ctx) + expect(result).toBeUndefined() + }) + + it('Parameter: sets value, valueVar, and asInt correctly for Integer type', async () => { + const state = makeState() + state.parameters.set('0.1', { value: 42, parameterType: 'integer', factor: 100 }) + const feedback = makeFeedback('0.1', { factor: '1' }) + const result = await learnParameterFeedbackOptions(state, FeedbackId.Parameter)(feedback, ctx) + expect(result?.value).toBe(42) + expect(result?.valueVar).toBe('42') + expect(result?.asInt).toBe(true) + expect(result?.factor).toBe('100') + }) + + it('Parameter: asInt is false for Real type', async () => { + const state = makeState() + state.parameters.set('0.1', { value: 3.14, parameterType: 'real' }) + const result = await learnParameterFeedbackOptions(state, FeedbackId.Parameter)(makeFeedback('0.1'), ctx) + expect(result?.asInt).toBe(false) + }) + + it('returns undefined for unknown feedbackType', async () => { + const state = makeState() + state.parameters.set('0.1', { value: 1 }) + const result = await learnParameterFeedbackOptions(state, 'unknown' as any)(makeFeedback('0.1'), ctx) + expect(result).toBeUndefined() + }) + + it('does not mutate the original feedback.options object', async () => { + const state = makeState() + state.parameters.set('0.1', { value: 'hello' }) + const feedback = makeFeedback('0.1', { parseEscapeChars: false, value: 'original' }) + const originalOptions = feedback.options + await learnParameterFeedbackOptions(state, FeedbackId.String)(feedback, ctx) + expect(feedback.options).toBe(originalOptions) + expect(feedback.options.value).toBe('original') + }) +}) + +// --------------------------------------------------------------------------- +// resolveBooleanFeedback +// --------------------------------------------------------------------------- + +describe('resolveBooleanFeedback', () => { + beforeEach(() => { + vi.mocked(compareNumber).mockClear() + vi.mocked(compareNumber).mockReturnValue(true) + }) + + it('Boolean: returns true when parameter value is truthy', async () => { + const state = makeState() + state.parameters.set('0.1', { value: true }) + const result = await resolveBooleanFeedback(state, 'boolean' as any, '0.1') + expect(result).toBe(true) + }) + + it('Boolean: returns false when parameter value is falsy', async () => { + const state = makeState() + state.parameters.set('0.1', { value: false }) + const result = await resolveBooleanFeedback(state, 'boolean' as any, '0.1') + expect(result).toBe(false) + }) + + it('Real: delegates to compareNumber', async () => { + vi.mocked(compareNumber).mockReturnValue(false) + const state = makeState() + state.parameters.set('0.1', { value: 3.14 }) + const result = await resolveBooleanFeedback(state, 'real' as any, '0.1', 3.14) + expect(compareNumber).toHaveBeenCalled() + expect(result).toBe(false) + }) + + it('Integer: applies factor to the comparison value', async () => { + vi.mocked(compareNumber).mockReturnValue(true) + const state = makeState() + state.parameters.set('0.1', { value: 500 }) + await resolveBooleanFeedback(state, 'integer' as any, '0.1', 5, { + comparitor: 'eq' as any, + factor: '100', + }) + expect(vi.mocked(compareNumber).mock.calls[0][0]).toBe(500) // Math.floor(5 * 100) + }) + + it('Integer: treats NaN factor as 1', async () => { + vi.mocked(compareNumber).mockReturnValue(true) + const state = makeState() + state.parameters.set('0.1', { value: 5 }) + await resolveBooleanFeedback(state, 'integer' as any, '0.1', 5, { + comparitor: 'eq' as any, + factor: 'not-a-number', + }) + expect(vi.mocked(compareNumber).mock.calls[0][0]).toBe(5) // Math.floor(5 * 1) + }) + + it('Enum: returns true when current enum value matches', async () => { + const state = makeState() + state.getCurrentEnumValue.mockReturnValue('On') + const result = await resolveBooleanFeedback(state, 'enum' as any, '0.1', 'On') + expect(result).toBe(true) + }) + + it('Enum: returns false when current enum value does not match', async () => { + const state = makeState() + state.getCurrentEnumValue.mockReturnValue('Off') + const result = await resolveBooleanFeedback(state, 'enum' as any, '0.1', 'On') + expect(result).toBe(false) + }) + + it('String: compares parsed value against parameter string value', async () => { + const state = makeState() + state.parameters.set('0.1', { value: 'hello' }) + const result = await resolveBooleanFeedback(state, 'string' as any, '0.1', 'hello', { parse: false }) + expect(result).toBe(true) + }) + + it('String: returns false when values do not match', async () => { + const state = makeState() + state.parameters.set('0.1', { value: 'hello' }) + const result = await resolveBooleanFeedback(state, 'string' as any, '0.1', 'world', { parse: false }) + expect(result).toBe(false) + }) + + it('String: calls parseEscapeCharacters when parse is true', async () => { + const state = makeState() + state.parameters.set('0.1', { value: 'hello' }) + await resolveBooleanFeedback(state, 'string' as any, '0.1', 'hello', { parse: true }) + expect(parseEscapeCharacters).toHaveBeenCalled() + }) + + it('default: falls through to String comparison', async () => { + const state = makeState() + state.parameters.set('0.1', { value: 'test' }) + const result = await resolveBooleanFeedback(state, 'unknown' as any, '0.1', 'test', { parse: false }) + expect(result).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// parameterFeedbackCallback +// --------------------------------------------------------------------------- + +describe('parameterFeedbackCallback', () => { + it('calls registerNewParameter on every invocation', async () => { + const state = makeState() + const self = makeSelf() + const fn = parameterFeedbackCallback(self, state, FeedbackId.Boolean) + await fn(makeFeedback('0.1'), ctx) + expect(self.registerNewParameter).toHaveBeenCalledWith('0.1', true) + }) + + it('returns false when path is not in state.parameters', async () => { + const state = makeState() + const self = makeSelf() + const result = await parameterFeedbackCallback(self, state, FeedbackId.Boolean)(makeFeedback('0.1'), ctx) + expect(result).toBe(false) + }) + + it('does not call registerNewParameter a second time in the else branch', async () => { + const state = makeState() + const self = makeSelf({ registerNewParameter: vi.fn().mockResolvedValue(false) }) + await parameterFeedbackCallback(self, state, FeedbackId.Boolean)(makeFeedback('0.1'), ctx) + expect(self.registerNewParameter.mock.calls.length).toBe(1) + }) + + it('Boolean: resolves feedback when parameter exists', async () => { + const state = makeState() + state.parameters.set('0.1', { value: true }) + const self = makeSelf() + const result = await parameterFeedbackCallback(self, state, FeedbackId.Boolean)(makeFeedback('0.1'), ctx) + expect(typeof result).toBe('boolean') + }) + + it('ENUM: passes enum value string from options', async () => { + const state = makeState() + state.parameters.set('0.1', { value: 1 }) + state.getCurrentEnumValue.mockReturnValue('On') + const self = makeSelf() + const feedback = makeFeedback('0.1', { value: 'On' }) + const result = await parameterFeedbackCallback(self, state, FeedbackId.ENUM)(feedback, ctx) + expect(result).toBe(true) + }) + + it('Parameter: uses Integer type when asInt is true', async () => { + vi.mocked(compareNumber).mockReturnValue(true) + const state = makeState() + state.parameters.set('0.1', { value: 5 }) + const self = makeSelf() + const feedback = makeFeedback('0.1', { + asInt: true, + value: 5, + valueVar: '5', + useVar: false, + comparitor: 'eq', + factor: '1', + }) + const result = await parameterFeedbackCallback(self, state, FeedbackId.Parameter)(feedback, ctx) + expect(result).toBe(true) + }) + + it('Parameter: uses Real type when asInt is false', async () => { + vi.mocked(compareNumber).mockReturnValue(true) + const state = makeState() + state.parameters.set('0.1', { value: 3.14 }) + const self = makeSelf() + const feedback = makeFeedback('0.1', { + asInt: false, + value: 3.14, + useVar: false, + comparitor: 'eq', + factor: '1', + }) + const result = await parameterFeedbackCallback(self, state, FeedbackId.Parameter)(feedback, ctx) + expect(result).toBe(true) + }) + + it('Parameter: uses valueVar when useVar is true', async () => { + const state = makeState() + state.parameters.set('0.1', { value: 5 }) + const self = makeSelf() + const feedback = makeFeedback('0.1', { + asInt: true, + value: 0, + valueVar: '5', + useVar: true, + comparitor: 'eq', + factor: '1', + }) + await parameterFeedbackCallback(self, state, FeedbackId.Parameter)(feedback, ctx) + expect(vi.mocked(compareNumber).mock.calls.at(-1)?.[0]).toBe(5) // Math.floor('5' * 1) + }) + + it('String: passes value and parse flag from options', async () => { + const state = makeState() + state.parameters.set('0.1', { value: 'hello' }) + const self = makeSelf() + const feedback = makeFeedback('0.1', { value: 'hello', parseEscapeChars: false }) + const result = await parameterFeedbackCallback(self, state, FeedbackId.String)(feedback, ctx) + expect(result).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// parameterValueFeedbackCallback +// --------------------------------------------------------------------------- + +describe('parameterValueFeedbackCallback', () => { + it('returns null when path is not in state.parameters', async () => { + const state = makeState() + const self = makeSelf() + const result = await parameterValueFeedbackCallback(self, state)(makeFeedback('0.1'), ctx) + expect(result).toBeNull() + }) + + it('returns the raw value for non-Integer types', async () => { + const state = makeState() + state.parameters.set('0.1', { value: 'hello', parameterType: 'string' }) + const self = makeSelf() + const result = await parameterValueFeedbackCallback(self, state)(makeFeedback('0.1'), ctx) + expect(result).toBe('hello') + }) + + it('applies factor division for Integer type', async () => { + const state = makeState() + state.parameters.set('0.1', { value: 500, parameterType: 'integer', factor: 100 }) + const self = makeSelf() + const result = await parameterValueFeedbackCallback(self, state)(makeFeedback('0.1'), ctx) + expect(result).toBe(5) + }) + + it('defaults factor to 1 when not set for Integer type', async () => { + const state = makeState() + state.parameters.set('0.1', { value: 42, parameterType: 'integer' }) + const self = makeSelf() + const result = await parameterValueFeedbackCallback(self, state)(makeFeedback('0.1'), ctx) + expect(result).toBe(42) + }) + + it('converts Buffer values to an Array', async () => { + const state = makeState() + const buf = Buffer.from([1, 2, 3]) + state.parameters.set('0.1', { value: buf, parameterType: 'string' }) + const self = makeSelf() + const result = await parameterValueFeedbackCallback(self, state)(makeFeedback('0.1'), ctx) + expect(Array.isArray(result)).toBe(true) + expect(result).toEqual([1, 2, 3]) + }) + + it('returns null when parameter value is null', async () => { + const state = makeState() + state.parameters.set('0.1', { value: null, parameterType: 'string' }) + const self = makeSelf() + const result = await parameterValueFeedbackCallback(self, state)(makeFeedback('0.1'), ctx) + expect(result).toBeNull() + }) + + it('calls registerNewParameter with false (read-only)', async () => { + const state = makeState() + const self = makeSelf() + await parameterValueFeedbackCallback(self, state)(makeFeedback('0.1'), ctx) + expect(self.registerNewParameter).toHaveBeenCalledWith('0.1', false) + }) + + it('does not call registerNewParameter a second time in the else branch', async () => { + const state = makeState() + const self = makeSelf({ registerNewParameter: vi.fn().mockResolvedValue(false) }) + await parameterValueFeedbackCallback(self, state)(makeFeedback('0.1'), ctx) + expect(self.registerNewParameter.mock.calls.length).toBe(1) + }) +}) diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..6dc44b8 --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,413 @@ +import { describe, it, expect, vi } from 'vitest' +import { EmberPlusInstance } from './index.js' +import { EmberPlusState } from './state.js' +import { ElementType, ParameterType } from 'emberplus-connection/dist/model' +import { LoggerLevel } from './logger.js' + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('@companion-module/base', () => ({ + InstanceBase: class { + checkFeedbacks = vi.fn() + checkFeedbacksById = vi.fn() + setActionDefinitions = vi.fn() + setFeedbackDefinitions = vi.fn() + setVariableDefinitions = vi.fn() + setVariableValues = vi.fn() + setPresetDefinitions = vi.fn() + recordAction = vi.fn() + log = vi.fn() + }, + InstanceStatus: { + Ok: 'ok', + Connecting: 'connecting', + ConnectionFailure: 'connection_failure', + BadConfig: 'bad_config', + UnknownWarning: 'unknown_warning', + Disconnected: 'disconnected', + }, + runEntrypoint: vi.fn(), +})) + +vi.mock('emberplus-connection', () => ({ + EmberClient: class { + on = vi.fn() + connect = vi.fn().mockResolvedValue(undefined) + disconnect = vi.fn().mockResolvedValue(undefined) + discard = vi.fn() + removeAllListeners = vi.fn() + getDirectory = vi.fn().mockResolvedValue({ response: Promise.resolve() }) + getElementByPath = vi.fn().mockResolvedValue(undefined) + tree = {} + }, + Model: { + ParameterType: { + Boolean: 'boolean', + Integer: 'integer', + Real: 'real', + Enum: 'enum', + String: 'string', + }, + }, +})) + +vi.mock('emberplus-connection/dist/model', () => ({ + ElementType: { Parameter: 'parameter', Node: 'node' }, + ParameterType: { + Boolean: 'boolean', + Integer: 'integer', + Real: 'real', + Enum: 'enum', + String: 'string', + }, +})) + +vi.mock('./actions', () => ({ GetActionsList: vi.fn().mockReturnValue({}) })) +vi.mock('./feedback', () => ({ + GetFeedbacksList: vi.fn().mockReturnValue({}), + FeedbackId: {}, +})) +vi.mock('./presets', () => ({ GetPresetsList: vi.fn().mockReturnValue({}) })) +vi.mock('./variables', () => ({ GetVariablesList: vi.fn().mockReturnValue([]) })) +vi.mock('./config', () => ({ + GetConfigFields: vi.fn().mockReturnValue([]), +})) +vi.mock('./upgrades', () => ({ UpgradeScripts: [] })) + +vi.mock('./logger.js', () => ({ + Logger: class { + info = vi.fn() + warn = vi.fn() + error = vi.fn() + debug = vi.fn() + console = vi.fn() + }, + LoggerLevel: { Information: 'information', Warning: 'warning', Error: 'error' }, +})) + +vi.mock('./status.js', () => ({ + StatusManager: class { + updateStatus = vi.fn() + destroy = vi.fn() + }, +})) + +vi.mock('./util', () => ({ + sanitiseVariableId: (id: string) => id.replaceAll(/[^a-zA-Z0-9-_.]/gm, '_'), + parseBonjourHost: vi.fn().mockReturnValue(['192.168.0.1', 9000]), + hasConnectionChanged: vi.fn().mockReturnValue(false), + recordParameterAction: vi.fn(), + parseParameterValue: vi.fn().mockReturnValue({ actionType: 'setValueInt', value: 42 }), +})) + +vi.mock('p-queue', () => ({ + default: class { + add = vi.fn().mockImplementation((fn: any) => fn()) + clear = vi.fn() + }, +})) + +vi.mock('es-toolkit', () => ({ + throttle: vi.fn().mockImplementation((fn) => { + const wrapped = (...args: any[]) => fn(...args) + wrapped.cancel = vi.fn() + return wrapped + }), + debounce: vi.fn().mockImplementation((fn) => { + const wrapped = (...args: any[]) => fn(...args) + wrapped.cancel = vi.fn() + return wrapped + }), +})) + +// --------------------------------------------------------------------------- +// Factory — creates a fresh instance with state wired in +// --------------------------------------------------------------------------- + +function makeInstance(): EmberPlusInstance { + const instance = new EmberPlusInstance('test-id') + // Wire a fresh state + ;(instance as any).state = new EmberPlusState() + // Provide a default config + ;(instance as any).config = { + host: '192.168.0.1', + port: 9000, + factor: true, + logging: LoggerLevel.Information, + } + return instance +} + +// --------------------------------------------------------------------------- +// setupMatrices (via private access) +// --------------------------------------------------------------------------- + +describe('setupMatrices', () => { + it('populates state.matrices array from matricesString', () => { + const instance = makeInstance() + ;(instance as any).config.matricesString = '0.1.0, 0.2.0, 0.3.0' + ;(instance as any).setupMatrices() + expect((instance as any).state.matrices).toEqual(['0.1.0', '0.2.0', '0.3.0']) + }) + + it('converts slashes to dots', () => { + const instance = makeInstance() + ;(instance as any).config.matricesString = '0/1/0, 0/2/0' + ;(instance as any).setupMatrices() + expect((instance as any).state.matrices.includes('0.1.0')).toBe(true) + }) + + it('filters out empty entries', () => { + const instance = makeInstance() + ;(instance as any).config.matricesString = '0.1.0,, , 0.2.0' + ;(instance as any).setupMatrices() + expect((instance as any).state.matrices.length).toBe(2) + }) + + it('resets selected source and target when matrices exist', () => { + const instance = makeInstance() + ;(instance as any).state.selected = { source: 5, target: 3, matrix: 0 } + ;(instance as any).config.matricesString = '0.1.0' + ;(instance as any).setupMatrices() + expect((instance as any).state.selected.source).toBe(-1) + expect((instance as any).state.selected.target).toBe(-1) + }) + + it('does nothing when matricesString is undefined', () => { + const instance = makeInstance() + ;(instance as any).config.matricesString = undefined + ;(instance as any).setupMatrices() + expect((instance as any).state.matrices.length).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// setupMonitoredParams (via private access) +// --------------------------------------------------------------------------- + +describe('setupMonitoredParams', () => { + it('populates monitoredParameters from monitoredParametersString', () => { + const instance = makeInstance() + ;(instance as any).config.monitoredParametersString = '0.1.2, 0.3.4' + ;(instance as any).setupMonitoredParams() + expect((instance as any).state.monitoredParameters).toEqual(new Set(['0.1.2', '0.3.4'])) + }) + + it('converts slashes to dots', () => { + const instance = makeInstance() + ;(instance as any).config.monitoredParametersString = '0/1/2' + ;(instance as any).setupMonitoredParams() + expect((instance as any).state.monitoredParameters.has('0.1.2')).toBe(true) + }) + + it('filters out empty entries', () => { + const instance = makeInstance() + ;(instance as any).config.monitoredParametersString = '0.1.2,, ,' + ;(instance as any).setupMonitoredParams() + expect((instance as any).state.monitoredParameters.size).toBe(1) + }) + + it('sorts parameters', () => { + const instance = makeInstance() + ;(instance as any).config.monitoredParametersString = '0.3, 0.1, 0.2' + ;(instance as any).setupMonitoredParams() + const result = [...(instance as any).state.monitoredParameters] + expect(result).toEqual([...result].sort()) + }) + + it('results in an empty set when monitoredParametersString is undefined', () => { + const instance = makeInstance() + ;(instance as any).config.monitoredParametersString = undefined + ;(instance as any).setupMonitoredParams() + expect((instance as any).state.monitoredParameters.size).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// updateCompanionBits +// --------------------------------------------------------------------------- + +describe('updateCompanionBits', () => { + it('calls all four setters when all options are true', () => { + const instance = makeInstance() + instance.updateCompanionBits({ + updateActions: true, + updateFeedbacks: true, + updatePresets: true, + updateVariables: true, + }) + expect((instance as any).setActionDefinitions).toHaveBeenCalled() + expect((instance as any).setFeedbackDefinitions).toHaveBeenCalled() + expect((instance as any).setVariableDefinitions).toHaveBeenCalled() + expect((instance as any).setPresetDefinitions).toHaveBeenCalled() + }) + + it('skips setters for false options', () => { + const instance = makeInstance() + instance.updateCompanionBits({ + updateActions: false, + updateFeedbacks: false, + updatePresets: false, + updateVariables: false, + }) + expect((instance as any).setActionDefinitions.mock.calls.length).toBe(0) + expect((instance as any).setFeedbackDefinitions.mock.calls.length).toBe(0) + expect((instance as any).setVariableDefinitions.mock.calls.length).toBe(0) + expect((instance as any).setPresetDefinitions.mock.calls.length).toBe(0) + }) + + it('only calls variable definitions when only updateVariables is true', () => { + const instance = makeInstance() + instance.updateCompanionBits({ + updateVariables: true, + updateActions: false, + updateFeedbacks: false, + updatePresets: false, + }) + expect((instance as any).setVariableDefinitions).toHaveBeenCalled() + expect((instance as any).setActionDefinitions.mock.calls.length).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// handleStartStopRecordActions +// --------------------------------------------------------------------------- + +describe('handleStartStopRecordActions', () => { + it('sets isRecordingActions to true', () => { + const instance = makeInstance() + instance.handleStartStopRecordActions(true) + expect((instance as any).isRecordingActions).toBe(true) + }) + + it('sets isRecordingActions to false', () => { + const instance = makeInstance() + ;(instance as any).isRecordingActions = true + instance.handleStartStopRecordActions(false) + expect((instance as any).isRecordingActions).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// handleChangedValue +// --------------------------------------------------------------------------- + +describe('handleChangedValue', () => { + function makeNode(overrides: Record = {}) { + return { + contents: { + type: ElementType.Parameter, + parameterType: ParameterType.Integer, + value: 10, + ...overrides, + }, + } as any + } + + it('ignores non-Parameter nodes', async () => { + const instance = makeInstance() + await instance.handleChangedValue('0.1', { contents: { type: ElementType.Node } }) + expect((instance as any).setVariableValues.mock.calls.length).toBe(0) + }) + + it('queues feedback check for registered feedback ids', async () => { + const instance = makeInstance() + ;(instance as any).state.addIdToPathMap('fb1', '0.1') + ;(instance as any).throttledFeedbackChecksVariableUpdates = vi.fn() + await instance.handleChangedValue('0.1', makeNode()) + expect((instance as any).feedbacksToCheck.has('fb1')).toBe(true) + }) + + it('calls recordParameterAction when recording is active', async () => { + const { recordParameterAction } = await import('./util.js') + const instance = makeInstance() + ;(instance as any).isRecordingActions = true + await instance.handleChangedValue('0.1', makeNode()) + expect(recordParameterAction).toHaveBeenCalled() + }) + + it('does not call recordParameterAction when not recording', async () => { + const { recordParameterAction } = await import('./util.js') + vi.mocked(recordParameterAction).mockClear() + const instance = makeInstance() + ;(instance as any).isRecordingActions = false + await instance.handleChangedValue('0.1', makeNode()) + expect(vi.mocked(recordParameterAction).mock.calls.length).toBe(0) + }) + + it('skips processing when parseParameterValue returns no actionType', async () => { + const util = await import('./util.js') + vi.mocked(util.parseParameterValue).mockReturnValueOnce({ actionType: undefined, value: 0 }) + const instance = makeInstance() + await instance.handleChangedValue('0.1', makeNode()) + expect((instance as any).setVariableValues.mock.calls.length).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// updateFeedbacksAndVariables +// --------------------------------------------------------------------------- + +describe('updateFeedbacksAndVariables', () => { + it('stores factorised value in variableValueUpdates for Integer when factor=true', () => { + const instance = makeInstance() + ;(instance as any).throttledFeedbackChecksVariableUpdates = vi.fn() + ;(instance as any).config.factor = true + ;(instance as any).updateFeedbacksAndVariables('0.1', ParameterType.Integer, 5, 10) + expect((instance as any).variableValueUpdates['0.1']).toBe(5) + }) + + it('stores raw value in variableValueUpdates for Integer when factor=false', () => { + const instance = makeInstance() + ;(instance as any).throttledFeedbackChecksVariableUpdates = vi.fn() + ;(instance as any).config.factor = false + ;(instance as any).updateFeedbacksAndVariables('0.1', ParameterType.Integer, 5, 10) + expect((instance as any).variableValueUpdates['0.1']).toBe(10) + }) + + it('stores _ENUM variable for Enum type', () => { + const instance = makeInstance() + ;(instance as any).throttledFeedbackChecksVariableUpdates = vi.fn() + ;(instance as any).state.updateParameterMap('0.1', { + contents: { type: ElementType.Parameter, parameterType: ParameterType.Enum, enumeration: 'Off\nOn', value: 1 }, + } as any) + ;(instance as any).updateFeedbacksAndVariables('0.1', ParameterType.Enum, 1, 1) + expect((instance as any).variableValueUpdates['0.1_ENUM']).toBe('On') + }) + + it('sanitises path to create variable id', () => { + const instance = makeInstance() + ;(instance as any).throttledFeedbackChecksVariableUpdates = vi.fn() + ;(instance as any).updateFeedbacksAndVariables('0/1/2', ParameterType.Integer, 5, 5) + expect((instance as any).variableValueUpdates['0_1_2']).toBeDefined() + }) +}) + +// --------------------------------------------------------------------------- +// getConfigFields +// --------------------------------------------------------------------------- + +describe('getConfigFields', () => { + it('returns an array', () => { + const instance = makeInstance() + expect(Array.isArray(instance.getConfigFields())).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// destroy +// --------------------------------------------------------------------------- + +describe('destroy', () => { + it('cancels throttles, clears queue, and destroys client without throwing', async () => { + const instance = makeInstance() + // Provide a mock emberClient so destroyEmberClient does not throw on undefined check + ;(instance as any).emberClient = { + removeAllListeners: vi.fn(), + discard: vi.fn(), + } + await instance.destroy() + }) +}) diff --git a/src/state.test.ts b/src/state.test.ts new file mode 100644 index 0000000..13fa827 --- /dev/null +++ b/src/state.test.ts @@ -0,0 +1,313 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { EmberPlusState } from './state.js' +import { ElementType } from 'emberplus-connection/dist/model' + +vi.mock('emberplus-connection/dist/model', () => ({ + ElementType: { + Parameter: 'parameter', + Node: 'node', + }, +})) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeNode(overrides: Record = {}) { + return { + contents: { + type: ElementType.Parameter, + identifier: 'gain', + value: 0, + ...overrides, + }, + } as any +} + +// --------------------------------------------------------------------------- +// constructor / initial state +// --------------------------------------------------------------------------- + +describe('EmberPlusState constructor', () => { + it('initialises selected with -1 for all fields', () => { + const state = new EmberPlusState() + expect(state.selected).toEqual({ source: -1, target: -1, matrix: -1 }) + }) + + it('initialises empty parameters map', () => { + expect(new EmberPlusState().parameters.size).toBe(0) + }) + + it('initialises empty monitoredParameters set', () => { + expect(new EmberPlusState().monitoredParameters.size).toBe(0) + }) + + it('initialises empty matrices array', () => { + expect(new EmberPlusState().matrices.length).toBe(0) + }) + + it('initialises empty emberElement map', () => { + expect(new EmberPlusState().emberElement.size).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// addIdToPathMap / getFeedbacksByPath +// --------------------------------------------------------------------------- + +describe('addIdToPathMap', () => { + let state: EmberPlusState + + beforeEach(() => { + state = new EmberPlusState() + }) + + it('registers a feedback id against a path', () => { + state.addIdToPathMap('fb1', '0.1.2') + expect(state.getFeedbacksByPath('0.1.2').has('fb1')).toBe(true) + }) + + it('multiple ids can be registered against the same path', () => { + state.addIdToPathMap('fb1', '0.1.2') + state.addIdToPathMap('fb2', '0.1.2') + expect(state.getFeedbacksByPath('0.1.2').size).toBe(2) + }) + + it('adding same id twice does not create duplicates', () => { + state.addIdToPathMap('fb1', '0.1.2') + state.addIdToPathMap('fb1', '0.1.2') + expect(state.getFeedbacksByPath('0.1.2').size).toBe(1) + }) + + it('does not add to byPath when path is empty string', () => { + state.addIdToPathMap('fb1', '') + expect(state.getFeedbacksByPath('').size).toBe(0) + }) + + it('moves id to new path when path changes', () => { + state.addIdToPathMap('fb1', '0.1.2') + state.addIdToPathMap('fb1', '0.1.3') + expect(state.getFeedbacksByPath('0.1.2').has('fb1')).toBe(false) + expect(state.getFeedbacksByPath('0.1.3').has('fb1')).toBe(true) + }) + + it('cleans up empty sets after path change', () => { + state.addIdToPathMap('fb1', '0.1.2') + state.addIdToPathMap('fb1', '0.1.3') + // byPath entry for old path should have been removed + expect(state.getFeedbacksByPath('0.1.2').size).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// getFeedbacksByPath +// --------------------------------------------------------------------------- + +describe('getFeedbacksByPath', () => { + it('returns an empty set for unknown paths', () => { + const state = new EmberPlusState() + const result = state.getFeedbacksByPath('unknown') + expect(result).toBeInstanceOf(Set) + expect(result.size).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// removeFeedbackId +// --------------------------------------------------------------------------- + +describe('removeFeedbackId', () => { + let state: EmberPlusState + + beforeEach(() => { + state = new EmberPlusState() + }) + + it('removes id from byPath', () => { + state.addIdToPathMap('fb1', '0.1.2') + state.removeFeedbackId('fb1') + expect(state.getFeedbacksByPath('0.1.2').has('fb1')).toBe(false) + }) + + it('cleans up empty byPath entry after removal', () => { + state.addIdToPathMap('fb1', '0.1.2') + state.removeFeedbackId('fb1') + expect(state.getFeedbacksByPath('0.1.2').size).toBe(0) + }) + + it('does not remove other ids registered on the same path', () => { + state.addIdToPathMap('fb1', '0.1.2') + state.addIdToPathMap('fb2', '0.1.2') + state.removeFeedbackId('fb1') + expect(state.getFeedbacksByPath('0.1.2').has('fb2')).toBe(true) + }) + + it('does nothing for an unknown id', () => { + state.removeFeedbackId('nonexistent') + }) +}) + +// --------------------------------------------------------------------------- +// updateParameterMap +// --------------------------------------------------------------------------- + +describe('updateParameterMap', () => { + let state: EmberPlusState + + beforeEach(() => { + state = new EmberPlusState() + }) + + it('stores a new parameter', () => { + state.updateParameterMap('0.1', makeNode({ identifier: 'level' })) + expect(state.parameters.has('0.1')).toBe(true) + }) + + it('stores the ember element', () => { + const node = makeNode() + state.updateParameterMap('0.1', node) + expect(state.emberElement.get('0.1')).toBe(node) + }) + + it('merges new fields into existing parameter', () => { + state.updateParameterMap('0.1', makeNode({ identifier: 'level', value: 5 })) + state.updateParameterMap('0.1', { contents: { type: ElementType.Parameter, value: 10 } }) + expect(state.parameters.get('0.1')?.identifier).toBe('level') + expect(state.parameters.get('0.1')?.value).toBe(10) + }) + + it('ignores nodes that are not Parameters', () => { + const node = { contents: { type: ElementType.Node } } as any + state.updateParameterMap('0.1', node) + expect(state.parameters.has('0.1')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// getCurrentEnumValue +// --------------------------------------------------------------------------- + +describe('getCurrentEnumValue', () => { + let state: EmberPlusState + + beforeEach(() => { + state = new EmberPlusState() + }) + + it('returns the correct enum string for a valid index', () => { + state.updateParameterMap('0.1', makeNode({ enumeration: 'Off\nOn\nStandby', value: 1 })) + expect(state.getCurrentEnumValue('0.1')).toBe('On') + }) + + it('returns first entry for index 0', () => { + state.updateParameterMap('0.1', makeNode({ enumeration: 'Off\nOn', value: 0 })) + expect(state.getCurrentEnumValue('0.1')).toBe('Off') + }) + + it('returns empty string when index is out of range', () => { + state.updateParameterMap('0.1', makeNode({ enumeration: 'Off\nOn', value: 5 })) + expect(state.getCurrentEnumValue('0.1')).toBe('') + }) + + it('returns empty string when enumeration is missing', () => { + state.updateParameterMap('0.1', makeNode({ value: 0 })) + expect(state.getCurrentEnumValue('0.1')).toBe('') + }) + + it('returns empty string when value is undefined', () => { + state.updateParameterMap('0.1', makeNode({ enumeration: 'Off\nOn', value: undefined })) + expect(state.getCurrentEnumValue('0.1')).toBe('') + }) + + it('returns empty string for unknown path', () => { + expect(state.getCurrentEnumValue('9.9.9')).toBe('') + }) +}) + +// --------------------------------------------------------------------------- +// getEnumIndex +// --------------------------------------------------------------------------- + +describe('getEnumIndex', () => { + let state: EmberPlusState + + beforeEach(() => { + state = new EmberPlusState() + state.updateParameterMap('0.1', makeNode({ enumeration: 'Off\nOn\nStandby', value: 0 })) + }) + + it('returns the correct index for a known enum string', () => { + expect(state.getEnumIndex('0.1', 'On')).toBe(1) + }) + + it('returns 0 for the first enum entry', () => { + expect(state.getEnumIndex('0.1', 'Off')).toBe(0) + }) + + it('returns undefined for a string not in the enumeration', () => { + expect(state.getEnumIndex('0.1', 'Unknown')).toBeUndefined() + }) + + it('returns undefined when path has no enumeration', () => { + state.updateParameterMap('0.2', makeNode({ value: 0 })) + expect(state.getEnumIndex('0.2', 'Off')).toBeUndefined() + }) + + it('returns undefined for unknown path', () => { + expect(state.getEnumIndex('9.9.9', 'Off')).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// getParameter / hasParameter +// --------------------------------------------------------------------------- + +describe('getParameter', () => { + it('returns the parameter for a known path', () => { + const state = new EmberPlusState() + state.updateParameterMap('0.1', makeNode({ identifier: 'gain' })) + expect(state.getParameter('0.1')?.identifier).toBe('gain') + }) + + it('returns undefined for an unknown path', () => { + expect(new EmberPlusState().getParameter('9.9.9')).toBeUndefined() + }) +}) + +describe('hasParameter', () => { + it('returns true for a registered path', () => { + const state = new EmberPlusState() + state.updateParameterMap('0.1', makeNode()) + expect(state.hasParameter('0.1')).toBe(true) + }) + + it('returns false for an unregistered path', () => { + expect(new EmberPlusState().hasParameter('0.1')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// clear +// --------------------------------------------------------------------------- + +describe('clear', () => { + it('empties all maps and sets and resets selected', () => { + const state = new EmberPlusState() + state.updateParameterMap('0.1', makeNode()) + state.addIdToPathMap('fb1', '0.1') + state.monitoredParameters.add('0.1') + state.matrices.push('0.1') + state.selected = { source: 1, target: 2, matrix: 3 } + + state.clear() + + expect(state.parameters.size).toBe(0) + expect(state.emberElement.size).toBe(0) + expect(state.getFeedbacksByPath('0.1').size).toBe(0) + expect(state.selected).toEqual({ source: -1, target: -1, matrix: -1 }) + }) + + it('can be called on an already-empty state without throwing', () => { + new EmberPlusState().clear() + }) +}) diff --git a/src/util.test.ts b/src/util.test.ts new file mode 100644 index 0000000..4a18481 --- /dev/null +++ b/src/util.test.ts @@ -0,0 +1,589 @@ +import { describe, it, expect, vi } from 'vitest' +import { + assertUnreachable, + literal, + compareNumber, + NumberComparitor, + comparitorOptions, + parseEscapeCharacters, + substituteEscapeCharacters, + filterPathChoices, + checkNumberLimits, + calcRelativeNumber, + resolvePath, + resolveEventPath, + sanitiseVariableId, + isDefined, + parseBonjourHost, + hasConnectionChanged, + recordParameterAction, + parseParameterValue, +} from './util.js' +import { ActionId } from './actions.js' +import { EmberPlusState } from './state.js' +import { Model as EmberModel } from 'emberplus-connection' + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock('emberplus-connection', () => ({ + Model: { + ParameterType: { + Boolean: 'boolean', + Integer: 'integer', + Real: 'real', + Enum: 'enum', + String: 'string', + }, + ParameterAccess: { + None: 'none', + Read: 'read', + Write: 'write', + ReadWrite: 'readWrite', + }, + }, +})) + +vi.mock('./actions', () => ({ + ActionId: { + SetValueBoolean: 'setValueBoolean', + SetValueInt: 'setValueInt', + SetValueReal: 'setValueReal', + SetValueEnum: 'setValueEnum', + SetValueString: 'setValueString', + }, +})) + +// Minimal EmberPlusState mock factory +function makeState(params: Map = new Map()): EmberPlusState { + return { + parameters: params, + getCurrentEnumValue: vi.fn().mockReturnValue('enumLabel'), + } as unknown as EmberPlusState +} + +// Minimal EmberPlusInstance mock +function makeInstance() { + return { recordAction: vi.fn() } as any +} + +// --------------------------------------------------------------------------- +// literal +// --------------------------------------------------------------------------- + +describe('literal', () => { + it('returns the value unchanged', () => { + expect(literal(42)).toBe(42) + expect(literal('hello')).toBe('hello') + expect(literal({ a: 1 })).toEqual({ a: 1 }) + }) +}) + +// --------------------------------------------------------------------------- +// assertUnreachable +// --------------------------------------------------------------------------- + +describe('assertUnreachable', () => { + it('does not throw (no-op implementation)', () => { + // If this throws, vitest will fail the test automatically + assertUnreachable(undefined as never) + }) +}) + +// --------------------------------------------------------------------------- +// compareNumber +// --------------------------------------------------------------------------- + +describe('compareNumber', () => { + it('Equal: returns true when values match', () => { + expect(compareNumber(5, NumberComparitor.Equal, 5)).toBe(true) + }) + it('Equal: returns false when values differ', () => { + expect(compareNumber(5, NumberComparitor.Equal, 6)).toBe(false) + }) + it('NotEqual: returns true when values differ', () => { + expect(compareNumber(5, NumberComparitor.NotEqual, 6)).toBe(true) + }) + it('NotEqual: returns false when values match', () => { + expect(compareNumber(5, NumberComparitor.NotEqual, 5)).toBe(false) + }) + it('LessThan: currentValue < target', () => { + expect(compareNumber(10, NumberComparitor.LessThan, 5)).toBe(true) + expect(compareNumber(10, NumberComparitor.LessThan, 10)).toBe(false) + }) + it('LessThanEqual: currentValue <= target', () => { + expect(compareNumber(10, NumberComparitor.LessThanEqual, 10)).toBe(true) + expect(compareNumber(10, NumberComparitor.LessThanEqual, 11)).toBe(false) + }) + it('GreaterThan: currentValue > target', () => { + expect(compareNumber(5, NumberComparitor.GreaterThan, 10)).toBe(true) + expect(compareNumber(10, NumberComparitor.GreaterThan, 10)).toBe(false) + }) + it('GreaterThanEqual: currentValue >= target', () => { + expect(compareNumber(5, NumberComparitor.GreaterThanEqual, 5)).toBe(true) + expect(compareNumber(5, NumberComparitor.GreaterThanEqual, 4)).toBe(false) + }) + it('returns false when target is NaN', () => { + expect(compareNumber(NaN, NumberComparitor.Equal, 5)).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// comparitorOptions +// --------------------------------------------------------------------------- + +describe('comparitorOptions', () => { + it('contains all 6 comparitor entries', () => { + expect(comparitorOptions).toHaveLength(6) + }) + it('each entry has id and label', () => { + comparitorOptions.forEach((opt) => { + expect(opt).toHaveProperty('id') + expect(opt).toHaveProperty('label') + }) + }) +}) + +// --------------------------------------------------------------------------- +// parseEscapeCharacters +// --------------------------------------------------------------------------- + +describe('parseEscapeCharacters', () => { + it('converts \\n to newline', () => { + expect(parseEscapeCharacters('line1\\nline2')).toBe('line1\nline2') + }) + it('converts \\r, \\t, \\f, \\v, \\b', () => { + expect(parseEscapeCharacters('\\r')).toBe('\r') + expect(parseEscapeCharacters('\\t')).toBe('\t') + expect(parseEscapeCharacters('\\f')).toBe('\f') + expect(parseEscapeCharacters('\\v')).toBe('\v') + expect(parseEscapeCharacters('\\b')).toBe('\b') + }) + it('converts \\x00 – \\x03 to control characters', () => { + expect(parseEscapeCharacters('\\x00')).toBe('\x00') + expect(parseEscapeCharacters('\\x03')).toBe('\x03') + }) + it('leaves regular strings unchanged', () => { + expect(parseEscapeCharacters('hello world')).toBe('hello world') + }) +}) + +// --------------------------------------------------------------------------- +// substituteEscapeCharacters +// --------------------------------------------------------------------------- + +describe('substituteEscapeCharacters', () => { + it('is the inverse of parseEscapeCharacters for supported sequences', () => { + const original = 'a\nb\tc\rd' + const substituted = substituteEscapeCharacters(original) + expect(substituted).toBe('a\\nb\\tc\\rd') + expect(parseEscapeCharacters(substituted)).toBe(original) + }) + it('converts control characters back to escape sequences', () => { + expect(substituteEscapeCharacters('\x00')).toBe('\\x00') + expect(substituteEscapeCharacters('\x03')).toBe('\\x03') + }) +}) + +// --------------------------------------------------------------------------- +// filterPathChoices +// --------------------------------------------------------------------------- + +describe('filterPathChoices', () => { + const makeParam = (parameterType: string, access: string, identifier?: string, description?: string) => ({ + parameterType, + access, + identifier, + description, + }) + + it('returns readable choices when isWriteable=false', () => { + const params = new Map([ + ['0.1', makeParam(EmberModel.ParameterType.Integer, EmberModel.ParameterAccess.Read, 'gain')], + ]) + const state = makeState(params) + const choices = filterPathChoices(state, false, EmberModel.ParameterType.Integer) + expect(choices).toHaveLength(1) + expect(choices[0].id).toBe('0.1') + expect(choices[0].label).toContain('gain') + }) + + it('excludes None-access paths when isWriteable=false', () => { + const params = new Map([ + ['0.1', makeParam(EmberModel.ParameterType.Integer, EmberModel.ParameterAccess.None)], + ]) + const choices = filterPathChoices(makeState(params), false, EmberModel.ParameterType.Integer) + expect(choices).toHaveLength(0) + }) + + it('only includes ReadWrite/Write paths when isWriteable=true', () => { + const params = new Map([ + ['0.1', makeParam(EmberModel.ParameterType.Integer, EmberModel.ParameterAccess.Read)], + ['0.2', makeParam(EmberModel.ParameterType.Integer, EmberModel.ParameterAccess.ReadWrite)], + ['0.3', makeParam(EmberModel.ParameterType.Integer, EmberModel.ParameterAccess.Write)], + ]) + const choices = filterPathChoices(makeState(params), true, EmberModel.ParameterType.Integer) + expect(choices).toHaveLength(2) + expect(choices.map((c) => c.id)).toEqual(expect.arrayContaining(['0.2', '0.3'])) + }) + + it('returns all types when no filter specified', () => { + const params = new Map([ + ['0.1', makeParam(EmberModel.ParameterType.Integer, EmberModel.ParameterAccess.Read)], + ['0.2', makeParam(EmberModel.ParameterType.Boolean, EmberModel.ParameterAccess.Read)], + ]) + const choices = filterPathChoices(makeState(params), false) + expect(choices).toHaveLength(2) + }) + + it('appends description in parentheses when present', () => { + const params = new Map([ + ['0.1', makeParam(EmberModel.ParameterType.Integer, EmberModel.ParameterAccess.Read, 'id', 'my desc')], + ]) + const choices = filterPathChoices(makeState(params), false, EmberModel.ParameterType.Integer) + expect(choices[0].label).toContain('(my desc)') + }) +}) + +// --------------------------------------------------------------------------- +// checkNumberLimits +// --------------------------------------------------------------------------- + +describe('checkNumberLimits', () => { + it('returns value when within range', () => { + expect(checkNumberLimits(5, 0, 10)).toBe(5) + }) + it('clamps to min', () => { + expect(checkNumberLimits(-5, 0, 10)).toBe(0) + }) + it('clamps to max', () => { + expect(checkNumberLimits(15, 0, 10)).toBe(10) + }) + it('handles boundary values', () => { + expect(checkNumberLimits(0, 0, 10)).toBe(0) + expect(checkNumberLimits(10, 0, 10)).toBe(10) + }) +}) + +// --------------------------------------------------------------------------- +// calcRelativeNumber +// --------------------------------------------------------------------------- + +describe('calcRelativeNumber', () => { + it('adds relative value to current state value', () => { + const state = makeState(new Map([['0.1', { value: 5 }]])) + const result = calcRelativeNumber(3, '0.1', '', '', EmberModel.ParameterType.Integer, state) + expect(result).toBe(8) + }) + + it('rounds result for Integer type', () => { + const state = makeState(new Map([['0.1', { value: 5.4 }]])) + const result = calcRelativeNumber(1.3, '0.1', '', '', EmberModel.ParameterType.Integer, state) + expect(result).toBe(Math.round(6.7)) + }) + + it('enforces min/max limits', () => { + const state = makeState(new Map([['0.1', { value: 8 }]])) + const result = calcRelativeNumber(5, '0.1', '0', '10', EmberModel.ParameterType.Integer, state) + expect(result).toBe(10) + }) + + it('defaults old value to 0 when path missing', () => { + const state = makeState(new Map()) + const result = calcRelativeNumber(3, 'missing', '', '', EmberModel.ParameterType.Real, state) + expect(result).toBe(3) + }) + + it('clamps Enum type to minimum of 0', () => { + const state = makeState(new Map([['0.1', { value: 0 }]])) + const result = calcRelativeNumber(-5, '0.1', '', '', EmberModel.ParameterType.Enum, state) + expect(result).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// resolvePath +// --------------------------------------------------------------------------- + +describe('resolvePath', () => { + it('replaces slashes with dots and trims', () => { + expect(resolvePath('0/1/2')).toBe('0.1.2') + }) + it('extracts content inside last brackets', () => { + expect(resolvePath('node[0.1.2]')).toBe('0.1.2') + }) + it('returns dotted path when no brackets', () => { + expect(resolvePath('0.1.2')).toBe('0.1.2') + }) + it('uses last bracket pair when multiple exist', () => { + expect(resolvePath('[0.1][0.2.3]')).toBe('0.2.3') + }) + it('ignores brackets when close comes before open', () => { + expect(resolvePath(']0.1[')).toBe(']0.1[') + }) + + describe('valid numeric ember+ paths in brackets', () => { + it('extracts a single segment path', () => { + expect(resolvePath('node[0]')).toBe('0') + }) + it('extracts a two segment path', () => { + expect(resolvePath('node[0.1]')).toBe('0.1') + }) + it('extracts a deeply nested path', () => { + expect(resolvePath('node[0.1.2.3.4]')).toBe('0.1.2.3.4') + }) + it('extracts path with multi-digit segments', () => { + expect(resolvePath('node[12.345.6]')).toBe('12.345.6') + }) + it('replaces slashes before extracting bracket content', () => { + expect(resolvePath('node/path[0.1.2]')).toBe('0.1.2') + }) + }) + + describe('invalid numeric ember+ paths in brackets', () => { + it('returns full path when bracket content is empty', () => { + expect(resolvePath('node[]')).toBe('node[]') + }) + it('returns full path when bracket content is a label string', () => { + expect(resolvePath('node[gain.value]')).toBe('node[gain.value]') + }) + it('returns full path when bracket content has a trailing dot', () => { + expect(resolvePath('node[0.1.]')).toBe('node[0.1.]') + }) + it('returns full path when bracket content has a leading dot', () => { + expect(resolvePath('node[.0.1]')).toBe('node[.0.1]') + }) + it('returns full path when bracket content has consecutive dots', () => { + expect(resolvePath('node[0..1]')).toBe('node[0..1]') + }) + it('returns full path when bracket content contains letters mixed with numbers', () => { + expect(resolvePath('node[0.1a.2]')).toBe('node[0.1a.2]') + }) + it('returns full path when bracket content contains slashes', () => { + expect(resolvePath('node[0/1/2]')).toBe('0.1.2') // slashes are replaced first → becomes 'node[0.1.2]' → valid + }) + }) +}) + +// --------------------------------------------------------------------------- +// resolveEventPath +// --------------------------------------------------------------------------- + +describe('resolveEventPath', () => { + it('uses path when usePathVar is false', () => { + const event = { options: { usePathVar: false, path: '0.1.2', pathVar: 'var.path' } } as any + expect(resolveEventPath(event)).toBe('0.1.2') + }) + it('uses pathVar when usePathVar is true', () => { + const event = { options: { usePathVar: true, path: '0.1.2', pathVar: 'var.path' } } as any + expect(resolveEventPath(event)).toBe('var.path') + }) + it('handles missing path gracefully', () => { + const event = { options: { usePathVar: false } } as any + expect(resolveEventPath(event)).toBe('') + }) +}) + +// --------------------------------------------------------------------------- +// sanitiseVariableId +// --------------------------------------------------------------------------- + +describe('sanitiseVariableId', () => { + it('replaces illegal characters with underscore by default', () => { + expect(sanitiseVariableId('my var!')).toBe('my_var_') + }) + it('allows alphanumerics, hyphens, underscores, dots', () => { + expect(sanitiseVariableId('my-var_1.2')).toBe('my-var_1.2') + }) + it('uses specified substitute character', () => { + expect(sanitiseVariableId('my var', '-')).toBe('my-var') + }) + it('removes illegal chars when substitute is empty string', () => { + expect(sanitiseVariableId('my var!', '')).toBe('myvar') + }) +}) + +// --------------------------------------------------------------------------- +// isDefined +// --------------------------------------------------------------------------- + +describe('isDefined', () => { + it('returns true for defined values', () => { + expect(isDefined(0)).toBe(true) + expect(isDefined('')).toBe(true) + expect(isDefined(false)).toBe(true) + expect(isDefined({})).toBe(true) + }) + it('returns false for null', () => { + expect(isDefined(null)).toBe(false) + }) + it('returns false for undefined', () => { + expect(isDefined(undefined)).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// parseBonjourHost +// --------------------------------------------------------------------------- + +describe('parseBonjourHost', () => { + it('returns host and port from config when no bonjourHost', () => { + const config = { host: '192.168.1.1', port: 8080 } as any + expect(parseBonjourHost(config)).toEqual(['192.168.1.1', 8080]) + }) + it('parses host and port from bonjourHost string', () => { + const config = { bonjourHost: '10.0.0.1:9001' } as any + expect(parseBonjourHost(config)).toEqual(['10.0.0.1', 9001]) + }) + it('defaults to port 9000 when bonjourHost port is missing/invalid', () => { + const config = { bonjourHost: '10.0.0.1:abc' } as any + expect(parseBonjourHost(config)).toEqual(['10.0.0.1', 9000]) + }) + it('defaults host to empty string and port to 9000 when nothing provided', () => { + const config = {} as any + expect(parseBonjourHost(config)).toEqual(['', 9000]) + }) +}) + +// --------------------------------------------------------------------------- +// hasConnectionChanged +// --------------------------------------------------------------------------- + +describe('hasConnectionChanged', () => { + it('returns true when host changed', () => { + expect(hasConnectionChanged({ host: 'a', port: 9000 } as any, { host: 'b', port: 9000 } as any)).toBe(true) + }) + it('returns true when port changed', () => { + expect(hasConnectionChanged({ host: 'a', port: 9000 } as any, { host: 'a', port: 9001 } as any)).toBe(true) + }) + it('returns false when neither changed', () => { + expect(hasConnectionChanged({ host: 'a', port: 9000 } as any, { host: 'a', port: 9000 } as any)).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// recordParameterAction +// --------------------------------------------------------------------------- + +describe('recordParameterAction', () => { + it('records a boolean action', () => { + const instance = makeInstance() + const state = makeState(new Map()) + recordParameterAction('0.1', ActionId.SetValueBoolean, true, instance, state) + expect(instance.recordAction).toHaveBeenCalledWith( + expect.objectContaining({ actionId: ActionId.SetValueBoolean }), + '0.1', + ) + }) + + it('records an integer action with min/max from state', () => { + const instance = makeInstance() + const state = makeState(new Map([['0.1', { minimum: 0, maximum: 100, factor: 2 }]])) + recordParameterAction('0.1', ActionId.SetValueInt, 50, instance, state) + const call = instance.recordAction.mock.calls[0][0] + expect(call.options.min).toBe('0') + expect(call.options.max).toBe('100') + expect(call.options.factor).toBe('2') + }) + + it('records a real action', () => { + const instance = makeInstance() + recordParameterAction('0.1', ActionId.SetValueReal, 3.14, instance, makeState()) + expect(instance.recordAction).toHaveBeenCalledWith( + expect.objectContaining({ actionId: ActionId.SetValueReal }), + '0.1', + ) + }) + + it('records an enum action', () => { + const instance = makeInstance() + const state = makeState(new Map([['0.1', { minimum: 0, maximum: 5 }]])) + recordParameterAction('0.1', ActionId.SetValueEnum, 2, instance, state) + const call = instance.recordAction.mock.calls[0][0] + expect(call.options.asEnum).toBe(true) + }) + + it('records a string action', () => { + const instance = makeInstance() + recordParameterAction('0.1', ActionId.SetValueString, 'hello', instance, makeState()) + const call = instance.recordAction.mock.calls[0][0] + expect(call.options.parseEscapeChars).toBe(false) + }) + + it('does nothing for unknown action type', () => { + const instance = makeInstance() + recordParameterAction('0.1', 'unknown' as any, 0, instance, makeState()) + expect(instance.recordAction.mock.calls.length).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// parseParameterValue +// --------------------------------------------------------------------------- + +describe('parseParameterValue', () => { + it('parses Boolean parameter', () => { + const state = makeState() + const result = parseParameterValue( + '0.1', + { parameterType: EmberModel.ParameterType.Boolean, value: true } as any, + state, + ) + expect(result).toEqual({ actionType: ActionId.SetValueBoolean, value: true }) + }) + + it('parses Integer parameter and divides by factor', () => { + const state = makeState(new Map([['0.1', { factor: 2 }]])) + const result = parseParameterValue( + '0.1', + { parameterType: EmberModel.ParameterType.Integer, value: 100 } as any, + state, + ) + expect(result).toEqual({ actionType: ActionId.SetValueInt, value: 50 }) + }) + + it('defaults to factor 1 when param missing', () => { + const state = makeState() + const result = parseParameterValue( + '0.1', + { parameterType: EmberModel.ParameterType.Integer, value: 100 } as any, + state, + ) + expect(result.value).toBe(100) + }) + + it('parses Real parameter', () => { + const state = makeState() + const result = parseParameterValue( + '0.1', + { parameterType: EmberModel.ParameterType.Real, value: 3.14 } as any, + state, + ) + expect(result).toEqual({ actionType: ActionId.SetValueReal, value: 3.14 }) + }) + + it('parses Enum parameter', () => { + const state = makeState() + const result = parseParameterValue('0.1', { parameterType: EmberModel.ParameterType.Enum, value: 2 } as any, state) + expect(result).toEqual({ actionType: ActionId.SetValueEnum, value: 2 }) + }) + + it('parses String parameter and substitutes escape characters', () => { + const state = makeState() + const result = parseParameterValue( + '0.1', + { parameterType: EmberModel.ParameterType.String, value: 'line1\nline2' } as any, + state, + ) + expect(result.actionType).toBe(ActionId.SetValueString) + expect(result.value).toBe('line1\\nline2') + }) + + it('handles unknown parameter type', () => { + const state = makeState() + const result = parseParameterValue('0.1', { parameterType: 'unknown', value: 'raw' } as any, state) + expect(result.actionType).toBeUndefined() + expect(result.value).toBe('raw') + }) +}) From b957acbae40c6745f08fd6a375d77e188b74a60d Mon Sep 17 00:00:00 2001 From: Greaple Date: Fri, 14 Aug 2026 06:28:39 +0200 Subject: [PATCH 6/7] test: add unit tests for Ember+ function invocation feature --- src/actions/function.test.ts | 96 +++++++++++++++++++++++++ src/state.test.ts | 69 ++++++++++++++++++ src/util.test.ts | 133 +++++++++++++++++++++++++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 src/actions/function.test.ts diff --git a/src/actions/function.test.ts b/src/actions/function.test.ts new file mode 100644 index 0000000..6c2aaaa --- /dev/null +++ b/src/actions/function.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi } from 'vitest' +import { invokeFunctionAction } from './function.js' +import { Model as EmberModel } from 'emberplus-connection' +import { ElementType } from 'emberplus-connection/dist/model/index.js' + +describe('invokeFunctionAction callback', () => { + it('invokes emberClient.invoke with correctly parsed arguments', async () => { + const mockSelf: any = { logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } } + const mockResponse = Promise.resolve({ success: true, result: [] }) + const mockEmberClient: any = { + getElementByPath: vi.fn(), + invoke: vi.fn().mockResolvedValue({ response: mockResponse }), + } + const funcNode: any = { + contents: { + type: ElementType.Function, + identifier: 'MyFunc', + args: [{ type: EmberModel.ParameterType.Integer, name: 'id' }], + }, + } + const mockState: any = { + emberElement: new Map([['1.2.3', funcNode]]), + updateFunctionMap: vi.fn(), + } + const mockQueue: any = { + add: vi.fn().mockImplementation((fn: any) => fn()), + } + + const callback = invokeFunctionAction(mockSelf, mockEmberClient, mockState, mockQueue) + + const action: any = { + options: { + path: '1.2.3', + usePathVar: false, + args: '42', + parseEscapeChars: true, + }, + } + + const context: any = { + parseVariablesInString: vi.fn().mockImplementation(async (str) => str), + } + + await callback(action, context) + + expect(mockEmberClient.invoke).toHaveBeenCalledWith( + funcNode, + { type: EmberModel.ParameterType.Integer, value: 42 }, + ) + expect(mockSelf.logger.info).toHaveBeenCalledWith( + 'Function "1.2.3" invoked successfully', + [], + ) + }) + + it('logs warning when path is empty', async () => { + const mockSelf: any = { logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } } + const mockEmberClient: any = { invoke: vi.fn() } + const mockState: any = { emberElement: new Map() } + const mockQueue: any = { add: vi.fn().mockImplementation((fn: any) => fn()) } + + const callback = invokeFunctionAction(mockSelf, mockEmberClient, mockState, mockQueue) + const action: any = { options: { path: '', usePathVar: false, args: '' } } + const context: any = { parseVariablesInString: vi.fn().mockImplementation(async (str) => str) } + + await callback(action, context) + + expect(mockSelf.logger.warn).toHaveBeenCalledWith('Invoke Function: Path is empty') + expect(mockEmberClient.invoke).not.toHaveBeenCalled() + }) + + it('logs error when target node is not a Function', async () => { + const mockSelf: any = { logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } } + const mockEmberClient: any = { invoke: vi.fn() } + const paramNode: any = { + contents: { + type: ElementType.Parameter, + identifier: 'Param1', + }, + } + const mockState: any = { + emberElement: new Map([['1.2.3', paramNode]]), + updateFunctionMap: vi.fn(), + } + const mockQueue: any = { add: vi.fn().mockImplementation((fn: any) => fn()) } + + const callback = invokeFunctionAction(mockSelf, mockEmberClient, mockState, mockQueue) + const action: any = { options: { path: '1.2.3', usePathVar: false, args: '' } } + const context: any = { parseVariablesInString: vi.fn().mockImplementation(async (str) => str) } + + await callback(action, context) + + expect(mockSelf.logger.error).toHaveBeenCalledWith('Invoke Function: Node at path "1.2.3" is not a valid Ember+ Function') + expect(mockEmberClient.invoke).not.toHaveBeenCalled() + }) +}) diff --git a/src/state.test.ts b/src/state.test.ts index 13fa827..1838068 100644 --- a/src/state.test.ts +++ b/src/state.test.ts @@ -6,6 +6,7 @@ vi.mock('emberplus-connection/dist/model', () => ({ ElementType: { Parameter: 'parameter', Node: 'node', + Function: 'function', }, })) @@ -24,6 +25,17 @@ function makeNode(overrides: Record = {}) { } as any } +function makeFunctionNode(overrides: Record = {}) { + return { + contents: { + type: ElementType.Function, + identifier: 'muteFunction', + description: 'Mute Channel', + ...overrides, + }, + } as any +} + // --------------------------------------------------------------------------- // constructor / initial state // --------------------------------------------------------------------------- @@ -310,4 +322,61 @@ describe('clear', () => { it('can be called on an already-empty state without throwing', () => { new EmberPlusState().clear() }) + + it('clears functions map as well', () => { + const state = new EmberPlusState() + state.updateFunctionMap('0.1.2', makeFunctionNode()) + expect(state.functions.size).toBe(1) + state.clear() + expect(state.functions.size).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// clearCache +// --------------------------------------------------------------------------- + +describe('clearCache', () => { + it('clears emberElement map', () => { + const state = new EmberPlusState() + state.updateFunctionMap('0.1.2', makeFunctionNode()) + expect(state.emberElement.size).toBe(1) + state.clearCache() + expect(state.emberElement.size).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// updateFunctionMap / getFunction / hasFunction +// --------------------------------------------------------------------------- + +describe('updateFunctionMap / getFunction / hasFunction', () => { + it('stores function node in functions and emberElement maps', () => { + const state = new EmberPlusState() + const funcNode = makeFunctionNode({ identifier: 'fn1' }) + state.updateFunctionMap('1.2.3', funcNode) + + expect(state.hasFunction('1.2.3')).toBe(true) + expect(state.getFunction('1.2.3')?.identifier).toBe('fn1') + expect(state.emberElement.get('1.2.3')).toBe(funcNode) + }) + + it('ignores non-function elements', () => { + const state = new EmberPlusState() + const paramNode = makeNode() + state.updateFunctionMap('1.2.3', paramNode) + + expect(state.hasFunction('1.2.3')).toBe(false) + expect(state.getFunction('1.2.3')).toBeUndefined() + }) + + it('merges data for existing function path', () => { + const state = new EmberPlusState() + state.updateFunctionMap('1.2.3', makeFunctionNode({ identifier: 'fn1', description: 'Old' })) + state.updateFunctionMap('1.2.3', makeFunctionNode({ identifier: 'fn1', description: 'New description' })) + + const stored = state.getFunction('1.2.3') + expect(stored?.identifier).toBe('fn1') + expect(stored?.description).toBe('New description') + }) }) diff --git a/src/util.test.ts b/src/util.test.ts index 4a18481..d8538ea 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -8,6 +8,7 @@ import { parseEscapeCharacters, substituteEscapeCharacters, filterPathChoices, + filterFunctionPathChoices, checkNumberLimits, calcRelativeNumber, resolvePath, @@ -18,6 +19,8 @@ import { hasConnectionChanged, recordParameterAction, parseParameterValue, + parseFunctionArguments, + discoverFunctionsFromTree, } from './util.js' import { ActionId } from './actions.js' import { EmberPlusState } from './state.js' @@ -36,6 +39,11 @@ vi.mock('emberplus-connection', () => ({ Enum: 'enum', String: 'string', }, + ElementType: { + Function: 'function', + Parameter: 'parameter', + Node: 'node', + }, ParameterAccess: { None: 'none', Read: 'read', @@ -45,6 +53,14 @@ vi.mock('emberplus-connection', () => ({ }, })) +vi.mock('emberplus-connection/dist/model/index.js', () => ({ + ElementType: { + Function: 'function', + Parameter: 'parameter', + Node: 'node', + }, +})) + vi.mock('./actions', () => ({ ActionId: { SetValueBoolean: 'setValueBoolean', @@ -587,3 +603,120 @@ describe('parseParameterValue', () => { expect(result.value).toBe('raw') }) }) + +// --------------------------------------------------------------------------- +// filterFunctionPathChoices +// --------------------------------------------------------------------------- + +describe('filterFunctionPathChoices', () => { + it('returns formatted choices for registered functions', () => { + const mockState: any = { + functions: new Map([ + ['1.2.3', { identifier: 'Mute', description: 'Mute Channel' }], + ['1.2.4', { identifier: 'Unmute' }], + ['1.2.5', {}], + ]), + } + + const choices = filterFunctionPathChoices(mockState) + expect(choices).toEqual([ + { id: '1.2.3', label: '1.2.3: Mute (Mute Channel)' }, + { id: '1.2.4', label: '1.2.4: Unmute' }, + { id: '1.2.5', label: '1.2.5' }, + ]) + }) + + it('returns empty array when no functions are registered', () => { + const mockState: any = { functions: new Map() } + expect(filterFunctionPathChoices(mockState)).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// parseFunctionArguments +// --------------------------------------------------------------------------- + +describe('parseFunctionArguments', () => { + it('returns empty array for empty input', () => { + expect(parseFunctionArguments('')).toEqual([]) + expect(parseFunctionArguments(' ')).toEqual([]) + }) + + it('parses and casts comma-separated arguments matching expected schema', () => { + const expectedArgs: any[] = [ + { type: EmberModel.ParameterType.Integer, name: 'id' }, + { type: EmberModel.ParameterType.Real, name: 'gain' }, + { type: EmberModel.ParameterType.Boolean, name: 'state' }, + { type: EmberModel.ParameterType.String, name: 'name' }, + ] + + const result = parseFunctionArguments('42, 3.14, true, Hello World', expectedArgs) + expect(result).toEqual([ + { type: EmberModel.ParameterType.Integer, value: 42 }, + { type: EmberModel.ParameterType.Real, value: 3.14 }, + { type: EmberModel.ParameterType.Boolean, value: true }, + { type: EmberModel.ParameterType.String, value: 'Hello World' }, + ]) + }) + + it('infers parameter types when no schema is provided', () => { + const result = parseFunctionArguments('true, 100, 2.5, sample') + expect(result).toEqual([ + { type: EmberModel.ParameterType.Boolean, value: true }, + { type: EmberModel.ParameterType.Integer, value: 100 }, + { type: EmberModel.ParameterType.Real, value: 2.5 }, + { type: EmberModel.ParameterType.String, value: 'sample' }, + ]) + }) + + it('parses JSON array format correctly', () => { + const result = parseFunctionArguments('[1, "abc", false]') + expect(result).toEqual([ + { type: EmberModel.ParameterType.Integer, value: 1 }, + { type: EmberModel.ParameterType.String, value: 'abc' }, + { type: EmberModel.ParameterType.Boolean, value: false }, + ]) + }) + + it('passes through explicit typed objects in JSON array', () => { + const result = parseFunctionArguments('[{"type": "integer", "value": 55}]') + expect(result).toEqual([{ type: 'integer', value: 55 }]) + }) +}) + +// --------------------------------------------------------------------------- +// discoverFunctionsFromTree +// --------------------------------------------------------------------------- + +describe('discoverFunctionsFromTree', () => { + it('recursively discovers function nodes in tree', () => { + const state = new EmberPlusState() + const tree = { + 0: { + number: 1, + contents: { type: 'node' }, + children: { + 0: { + number: 2, + contents: { type: 'function', identifier: 'TestFunc' }, + }, + 1: { + number: 3, + contents: { type: 'parameter' }, + }, + }, + }, + } + + discoverFunctionsFromTree(tree, state) + expect(state.hasFunction('1.2')).toBe(true) + expect(state.getFunction('1.2')?.identifier).toBe('TestFunc') + expect(state.hasFunction('1.3')).toBe(false) + }) + + it('handles empty or null tree gracefully', () => { + const state = new EmberPlusState() + discoverFunctionsFromTree(null, state) + expect(state.functions.size).toBe(0) + }) +}) From b3b674719a6a78ec833147c58478508e79bc2f7f Mon Sep 17 00:00:00 2001 From: Greaple Date: Fri, 14 Aug 2026 09:19:39 +0200 Subject: [PATCH 7/7] chore: revert extraneous changes in feedback.ts, tsconfig.json, yarn.lock, and resolvePath --- src/feedback.ts | 2 +- src/util.ts | 11 +---------- tsconfig.json | 5 ++++- yarn.lock | 6 +++--- 4 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/feedback.ts b/src/feedback.ts index 079c563..7285bc9 100644 --- a/src/feedback.ts +++ b/src/feedback.ts @@ -109,7 +109,7 @@ const comparitorDropdown = { label: 'Comparitor', id: 'comparitor', choices: comparitorOptions, - default: comparitorOptions?.[0]?.id ?? 'eq', + default: comparitorOptions[0].id, allowCustom: false, } as const satisfies CompanionInputFieldDropdown diff --git a/src/util.ts b/src/util.ts index b229631..a2adcf0 100644 --- a/src/util.ts +++ b/src/util.ts @@ -187,7 +187,7 @@ export function resolvePath(path: string): string { const lastOpenBracket = pathString.lastIndexOf('[') const lastCloseBracket = pathString.lastIndexOf(']') - // Check if both brackets exist and close bracket comes after open bracket (e.g. "Descriptor[1.2.3.4]") + // Check if both brackets exist and close bracket comes after open bracket if (lastOpenBracket !== -1 && lastCloseBracket !== -1 && lastCloseBracket > lastOpenBracket) { const candidate = pathString.substring(lastOpenBracket + 1, lastCloseBracket) if (/^\d+(\.\d+)*$/.test(candidate)) { @@ -195,15 +195,6 @@ export function resolvePath(path: string): string { } } - // Check if colon format (e.g. "1.2.3.4 : Identifier") - const colonIndex = pathString.indexOf(':') - if (colonIndex !== -1) { - const candidate = pathString.substring(0, colonIndex).trim() - if (/^\d+(\.\d+)*$/.test(candidate)) { - return candidate - } - } - return pathString } diff --git a/tsconfig.json b/tsconfig.json index c185ea2..245d316 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,9 @@ "exclude": ["node_modules/**", "src/**/*spec.ts", "src/**/__tests__/*", "src/**/__mocks__/*"], "compilerOptions": { "outDir": "./dist", - "rootDir": "./" + "baseUrl": "./", + "paths": { + "*": ["./node_modules/*"] + } } } diff --git a/yarn.lock b/yarn.lock index ce4892b..6a447e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1288,7 +1288,7 @@ asn1@evs-broadcast/node-asn1: long: "npm:^3.2.0" smart-buffer: "npm:^3.0.3" tslib: "npm:^2.6.2" - checksum: 10c0/ec4d56ddea48517600d2e54e96c390b3785978615a3f4037bd412f6616be43539ad46d4eb7733ee00b6cf2a1e162e5dbd750b18c412f55a577fc56b54e0521c8 + checksum: 10c0/b1e3d1f926be6260e4fe063ec2982f0b2b5cec464a14c8a73defc6730210d2cc05e4d58ee7aba2c4c69666093f3476aa27a37dbd8125c950a30685e4eba507be languageName: node linkType: hard @@ -2823,7 +2823,7 @@ asn1@evs-broadcast/node-asn1: supports-preserve-symlinks-flag: "npm:^1.0.0" bin: resolve: bin/resolve - checksum: 10c0/3f9cb0d3e1f8552ed98b80a4be02d411f54fc5d844810fd2d2c57c616c3b5de006d59d5a2a28d9e0a93d4911b86a0938ba9810a240c9f828c4235dfaf38003b8 + checksum: 10c0/55f7a298977b1aacf6dbec6dcfc81100ba98675bced193b57d3ac58ced65acc40c3a38927853cea86b7f75edb3c20e1f099a09d48b0d8ceccc25d466993eec47 languageName: node linkType: hard @@ -3250,7 +3250,7 @@ asn1@evs-broadcast/node-asn1: bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10c0/e71955556fa9731f96949a638473582caef8a365c78d9337b79e8c9caaf6fd1b12cd800a4388ee421a49ed7c0c9e4e326c5467f4af6581269a68ebfe0aa98c19 + checksum: 10c0/2f25c74e65663c248fa1ade2b8459d9ce5372ff9dad07067310f132966ebec1d93f6c42f0baf77a6b6a7a91460463f708e6887013aaade22111037457c6b25df languageName: node linkType: hard