diff --git a/src/device/attribute/boolDeviceAttribute.ts b/src/device/attribute/boolDeviceAttribute.ts index aa898ec7..5cf99e4f 100644 --- a/src/device/attribute/boolDeviceAttribute.ts +++ b/src/device/attribute/boolDeviceAttribute.ts @@ -1,11 +1,9 @@ -import type { DeviceAttributeModifier, NotJustUndefined, NotUndefined } from './deviceAttribute.js'; +import type { DeviceAttributeModifier } from './deviceAttribute.js'; import DeviceAttribute from './deviceAttribute.js'; -type BoolDeviceAttributeValue = NotJustUndefined; +export type InitializedBoolDeviceAttribute = BoolDeviceAttribute; -export type InitializedBoolDeviceAttribute = BoolDeviceAttribute; - -export default class BoolDeviceAttribute extends DeviceAttribute +export default class BoolDeviceAttribute extends DeviceAttribute { public static createInitialized( name: string, @@ -13,7 +11,7 @@ export default class BoolDeviceAttribute(name, label, modifier, initialValue); + return new BoolDeviceAttribute(name, label, modifier, initialValue); } public static create( @@ -24,13 +22,11 @@ export default class BoolDeviceAttribute { + public override isValidValue(value: unknown): value is boolean { return typeof value === 'boolean'; } diff --git a/src/device/attribute/deviceAttribute.ts b/src/device/attribute/deviceAttribute.ts index 2ad13762..322089f8 100644 --- a/src/device/attribute/deviceAttribute.ts +++ b/src/device/attribute/deviceAttribute.ts @@ -1,9 +1,9 @@ import { Exclude, Expose } from 'class-transformer'; import type { Float, Int } from '../../util/numbers.js'; -export type NotJustUndefined = [V] extends [undefined] ? never : V; -export type NotUndefined = V extends undefined ? never : V; -export type AttributeValue = NotJustUndefined; +export type AllowedAttributeType = string | Int | Float | boolean | null; + +export type AttributeValue = IsInitialized extends true ? V : V | undefined; export enum DeviceAttributeModifier { @@ -12,13 +12,16 @@ export enum DeviceAttributeModifier writeOnly = 'wo', } -export const isValidAttributeValue = ( - attribute: DeviceAttribute | undefined, +export const isValidAttributeValue = ( + attribute: DeviceAttribute | undefined, value: unknown, -): value is NotUndefined => attribute?.isValidValue(value) ?? false; +): value is V => attribute?.isValidValue(value) ?? false; @Exclude() -export default abstract class DeviceAttribute +export default abstract class DeviceAttribute< + V extends AllowedAttributeType = AllowedAttributeType, + IsInitialized extends boolean = false, +> { @Expose({ name: 'name' }) private readonly _name: string; @@ -30,9 +33,9 @@ export default abstract class DeviceAttribute; - public constructor(name: string, label: string | undefined, modifier: DeviceAttributeModifier, initialValue: T) { + public constructor(name: string, label: string | undefined, modifier: DeviceAttributeModifier, initialValue: AttributeValue) { this._name = name; this._label = label; this._modifier = modifier; @@ -58,15 +61,15 @@ export default abstract class DeviceAttribute { return this._value; } - public set value(value: T) { + public set value(value: AttributeValue) { this._value = value; } - public hasValue(): this is { value: T } { + public hasValue(): this is { value: V } { return this._value !== undefined; } @@ -76,7 +79,7 @@ export default abstract class DeviceAttribute; + public abstract isValidValue(value: unknown): value is V; } diff --git a/src/device/attribute/floatDeviceAttribute.ts b/src/device/attribute/floatDeviceAttribute.ts index 619504c9..d7604fe6 100644 --- a/src/device/attribute/floatDeviceAttribute.ts +++ b/src/device/attribute/floatDeviceAttribute.ts @@ -1,19 +1,17 @@ -import type { DeviceAttributeModifier, NotJustUndefined } from './deviceAttribute.js'; +import type { AttributeValue, DeviceAttributeModifier } from './deviceAttribute.js'; import { Float } from '../../util/numbers.js'; import NumberDeviceAttribute from './numberDeviceAttribute.js'; -type FloatDeviceAttributeValue = NotJustUndefined; +export type InitializedFloatGenericDeviceAttribute = FloatDeviceAttribute; -export type InitializedFloatGenericDeviceAttribute = FloatDeviceAttribute; - -export default class FloatDeviceAttribute extends NumberDeviceAttribute +export default class FloatDeviceAttribute extends NumberDeviceAttribute { public constructor( name: string, label: string | undefined, modifier: DeviceAttributeModifier, uom: string | undefined, - initialValue: T, + initialValue: AttributeValue, ) { super(name, label, modifier, uom, initialValue); } @@ -25,7 +23,7 @@ export default class FloatDeviceAttribute(name, label, modifier, uom, initialValue); + return new FloatDeviceAttribute(name, label, modifier, uom, initialValue); } public static create( @@ -37,16 +35,18 @@ export default class FloatDeviceAttribute; -export type InitializedIntGenericDeviceAttribute = IntDeviceAttribute; +export type InitializedIntGenericDeviceAttribute = IntDeviceAttribute; -export default class IntDeviceAttribute extends NumberDeviceAttribute +export default class IntDeviceAttribute extends NumberDeviceAttribute { public static createInitialized( name: string, @@ -14,7 +13,7 @@ export default class IntDeviceAttribute(name, label, modifier, uom, initialValue); + return new IntDeviceAttribute(name, label, modifier, uom, initialValue); } public static create( @@ -26,16 +25,18 @@ export default class IntDeviceAttribute; +export type InitializedIntRangeDeviceAttribute = IntRangeDeviceAttribute; -export default class IntRangeDeviceAttribute extends NumberDeviceAttribute +export default class IntRangeDeviceAttribute extends NumberDeviceAttribute { @Expose({ name: 'min' }) private _min: Int; @@ -17,7 +16,16 @@ export default class IntRangeDeviceAttribute, + ) { super(name, label, modifier, uom, initialValue); this._min = min; this._max = max; @@ -34,7 +42,7 @@ export default class IntRangeDeviceAttribute(name, label, modifier, uom, min, max, incrementStep, initialValue); + return new IntRangeDeviceAttribute(name, label, modifier, uom, min, max, incrementStep, initialValue); } public static create( @@ -69,16 +77,18 @@ export default class IntRangeDeviceAttribute = ListDeviceAttribute; +> = ListDeviceAttribute; export type ListDeviceAttributeOptions = ListDeviceAttributeOption[]; export default class ListDeviceAttribute< IKey extends ListDeviceAttributeItem, IValue extends ListDeviceAttributeItem, - V extends IKey | undefined = IKey | undefined, -> extends DeviceAttribute + IsInitialized extends boolean = false, +> extends DeviceAttribute { @Expose({ name: 'values' }) private _values: ListDeviceAttributeOptions; @@ -27,7 +27,7 @@ export default class ListDeviceAttribute< label: string | undefined, modifier: DeviceAttributeModifier, values: ListDeviceAttributeOptions, - initialValue: V, + initialValue: AttributeValue, ) { super(name, label, modifier, initialValue); @@ -41,7 +41,7 @@ export default class ListDeviceAttribute< values: ListDeviceAttributeOptions, initialValue: IKey, ): InitializedListDeviceAttribute { - return new ListDeviceAttribute( + return new ListDeviceAttribute( name, label, modifier, values, initialValue, ); } @@ -57,18 +57,18 @@ export default class ListDeviceAttribute< ); } - public fromString(value: string): V { + public fromString(value: string): IKey { if (this._values.length === 0 || typeof this._values[0]?.key === 'string') { - // TODO https://github.com/SlvCtrlPlus/slvctrlplus-server/issues/107 + // The value kind (IKey) is chosen by the caller per instance, so TypeScript can't + // prove `value`/the parsed number is an IKey here - see issue #107 for details. // eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-unsafe-type-assertion - return value as V; + return value as IKey; } const parsedInt = parseInt(value, 10); - // TODO https://github.com/SlvCtrlPlus/slvctrlplus-server/issues/107 // eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-unsafe-type-assertion - return (isNaN(parsedInt) ? value : parsedInt) as V; + return (isNaN(parsedInt) ? value : parsedInt) as IKey; } public get values(): ListDeviceAttributeOptions { @@ -79,7 +79,7 @@ export default class ListDeviceAttribute< this._values = value; } - public isValidValue(value: unknown): value is NotUndefined { + public isValidValue(value: unknown): value is IKey { if (typeof value === 'string' || typeof value === 'number') { return -1 !== this._values.findIndex(entry => entry.key === value); } diff --git a/src/device/attribute/numberDeviceAttribute.ts b/src/device/attribute/numberDeviceAttribute.ts index 8638fad9..bc9c5b80 100644 --- a/src/device/attribute/numberDeviceAttribute.ts +++ b/src/device/attribute/numberDeviceAttribute.ts @@ -1,11 +1,14 @@ -import type { DeviceAttributeModifier, NotJustUndefined, NotUndefined } from './deviceAttribute.js'; +import type { AttributeValue, DeviceAttributeModifier } from './deviceAttribute.js'; import DeviceAttribute from './deviceAttribute.js'; import { Expose } from 'class-transformer'; import type { Float, Int } from '../../util/numbers.js'; -export type NumberAttributeValue = NotJustUndefined; +export type NumberAttributeValue = Int | Float; -export default abstract class NumberDeviceAttribute extends DeviceAttribute +export default abstract class NumberDeviceAttribute< + V extends NumberAttributeValue = NumberAttributeValue, + IsInitialized extends boolean = false, +> extends DeviceAttribute { @Expose({ name: 'uom' }) private readonly _uom: string | undefined; @@ -15,7 +18,7 @@ export default abstract class NumberDeviceAttribute, ) { super(name, label, modifier, initialValue); this._uom = uom; @@ -24,8 +27,4 @@ export default abstract class NumberDeviceAttribute { - return typeof value === 'number'; - } } diff --git a/src/device/attribute/strDeviceAttribute.ts b/src/device/attribute/strDeviceAttribute.ts index 33a6e6a6..9c35a942 100644 --- a/src/device/attribute/strDeviceAttribute.ts +++ b/src/device/attribute/strDeviceAttribute.ts @@ -1,11 +1,9 @@ -import type { DeviceAttributeModifier, NotJustUndefined, NotUndefined } from './deviceAttribute.js'; +import type { DeviceAttributeModifier } from './deviceAttribute.js'; import DeviceAttribute from './deviceAttribute.js'; -type StrDeviceAttributeValue = NotJustUndefined; +export type InitializedStrDeviceAttribute = StrDeviceAttribute; -export type InitializedStrDeviceAttribute = StrDeviceAttribute; - -export default class StrDeviceAttribute extends DeviceAttribute +export default class StrDeviceAttribute extends DeviceAttribute { public static createInitialized( name: string, @@ -13,25 +11,23 @@ export default class StrDeviceAttribute(name, label, modifier, initialValue); + return new StrDeviceAttribute(name, label, modifier, initialValue); } public static create( name: string, label: string | undefined, modifier: DeviceAttributeModifier, - initialValue?: StrDeviceAttributeValue, + initialValue?: string, ): StrDeviceAttribute { return new StrDeviceAttribute(name, label, modifier, initialValue); } - public override fromString(value: string): T { - // TODO https://github.com/SlvCtrlPlus/slvctrlplus-server/issues/107 - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-unsafe-type-assertion - return value as T; + public override fromString(value: string): string { + return value; } - public override isValidValue(value: unknown): value is NotUndefined { + public override isValidValue(value: unknown): value is string { return typeof value === 'string'; } diff --git a/src/device/protocol/buttplugIo/buttplugIoDevice.ts b/src/device/protocol/buttplugIo/buttplugIoDevice.ts index 2590d1d5..881d048e 100644 --- a/src/device/protocol/buttplugIo/buttplugIoDevice.ts +++ b/src/device/protocol/buttplugIo/buttplugIoDevice.ts @@ -3,9 +3,12 @@ import type { ButtplugClientDevice, SensorType } from 'buttplug'; import { ActuatorType } from 'buttplug'; import type { AttributeKeyOf, AttributeValueOf, DeviceInfo } from '../../device.js'; import Device from '../../device.js'; +import type { InitializedIntRangeDeviceAttribute } from '../../attribute/intRangeDeviceAttribute.js'; import IntRangeDeviceAttribute from '../../attribute/intRangeDeviceAttribute.js'; +import type { InitializedBoolDeviceAttribute } from '../../attribute/boolDeviceAttribute.js'; import BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; import { Int } from '../../../util/numbers.js'; +import type { InitializedIntGenericDeviceAttribute } from '../../attribute/intDeviceAttribute.js'; import IntDeviceAttribute from '../../attribute/intDeviceAttribute.js'; import { DeviceAttributeModifier } from '../../attribute/deviceAttribute.js'; import type EventEmitter from 'events'; @@ -18,9 +21,11 @@ type ButtplugSensorTypeKey = `${SensorType}-${number}`; export type ButtplugIoDeviceAttributeKey = ButtplugActuatorTypeKey | ButtplugSensorTypeKey; +// All attributes are always constructed with an initial value (see buttplugIoDeviceFactory.ts), +// so they use the Initialized* variants to reflect that at the type level. export type ButtplugIoDeviceAttributes = Record< ButtplugIoDeviceAttributeKey, - IntRangeDeviceAttribute | BoolDeviceAttribute | IntDeviceAttribute | undefined + InitializedIntRangeDeviceAttribute | InitializedBoolDeviceAttribute | InitializedIntGenericDeviceAttribute | undefined >; type AttributeValue = AttributeValueOf; @@ -76,6 +81,10 @@ export default class ButtplugIoDevice extends Device throw new Error(`Attribute with name '${attributeName}' is readonly`); } + // TypeScript now guarantees `value` is defined for a caller bound by the typed setAttribute + // signature above, but this is still reachable via the untyped AnyDevice interface (e.g. + // automation scripts), so the runtime guard stays. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (undefined === value) { throw new Error(`Value to be set for attribute '${attributeName}' cannot be undefined`); } diff --git a/src/device/protocol/estim2b/estim2bDevice.ts b/src/device/protocol/estim2b/estim2bDevice.ts index d4b1acf3..93b28f6a 100644 --- a/src/device/protocol/estim2b/estim2bDevice.ts +++ b/src/device/protocol/estim2b/estim2bDevice.ts @@ -1,13 +1,14 @@ import type { AttributeKeyOf, AttributeValueOf, DeviceInfo } from '../../device.js'; +import type { InitializedIntRangeDeviceAttribute } from '../../attribute/intRangeDeviceAttribute.js'; import IntRangeDeviceAttribute from '../../attribute/intRangeDeviceAttribute.js'; import type { Estim2bCommand, EStim2bStatus } from './estim2bProtocol.js'; import type EStim2bProtocol from './estim2bProtocol.js'; import { EStim2bMode } from './estim2bProtocol.js'; import { Exclude, Expose } from 'class-transformer'; import { Int } from '../../../util/numbers.js'; -import type BoolDeviceAttribute from '../../attribute/boolDeviceAttribute.js'; -import type StrDeviceAttribute from '../../attribute/strDeviceAttribute.js'; -import type ListDeviceAttribute from '../../attribute/listDeviceAttribute.js'; +import type { InitializedBoolDeviceAttribute } from '../../attribute/boolDeviceAttribute.js'; +import type { InitializedStrDeviceAttribute } from '../../attribute/strDeviceAttribute.js'; +import type { InitializedListDeviceAttribute } from '../../attribute/listDeviceAttribute.js'; import { DeviceAttributeModifier, isValidAttributeValue } from '../../attribute/deviceAttribute.js'; import type DeviceBidirectionalTransport from '../../transport/deviceBidirectionalTransport.js'; import PeripheralDevice from '../../peripheralDevice.js'; @@ -15,15 +16,18 @@ import { getErrorFromDecodeResult } from '../deviceProtocol.js'; import type EventEmitter from 'events'; import type Logger from '../../../logging/Logger.js'; +// All attributes below are always constructed with an initial value (see estim2bDeviceFactory.ts +// and setModeBasedAttributes()/updateAttributeValues() in this file), so they use the Initialized* +// variants to reflect that at the type level. export type EStim2bDeviceAttributes = { - mode: ListDeviceAttribute; - channelALevel: IntRangeDeviceAttribute; - channelBLevel: IntRangeDeviceAttribute; - pulseFrequency?: IntRangeDeviceAttribute; - pulsePwm?: IntRangeDeviceAttribute; - channelsJoined: BoolDeviceAttribute; - highPowerMode: BoolDeviceAttribute; - batteryStatus: StrDeviceAttribute; + mode: InitializedListDeviceAttribute; + channelALevel: InitializedIntRangeDeviceAttribute; + channelBLevel: InitializedIntRangeDeviceAttribute; + pulseFrequency?: InitializedIntRangeDeviceAttribute; + pulsePwm?: InitializedIntRangeDeviceAttribute; + channelsJoined: InitializedBoolDeviceAttribute; + highPowerMode: InitializedBoolDeviceAttribute; + batteryStatus: InitializedStrDeviceAttribute; }; export type EStim2bBatteryStatus = 'mains' | 'full' | 'medium' | 'low' | 'critical'; @@ -234,7 +238,7 @@ export default class EStim2bDevice extends PeripheralDevice; type PiperVirtualDeviceAttributes = { text: StrDeviceAttribute; - queuing: BoolDeviceAttribute; + queuing: InitializedBoolDeviceAttribute; }; export default class PiperVirtualDeviceLogic extends VirtualDeviceLogic< diff --git a/src/device/protocol/virtual/audio/ttsVirtualDeviceLogic.ts b/src/device/protocol/virtual/audio/ttsVirtualDeviceLogic.ts index d565b484..77310199 100644 --- a/src/device/protocol/virtual/audio/ttsVirtualDeviceLogic.ts +++ b/src/device/protocol/virtual/audio/ttsVirtualDeviceLogic.ts @@ -3,7 +3,9 @@ import StrDeviceAttribute from '../../../attribute/strDeviceAttribute.js'; import VirtualDeviceLogic from '../virtualDeviceLogic.js'; import say from 'say'; import type VirtualDevice from '../virtualDevice.js'; +import type { InitializedBoolDeviceAttribute } from '../../../attribute/boolDeviceAttribute.js'; import BoolDeviceAttribute from '../../../attribute/boolDeviceAttribute.js'; +import type { InitializedIntGenericDeviceAttribute } from '../../../attribute/intDeviceAttribute.js'; import IntDeviceAttribute from '../../../attribute/intDeviceAttribute.js'; import { Int } from '../../../../util/numbers.js'; import type Logger from '../../../../logging/Logger.js'; @@ -11,9 +13,9 @@ import type { TtsVirtualDeviceConfig } from './ttsVirtualDeviceConfig.js'; type TtsVirtualDeviceAttributes = { text: StrDeviceAttribute; - speaking: BoolDeviceAttribute; - queuing: BoolDeviceAttribute; - queueLength: IntDeviceAttribute; + speaking: InitializedBoolDeviceAttribute; + queuing: InitializedBoolDeviceAttribute; + queueLength: InitializedIntGenericDeviceAttribute; }; export default class TtsVirtualDeviceLogic extends VirtualDeviceLogic< diff --git a/src/device/protocol/zc95/zc95Device.ts b/src/device/protocol/zc95/zc95Device.ts index bcdf4039..ca94da8a 100644 --- a/src/device/protocol/zc95/zc95Device.ts +++ b/src/device/protocol/zc95/zc95Device.ts @@ -48,7 +48,7 @@ export type Zc95DevicePowerChannelAttributes = Record>>; +type Zc95DevicePatternAttributes = Partial>>; export type Zc95DeviceAttributes = AllOrNone & Zc95DevicePatternAttributes & Required; @@ -161,7 +161,7 @@ export default class Zc95Device extends PeripheralDevice): attrs is { - [K in keyof Zc95DevicePowerChannelAttributes]-?: InitializedIntRangeDeviceAttribute + // Power channel attributes are constructed unset (see getChannelPowerAttribute()) and their + // value is assigned later, so we can't use InitializedIntRangeDeviceAttribute here (a + // different, incompatible instantiation of IntRangeDeviceAttribute) - intersecting with + // `{ value: Int }` instead narrows just the value's definedness. + [K in keyof Zc95DevicePowerChannelAttributes]-?: Zc95DevicePowerChannelAttributes[K] & { value: Int } } { return attrs.powerChannel1?.value !== undefined && attrs.powerChannel2?.value !== undefined diff --git a/tests/type/device/buttplugIo.test-d.ts b/tests/type/device/buttplugIo.test-d.ts index c0f85845..b99b1cc4 100644 --- a/tests/type/device/buttplugIo.test-d.ts +++ b/tests/type/device/buttplugIo.test-d.ts @@ -4,10 +4,11 @@ import { Int } from '../../../src/util/numbers.js'; declare const device: ButtplugIoDevice; -// any valid actuator/sensor attribute key: Int | boolean | undefined -expectTypeOf(device.setAttribute('Vibrate-0', Int.from(50))).toEqualTypeOf>(); -expectTypeOf(device.setAttribute('Rotate-1', true)).toEqualTypeOf>(); -expectTypeOf(device.setAttribute('Battery-0', undefined)).toEqualTypeOf>(); +// any valid actuator/sensor attribute key: Int | boolean (initialized attribute, always has a value) +expectTypeOf(device.setAttribute('Vibrate-0', Int.from(50))).toEqualTypeOf>(); +expectTypeOf(device.setAttribute('Rotate-1', true)).toEqualTypeOf>(); +// @ts-expect-error attribute value cannot be undefined +device.setAttribute('Battery-0', undefined); // @ts-expect-error attribute value cannot be a string device.setAttribute('Vibrate-0', 'fast'); diff --git a/tests/type/device/estim2b.test-d.ts b/tests/type/device/estim2b.test-d.ts index c96a6ea4..f7d8e62d 100644 --- a/tests/type/device/estim2b.test-d.ts +++ b/tests/type/device/estim2b.test-d.ts @@ -4,32 +4,33 @@ import { Int } from '../../../src/util/numbers.js'; declare const device: EStim2bDevice; -// mode: Int (list attribute key) | undefined -expectTypeOf(device.setAttribute('mode', Int.from(0))).toEqualTypeOf>(); -expectTypeOf(device.setAttribute('mode', undefined)).toEqualTypeOf>(); +// mode: Int (initialized list attribute, always has a value) +expectTypeOf(device.setAttribute('mode', Int.from(0))).toEqualTypeOf>(); +// @ts-expect-error mode is always initialized and does not accept undefined +device.setAttribute('mode', undefined); // @ts-expect-error mode does not accept a string value device.setAttribute('mode', 'bounce'); -// channelALevel / channelBLevel: Int | undefined -expectTypeOf(device.setAttribute('channelALevel', Int.from(50))).toEqualTypeOf>(); -expectTypeOf(device.setAttribute('channelBLevel', Int.from(50))).toEqualTypeOf>(); +// channelALevel / channelBLevel: Int (initialized attribute, always has a value) +expectTypeOf(device.setAttribute('channelALevel', Int.from(50))).toEqualTypeOf>(); +expectTypeOf(device.setAttribute('channelBLevel', Int.from(50))).toEqualTypeOf>(); // @ts-expect-error channelALevel does not accept a boolean value device.setAttribute('channelALevel', true); -// pulseFrequency / pulsePwm: Int | undefined -expectTypeOf(device.setAttribute('pulseFrequency', Int.from(10))).toEqualTypeOf>(); -expectTypeOf(device.setAttribute('pulsePwm', Int.from(10))).toEqualTypeOf>(); +// pulseFrequency / pulsePwm: Int (initialized attribute, always has a value) +expectTypeOf(device.setAttribute('pulseFrequency', Int.from(10))).toEqualTypeOf>(); +expectTypeOf(device.setAttribute('pulsePwm', Int.from(10))).toEqualTypeOf>(); // @ts-expect-error pulseFrequency does not accept a string value device.setAttribute('pulseFrequency', '10'); -// channelsJoined / highPowerMode: boolean | undefined -expectTypeOf(device.setAttribute('channelsJoined', true)).toEqualTypeOf>(); -expectTypeOf(device.setAttribute('highPowerMode', false)).toEqualTypeOf>(); +// channelsJoined / highPowerMode: boolean (initialized attribute, always has a value) +expectTypeOf(device.setAttribute('channelsJoined', true)).toEqualTypeOf>(); +expectTypeOf(device.setAttribute('highPowerMode', false)).toEqualTypeOf>(); // @ts-expect-error highPowerMode does not accept an Int value device.setAttribute('highPowerMode', Int.from(1)); -// batteryStatus: string | undefined -expectTypeOf(device.setAttribute('batteryStatus', 'mains')).toEqualTypeOf>(); +// batteryStatus: string (initialized attribute, always has a value) +expectTypeOf(device.setAttribute('batteryStatus', 'mains')).toEqualTypeOf>(); // @ts-expect-error batteryStatus does not accept a boolean value device.setAttribute('batteryStatus', true); diff --git a/tests/type/device/virtual/virtual.test-d.ts b/tests/type/device/virtual/virtual.test-d.ts index 1514e6f6..e191fafe 100644 --- a/tests/type/device/virtual/virtual.test-d.ts +++ b/tests/type/device/virtual/virtual.test-d.ts @@ -11,14 +11,14 @@ expectTypeOf(device.setAttribute('text', undefined)).toEqualTypeOf>(); -expectTypeOf(device.setAttribute('queuing', false)).toEqualTypeOf>(); +// speaking / queuing: boolean (initialized attribute, always has a value) +expectTypeOf(device.setAttribute('speaking', true)).toEqualTypeOf>(); +expectTypeOf(device.setAttribute('queuing', false)).toEqualTypeOf>(); // @ts-expect-error queuing does not accept a string value device.setAttribute('queuing', 'yes'); -// queueLength: Int | undefined -expectTypeOf(device.setAttribute('queueLength', Int.from(3))).toEqualTypeOf>(); +// queueLength: Int (initialized attribute, always has a value) +expectTypeOf(device.setAttribute('queueLength', Int.from(3))).toEqualTypeOf>(); // @ts-expect-error queueLength does not accept a boolean value device.setAttribute('queueLength', true); diff --git a/tests/type/device/zc95/zc95.test-d.ts b/tests/type/device/zc95/zc95.test-d.ts index a49e234c..16c64f95 100644 --- a/tests/type/device/zc95/zc95.test-d.ts +++ b/tests/type/device/zc95/zc95.test-d.ts @@ -20,8 +20,8 @@ expectTypeOf(device.setAttribute('powerChannel4', Int.from(50))).toEqualTypeOf

(dynamic, numeric suffix): Int | undefined -expectTypeOf(device.setAttribute('patternAttribute3', Int.from(10))).toEqualTypeOf>(); +// patternAttribute (dynamic, numeric suffix, initialized attribute, always has a value): Int +expectTypeOf(device.setAttribute('patternAttribute3', Int.from(10))).toEqualTypeOf>(); // @ts-expect-error patternAttribute suffix must be numeric device.setAttribute('patternAttributeFoo', Int.from(10)); diff --git a/tests/unit/device/attribute/floatDeviceAttribute.spec.ts b/tests/unit/device/attribute/floatDeviceAttribute.spec.ts new file mode 100644 index 00000000..7d958b94 --- /dev/null +++ b/tests/unit/device/attribute/floatDeviceAttribute.spec.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import FloatDeviceAttribute from '../../../../src/device/attribute/floatDeviceAttribute.js'; +import { DeviceAttributeModifier } from '../../../../src/device/attribute/deviceAttribute.js'; + +describe('FloatDeviceAttribute', () => { + + const attribute = FloatDeviceAttribute.create('attrName', undefined, DeviceAttributeModifier.readWrite, undefined); + + it.each([ + { value: 5.5, expected: true }, + { value: 0, expected: true }, + { value: -5.5, expected: true }, + { value: NaN, expected: false }, + { value: Infinity, expected: false }, + { value: -Infinity, expected: false }, + { value: '5.5', expected: false }, + { value: true, expected: false }, + ])('returns $expected for isValidValue($value)', ({ value, expected }) => { + expect(attribute.isValidValue(value)).toStrictEqual(expected); + }); +}); diff --git a/tests/unit/device/attribute/intDeviceAttribute.spec.ts b/tests/unit/device/attribute/intDeviceAttribute.spec.ts new file mode 100644 index 00000000..7481da4a --- /dev/null +++ b/tests/unit/device/attribute/intDeviceAttribute.spec.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import IntDeviceAttribute from '../../../../src/device/attribute/intDeviceAttribute.js'; +import { DeviceAttributeModifier } from '../../../../src/device/attribute/deviceAttribute.js'; + +describe('IntDeviceAttribute', () => { + + const attribute = IntDeviceAttribute.create('attrName', undefined, DeviceAttributeModifier.readWrite, undefined); + + it.each([ + { value: 5, expected: true }, + { value: 0, expected: true }, + { value: -5, expected: true }, + { value: 1.5, expected: false }, + { value: NaN, expected: false }, + { value: Infinity, expected: false }, + { value: -Infinity, expected: false }, + { value: '5', expected: false }, + { value: true, expected: false }, + ])('returns $expected for isValidValue($value)', ({ value, expected }) => { + expect(attribute.isValidValue(value)).toStrictEqual(expected); + }); +}); diff --git a/tests/unit/device/attribute/intRangeDeviceAttribute.spec.ts b/tests/unit/device/attribute/intRangeDeviceAttribute.spec.ts new file mode 100644 index 00000000..44c9e72e --- /dev/null +++ b/tests/unit/device/attribute/intRangeDeviceAttribute.spec.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import IntRangeDeviceAttribute from '../../../../src/device/attribute/intRangeDeviceAttribute.js'; +import { DeviceAttributeModifier } from '../../../../src/device/attribute/deviceAttribute.js'; +import { Int } from '../../../../src/util/numbers.js'; + +describe('IntRangeDeviceAttribute', () => { + + const attribute = IntRangeDeviceAttribute.create( + 'attrName', + undefined, + DeviceAttributeModifier.readWrite, + undefined, + Int.ZERO, + Int.from(100), + Int.from(1), + ); + + it.each([ + { value: 5, expected: true }, + { value: 0, expected: true }, + { value: -5, expected: true }, + { value: 1.5, expected: false }, + { value: NaN, expected: false }, + { value: Infinity, expected: false }, + { value: -Infinity, expected: false }, + { value: '5', expected: false }, + { value: true, expected: false }, + ])('returns $expected for isValidValue($value)', ({ value, expected }) => { + expect(attribute.isValidValue(value)).toStrictEqual(expected); + }); +}); diff --git a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts index 2ff31172..53a4c1a5 100644 --- a/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts +++ b/tests/unit/device/protocol/buttplugIo/buttplugIoDevice.spec.ts @@ -7,6 +7,7 @@ import ButtplugIoDevice, { ButtplugIoDeviceAttributes } from "../../../../../src/device/protocol/buttplugIo/buttplugIoDevice.js"; import {DeviceAttributeModifier} from "../../../../../src/device/attribute/deviceAttribute.js"; +import type {AnyDevice} from "../../../../../src/device/device.js"; import {Int} from "../../../../../src/util/numbers.js"; import {describe, it, expect} from "vitest"; import {mock} from "vitest-mock-extended"; @@ -52,7 +53,7 @@ describe('ButtplugIoDevice', () => { const buttplugDeviceMock = mock(); const boolAttrKey: ButtplugIoDeviceAttributeKey = 'Rotate-1'; - const boolAttr = BoolDeviceAttribute.create(boolAttrKey, undefined, DeviceAttributeModifier.readWrite); + const boolAttr = BoolDeviceAttribute.createInitialized(boolAttrKey, undefined, DeviceAttributeModifier.readWrite, true); const device = createDevice( buttplugDeviceMock, @@ -75,7 +76,7 @@ describe('ButtplugIoDevice', () => { const buttplugDeviceMock = mock(); const rangeAttrName: ButtplugIoDeviceAttributeKey = 'Vibrate-2'; - const rangeAttr = IntRangeDeviceAttribute.create( + const rangeAttr = IntRangeDeviceAttribute.createInitialized( rangeAttrName, undefined, DeviceAttributeModifier.readWrite, @@ -83,6 +84,7 @@ describe('ButtplugIoDevice', () => { Int.ZERO, Int.from(20), Int.from(1), + Int.ZERO, ); const device = createDevice( @@ -111,7 +113,7 @@ describe('ButtplugIoDevice', () => { // Arrange const buttplugDeviceMock = mock(); const boolAttrKey: ButtplugIoDeviceAttributeKey = 'Rotate-1'; - const boolAttr = BoolDeviceAttribute.create(boolAttrKey, undefined, DeviceAttributeModifier.readWrite); + const boolAttr = BoolDeviceAttribute.createInitialized(boolAttrKey, undefined, DeviceAttributeModifier.readWrite, false); const device = createDevice(buttplugDeviceMock, {[boolAttrKey]: boolAttr}); // Act @@ -128,7 +130,7 @@ describe('ButtplugIoDevice', () => { // Arrange const buttplugDeviceMock = mock(); const attrKey: ButtplugIoDeviceAttributeKey = 'Vibrate-1'; - const readOnlyAttr = BoolDeviceAttribute.create(attrKey, undefined, DeviceAttributeModifier.readOnly); + const readOnlyAttr = BoolDeviceAttribute.createInitialized(attrKey, undefined, DeviceAttributeModifier.readOnly, false); const device = createDevice(buttplugDeviceMock, {[attrKey]: readOnlyAttr}); // Act @@ -144,7 +146,7 @@ describe('ButtplugIoDevice', () => { // Arrange const buttplugDeviceMock = mock(); const sensorAttrKey: ButtplugIoDeviceAttributeKey = 'Battery-1'; - const attr = BoolDeviceAttribute.create(sensorAttrKey, undefined, DeviceAttributeModifier.readWrite); + const attr = BoolDeviceAttribute.createInitialized(sensorAttrKey, undefined, DeviceAttributeModifier.readWrite, false); const device = createDevice(buttplugDeviceMock, {[sensorAttrKey]: attr}); // Act @@ -160,11 +162,16 @@ describe('ButtplugIoDevice', () => { // Arrange const buttplugDeviceMock = mock(); const attrKey: ButtplugIoDeviceAttributeKey = 'Vibrate-1'; - const attr = BoolDeviceAttribute.create(attrKey, undefined, DeviceAttributeModifier.readWrite); + const attr = BoolDeviceAttribute.createInitialized(attrKey, undefined, DeviceAttributeModifier.readWrite, false); const device = createDevice(buttplugDeviceMock, {[attrKey]: attr}); + // Go through the untyped device interface: this exercises the runtime guard that protects + // against callers (e.g. automation scripts) that aren't bound by the typed setAttribute + // overload, since TypeScript itself now rejects `undefined` here for a typed attribute. + const untypedDevice: AnyDevice = device; + // Act - const result = device.setAttribute(attrKey, undefined); + const result = untypedDevice.setAttribute(attrKey, undefined); // Assert await expect(result).rejects.toThrow(`Value to be set for attribute '${attrKey}' cannot be undefined`); diff --git a/tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts b/tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts index f8af4212..03a69012 100644 --- a/tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts +++ b/tests/unit/device/protocol/estim2b/estim2bDevice.spec.ts @@ -49,12 +49,12 @@ describe('EStim2bDevice', () => { ]; return { - mode: ListDeviceAttribute.create('mode', 'Mode', DeviceAttributeModifier.readWrite, modeOptions), - channelALevel: IntRangeDeviceAttribute.create('channelALevel', 'Channel A', DeviceAttributeModifier.readWrite, undefined, Int.ZERO, Int.from(99), Int.from(1)), - channelBLevel: IntRangeDeviceAttribute.create('channelBLevel', 'Channel B', DeviceAttributeModifier.readWrite, undefined, Int.ZERO, Int.from(99), Int.from(1)), - channelsJoined: BoolDeviceAttribute.create('channelsJoined', 'Channels Joined', DeviceAttributeModifier.readOnly), - highPowerMode: BoolDeviceAttribute.create('highPowerMode', 'High Power Mode', DeviceAttributeModifier.readWrite), - batteryStatus: StrDeviceAttribute.create('batteryStatus', 'Battery', DeviceAttributeModifier.readOnly), + mode: ListDeviceAttribute.createInitialized('mode', 'Mode', DeviceAttributeModifier.readWrite, modeOptions, Int.from(EStim2bMode.pulse)), + channelALevel: IntRangeDeviceAttribute.createInitialized('channelALevel', 'Channel A', DeviceAttributeModifier.readWrite, undefined, Int.ZERO, Int.from(99), Int.from(1), Int.ZERO), + channelBLevel: IntRangeDeviceAttribute.createInitialized('channelBLevel', 'Channel B', DeviceAttributeModifier.readWrite, undefined, Int.ZERO, Int.from(99), Int.from(1), Int.ZERO), + channelsJoined: BoolDeviceAttribute.createInitialized('channelsJoined', 'Channels Joined', DeviceAttributeModifier.readOnly, false), + highPowerMode: BoolDeviceAttribute.createInitialized('highPowerMode', 'High Power Mode', DeviceAttributeModifier.readWrite, false), + batteryStatus: StrDeviceAttribute.createInitialized('batteryStatus', 'Battery', DeviceAttributeModifier.readOnly, ''), }; } diff --git a/tests/unit/device/protocol/zc95/zc95Device.spec.ts b/tests/unit/device/protocol/zc95/zc95Device.spec.ts index f1a625ab..c82ba28c 100644 --- a/tests/unit/device/protocol/zc95/zc95Device.spec.ts +++ b/tests/unit/device/protocol/zc95/zc95Device.spec.ts @@ -60,8 +60,11 @@ describe('Zc95Device', () => { } function createPowerChannelAttrs(): Zc95DevicePowerChannelAttributes { - const makeAttr = (ch: number) => - IntRangeDeviceAttribute.createInitialized( + // Power channel attributes are constructed unset in production (see + // Zc95Device.getChannelPowerAttribute()) and only get a value once a power status + // message has been received, so we mirror that here rather than using createInitialized(). + const makeAttr = (ch: number) => { + const attr = IntRangeDeviceAttribute.create( `powerChannel${ch}`, `Channel ${ch}`, DeviceAttributeModifier.readWrite, @@ -69,8 +72,10 @@ describe('Zc95Device', () => { Int.ZERO, Int.from(100), Int.from(1), - Int.from(10), ); + attr.value = Int.from(10); + return attr; + }; return { powerChannel1: makeAttr(1), @@ -693,7 +698,7 @@ describe('Zc95Device', () => { mockProtocol.decode.mockReturnValue({ message: powerStatusMsg }); const overLimitAttrs = createPowerChannelAttrs(); - overLimitAttrs.powerChannel1 = IntRangeDeviceAttribute.createInitialized( + overLimitAttrs.powerChannel1 = IntRangeDeviceAttribute.create( 'powerChannel1', 'Channel 1', DeviceAttributeModifier.readWrite, @@ -701,8 +706,8 @@ describe('Zc95Device', () => { Int.ZERO, Int.from(100), Int.from(1), - Int.from(90), // current value 90 was above the new power limit of 70 ); + overLimitAttrs.powerChannel1.value = Int.from(90); // current value 90 was above the new power limit of 70 const device = createDevice({ activePattern: createActivePatternAttr(),