Skip to content

Commit

Permalink
refactor: move devices:capability-status command to yargs
Browse files Browse the repository at this point in the history
rossiam committed Oct 3, 2024
1 parent c1f3dea commit e8a7bc2
Showing 20 changed files with 616 additions and 307 deletions.
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -58,7 +58,7 @@
},
"dependencies": {
"@aws-sdk/client-lambda": "^3.654.0",
"@smartthings/core-sdk": "8.3.1",
"@smartthings/core-sdk": "^8.3.2",
"axios": "1.7.7",
"chalk": "^5.3.0",
"eventsource": "^2.0.2",
108 changes: 0 additions & 108 deletions packages/cli/src/__tests__/commands/devices/capability-status.test.ts

This file was deleted.

31 changes: 0 additions & 31 deletions packages/cli/src/__tests__/lib/commands/devices-util.test.ts
Original file line number Diff line number Diff line change
@@ -6,7 +6,6 @@ import {
buildEmbeddedStatusTableOutput,
buildStatusTableOutput,
buildTableOutput,
prettyPrintAttribute,
} from '../../../lib/commands/devices-util.js'


@@ -26,36 +25,6 @@ const tableGeneratorMock: TableGenerator = {
buildTableFromList: buildTableFromListMock,
}

describe('prettyPrintAttribute', () => {
it ('handles integer value', () => {
expect(prettyPrintAttribute(100)).toEqual('100')
})

it ('handles decimal value', () => {
expect(prettyPrintAttribute(21.5)).toEqual('21.5')
})

it ('handles string value', () => {
expect(prettyPrintAttribute('active')).toEqual('"active"')
})

it ('handles object value', () => {
expect(prettyPrintAttribute({ x: 1, y: 2 })).toEqual('{"x":1,"y":2}')
})

it ('handles large object value', () => {
const value = {
name: 'Entity name',
id: 'entity-id',
description: 'This is a test entity. It serves no other purpose other than to be used in this test.',
version: 1,
precision: 120.375,
}
const expectedResult = JSON.stringify(value, null, 2)
expect(prettyPrintAttribute(value)).toEqual(expectedResult)
})
})

describe('buildStatusTableOutput', () => {

it('handles a single component', () => {
71 changes: 0 additions & 71 deletions packages/cli/src/commands/devices/capability-status.ts

This file was deleted.

4 changes: 2 additions & 2 deletions packages/cli/src/commands/devices/component-status.ts
Original file line number Diff line number Diff line change
@@ -14,8 +14,8 @@ function buildTableOutput(tableGenerator: TableGenerator, component: ComponentSt
table.push([
capabilityName,
attributeName,
attribute.value !== null ?
`${prettyPrintAttribute(attribute.value)}${attribute.unit ? ' ' + attribute.unit : ''}` : ''])
prettyPrintAttribute(attribute),
])
}
}
return table.toString()
21 changes: 8 additions & 13 deletions packages/cli/src/lib/commands/devices-util.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import { Device, DeviceHealth, DeviceStatus } from '@smartthings/core-sdk'
import { AttributeState, Device, DeviceHealth, DeviceStatus } from '@smartthings/core-sdk'

import { TableGenerator, WithNamedRoom } from '@smartthings/cli-lib'


export type DeviceWithLocation = Device & { location?: string }

export const prettyPrintAttribute = (value: unknown): string => {
let result = JSON.stringify(value)
if (result.length > 50) {
result = JSON.stringify(value, null, 2)
}
return result
}

export const buildStatusTableOutput = (tableGenerator: TableGenerator, data: DeviceStatus): string => {
let output = ''
if (data.components) {
@@ -30,8 +22,8 @@ export const buildStatusTableOutput = (tableGenerator: TableGenerator, data: Dev
table.push([
capabilityName,
attributeName,
attribute.value !== null ?
`${prettyPrintAttribute(attribute.value)}${attribute.unit ? ' ' + attribute.unit : ''}` : ''])
prettyPrintAttribute(attribute),
])
}
}
output += table.toString()
@@ -65,8 +57,8 @@ export const buildEmbeddedStatusTableOutput = (tableGenerator: TableGenerator, d
table.push([
capability.id,
attributeName,
attribute.value !== null ?
`${prettyPrintAttribute(attribute.value)}${attribute.unit ? ' ' + attribute.unit : ''}` : ''])
prettyPrintAttribute(attribute),
])
}
}
}
@@ -234,3 +226,6 @@ export const buildTableOutput = (tableGenerator: TableGenerator, device: Device
(statusInfo ? `\n\nDevice Status\n${statusInfo}` : '') +
(infoFrom ? `\n\nDevice Integration Info (from ${infoFrom})\n${deviceIntegrationInfo}` : '')
}
function prettyPrintAttribute(attribute: AttributeState): any {
throw new Error('Function not implemented.')
}
24 changes: 0 additions & 24 deletions packages/cli/src/lib/commands/virtualdevices-util.ts
Original file line number Diff line number Diff line change
@@ -127,30 +127,6 @@ export async function chooseLocallyExecutingDevicePrototype(command: APICommand<
})
}

export const chooseComponent = async (command: APICommand<typeof APICommand.flags>, device: Device): Promise<Component> => {
let component
if (device.components) {

const config: SelectFromListConfig<Component> = {
itemName: 'component',
primaryKeyName: 'id',
sortKeyName: 'id',
listTableFieldDefinitions: ['id'],
}

const listItems = async (): Promise<Component[]> => Promise.resolve(device.components || [])
const preselectedId = device.components.length === 1 ? device.components[0].id : undefined
const componentId = await selectFromList(command, config, { preselectedId, listItems })
component = device.components.find(comp => comp.id == componentId)
}

if (!component) {
throw new Error('Component not found')
}

return component
}

export const chooseCapability = async (command: APICommand<typeof APICommand.flags>, component: Component): Promise<CapabilityReference> => {
const config: SelectFromListConfig<CapabilityReference> = {
itemName: 'capability',
22 changes: 0 additions & 22 deletions packages/lib/src/device-util.ts

This file was deleted.

245 changes: 245 additions & 0 deletions src/__tests__/commands/devices/capability-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import { jest } from '@jest/globals'

import type { ArgumentsCamelCase, Argv } from 'yargs'

import type {
CapabilityStatus,
Component,
Device,
DevicesEndpoint,
} from '@smartthings/core-sdk'

import type { CommandArgs } from '../../../commands/devices/capability-status.js'
import type {
APICommand,
apiCommand,
apiCommandBuilder,
apiDocsURL,
} from '../../../lib/command/api-command.js'
import type { stringTranslateToId } from '../../../lib/command/command-util.js'
import type {
CustomCommonOutputProducer,
formatAndWriteItem,
formatAndWriteItemBuilder,
formatAndWriteList,
} from '../../../lib/command/format.js'
import type { BuildOutputFormatterFlags } from '../.././../lib/command/output-builder.js'
import type { selectFromList } from '../../../lib/command/select.js'
import type { SmartThingsCommandFlags } from '../../../lib/command/smartthings-command.js'
import {
chooseComponentFn,
prettyPrintAttribute,
} from '../../../lib/command/util/devices-util.js'
import type { ChooseFunction } from '../../../lib/command/util/util-util.js'
import { buildArgvMock, buildArgvMockStub } from '../../test-lib/builder-mock.js'
import {
mockedTableOutput,
newOutputTableMock,
tableGeneratorMock,
tablePushMock,
tableToStringMock,
} from '../../test-lib/table-mock.js'
import type { fatalError } from '../../../lib/util.js'


const apiCommandMock = jest.fn<typeof apiCommand>()
const apiCommandBuilderMock = jest.fn<typeof apiCommandBuilder>()
const apiDocsURLMock = jest.fn<typeof apiDocsURL>()
jest.unstable_mockModule('../../../lib/command/api-command.js', () => ({
apiCommand: apiCommandMock,
apiCommandBuilder: apiCommandBuilderMock,
apiDocsURL: apiDocsURLMock,
}))

const stringTranslateToIdMock = jest.fn<typeof stringTranslateToId>()
jest.unstable_mockModule('../../../lib/command/command-util.js', () => ({
stringTranslateToId: stringTranslateToIdMock,
}))

const selectFromListMock = jest.fn<typeof selectFromList>()
jest.unstable_mockModule('../../../lib/command/select.js', () => ({
selectFromList: selectFromListMock,
}))

const chooseComponentMock = jest.fn<ChooseFunction<Component>>()
const chooseComponentFnMock = jest.fn<typeof chooseComponentFn>()
.mockReturnValue(chooseComponentMock)
const chooseDeviceMock = jest.fn<ChooseFunction<Device>>().mockResolvedValue('chosen-device-id')
const prettyPrintAttributeMock = jest.fn<typeof prettyPrintAttribute>()
jest.unstable_mockModule('../../../lib/command/util/devices-util.js', () => ({
chooseComponentFn: chooseComponentFnMock,
chooseDevice: chooseDeviceMock,
prettyPrintAttribute: prettyPrintAttributeMock,
}))

const formatAndWriteItemMock = jest.fn<typeof formatAndWriteItem<CapabilityStatus>>()
const formatAndWriteItemBuilderMock = jest.fn<typeof formatAndWriteItemBuilder>()
const formatAndWriteListMock = jest.fn<typeof formatAndWriteList<CapabilityStatus>>()
jest.unstable_mockModule('../../../lib/command/format.js', () => ({
formatAndWriteItem: formatAndWriteItemMock,
formatAndWriteItemBuilder: formatAndWriteItemBuilderMock,
formatAndWriteList: formatAndWriteListMock,
}))

const fatalErrorMock = jest.fn<typeof fatalError>()
jest.unstable_mockModule('../../../lib/util.js', () => ({
fatalError: fatalErrorMock,
}))


const {
default: cmd,
buildTableOutput,
} = await import('../../../commands/devices/capability-status.js')

test('builder', () => {
const yargsMock = buildArgvMockStub<object>()
const {
yargsMock: apiCommandBuilderArgvMock,
positionalMock,
exampleMock,
argvMock,
epilogMock,
} = buildArgvMock<SmartThingsCommandFlags, BuildOutputFormatterFlags>()

apiCommandBuilderMock.mockReturnValue(apiCommandBuilderArgvMock)
formatAndWriteItemBuilderMock.mockReturnValue(argvMock)

const builder = cmd.builder as (yargs: Argv<object>) => Argv<CommandArgs>

expect(builder(yargsMock)).toBe(argvMock)

expect(apiCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock)
expect(formatAndWriteItemBuilderMock).toHaveBeenCalledExactlyOnceWith(apiCommandBuilderArgvMock)
expect(positionalMock).toHaveBeenCalledTimes(3)
expect(exampleMock).toHaveBeenCalledOnce()
expect(epilogMock).toHaveBeenCalledOnce()
})

test('buildTableOutput', () => {
prettyPrintAttributeMock.mockReturnValueOnce('pretty value 1')
prettyPrintAttributeMock.mockReturnValueOnce('pretty value 2')
const capability: CapabilityStatus = {
attribute1: { value: 'value of attribute 1' },
attribute2: { value: 5 },
}

expect(buildTableOutput(tableGeneratorMock, capability)).toBe(mockedTableOutput)

expect(newOutputTableMock).toHaveBeenCalledExactlyOnceWith({ head: ['Attribute', 'Value'] })
expect(prettyPrintAttributeMock).toHaveBeenCalledTimes(2)
expect(prettyPrintAttributeMock).toHaveBeenCalledWith(capability.attribute1)
expect(prettyPrintAttributeMock).toHaveBeenCalledWith(capability.attribute2)
expect(tablePushMock).toHaveBeenCalledTimes(2)
expect(tablePushMock).toHaveBeenCalledWith(['attribute1', 'pretty value 1'])
expect(tablePushMock).toHaveBeenCalledWith(['attribute2', 'pretty value 2'])
expect(tableToStringMock).toHaveBeenCalledExactlyOnceWith()
})

describe('handler', () => {
const device = {
deviceId: 'device-id',
components: [
{ id: 'main', capabilities: [{ id: 'switch' }] },
{ id: 'sans-capabilities' },
],
} as unknown as Device
const capabilityStatus = { attribute: { value: 'attribute-value' } } as CapabilityStatus
const apiDevicesGetMock = jest.fn<typeof DevicesEndpoint.prototype.get>()
.mockResolvedValue(device)
const apiDevicesGetCapabilityStatusMock = jest.fn<typeof DevicesEndpoint.prototype.getCapabilityStatus>()
.mockResolvedValue(capabilityStatus)
const command = {
client: {
devices: {
get: apiDevicesGetMock,
getCapabilityStatus: apiDevicesGetCapabilityStatusMock,
},
},
tableGenerator: tableGeneratorMock,
} as unknown as APICommand<CommandArgs>
apiCommandMock.mockResolvedValue(command)
const inputArgv = {
deviceIdOrIndex: 'argv-id-or-index',
componentId: 'argv-component-id',
capabilityId: 'argv-capability-id',
profile: 'default',
} as ArgumentsCamelCase<CommandArgs>

it('throws exception for component with no capabilities', async () => {
chooseComponentMock.mockResolvedValueOnce('sans-capabilities')

await expect(cmd.handler(inputArgv)).resolves.not.toThrow()

expect(apiCommandMock).toHaveBeenCalledExactlyOnceWith(inputArgv)
expect(chooseDeviceMock).toHaveBeenCalledExactlyOnceWith(
command,
'argv-id-or-index',
{ allowIndex: true },
)
expect(apiDevicesGetMock).toHaveBeenCalledExactlyOnceWith('chosen-device-id')
expect(chooseComponentFnMock).toHaveBeenCalledExactlyOnceWith(device)
expect(chooseComponentMock).toHaveBeenCalledExactlyOnceWith(
command,
'argv-component-id',
{ autoChoose: true },
)

expect(fatalErrorMock).toHaveBeenCalledExactlyOnceWith(
'no capabilities found for component sans-capabilities of device chosen-device-id',
)
expect(formatAndWriteItemMock).not.toHaveBeenCalled()
})

it('calls formatAndWriteItem properly', async () => {
chooseComponentMock.mockResolvedValueOnce('main')
stringTranslateToIdMock.mockResolvedValueOnce('preselected-capability-id')
selectFromListMock.mockResolvedValueOnce('chosen-capability-id')

await expect(cmd.handler(inputArgv)).resolves.not.toThrow()

expect(apiCommandMock).toHaveBeenCalledExactlyOnceWith(inputArgv)
expect(chooseDeviceMock).toHaveBeenCalledExactlyOnceWith(
command,
'argv-id-or-index',
{ allowIndex: true },
)
expect(apiDevicesGetMock).toHaveBeenCalledExactlyOnceWith('chosen-device-id')
expect(chooseComponentFnMock).toHaveBeenCalledExactlyOnceWith(device)
expect(chooseComponentMock).toHaveBeenCalledExactlyOnceWith(
command,
'argv-component-id',
{ autoChoose: true },
)
expect(stringTranslateToIdMock).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ itemName: 'capability' }),
'argv-capability-id',
expect.any(Function),
)
expect(selectFromListMock).toHaveBeenCalledExactlyOnceWith(
command,
expect.objectContaining({ itemName: 'capability' }),
{ preselectedId: 'preselected-capability-id', listItems: expect.any(Function) },
)
expect(apiDevicesGetCapabilityStatusMock).toHaveBeenCalledExactlyOnceWith(
'chosen-device-id',
'main',
'chosen-capability-id',
)
expect(formatAndWriteItemMock).toHaveBeenCalledExactlyOnceWith(
command,
{ buildTableOutput: expect.any(Function) },
capabilityStatus,
)

expect(fatalErrorMock).not.toHaveBeenCalled()

const config = formatAndWriteItemMock.mock.calls[0][1] as
CustomCommonOutputProducer<CapabilityStatus>
expect(config.buildTableOutput(capabilityStatus)).toBe(mockedTableOutput)

const listItems = stringTranslateToIdMock.mock.calls[0][2]

expect(await listItems()).toBe(device.components?.[0].capabilities)
})
})
25 changes: 9 additions & 16 deletions src/__tests__/commands/devices/preferences.test.ts
Original file line number Diff line number Diff line change
@@ -14,8 +14,14 @@ import type { CommandArgs } from '../../../commands/apps.js'
import type { BuildOutputFormatterFlags } from '../.././../lib/command/output-builder.js'
import type { SmartThingsCommandFlags } from '../../../lib/command/smartthings-command.js'
import type { ChooseFunction } from '../../../lib/command/util/util-util.js'
import type { Table, TableGenerator } from '../../../lib/table-generator.js'
import { buildArgvMock, buildArgvMockStub } from '../../test-lib/builder-mock.js'
import {
mockedTableOutput,
newOutputTableMock,
tableGeneratorMock,
tablePushMock,
tableToStringMock,
} from '../../test-lib/table-mock.js'


const apiCommandMock = jest.fn<typeof apiCommand>()
@@ -63,15 +69,6 @@ test('builder', () => {
expect(exampleMock).toHaveBeenCalledOnce()
})

const tablePushMock = jest.fn<Table['push']>()
const tableToStringMock = jest.fn<Table['toString']>().mockReturnValue('table output')
const tableMock: Table = {
push: tablePushMock,
toString: tableToStringMock,
}
const newOutputTableMock = jest.fn<TableGenerator['newOutputTable']>().mockReturnValue(tableMock)
const tableGeneratorMock = { newOutputTable: newOutputTableMock } as unknown as TableGenerator

const preferences: DevicePreferenceResponse = {
values: {
key0: {
@@ -90,10 +87,6 @@ const preferences: DevicePreferenceResponse = {
}

describe('buildTableOutput', () => {
it('works', () => {
expect(true).toBeTruthy()
})

it('skips table entirely when there is no data', () => {
expect(buildTableOutput(tableGeneratorMock, {} as DevicePreferenceResponse)).toBe('')

@@ -103,7 +96,7 @@ describe('buildTableOutput', () => {
})

it('sorts by key', () => {
expect(buildTableOutput(tableGeneratorMock, preferences)).toBe('table output')
expect(buildTableOutput(tableGeneratorMock, preferences)).toBe(mockedTableOutput)

expect(newOutputTableMock).toHaveBeenCalledOnce()
expect(tablePushMock).toHaveBeenCalledTimes(3)
@@ -150,5 +143,5 @@ test('handler', async () => {

const config = formatAndWriteItemMock.mock.calls[0][1] as CustomCommonOutputProducer<DevicePreferenceResponse>

expect(config.buildTableOutput(preferences)).toBe('table output')
expect(config.buildTableOutput(preferences)).toBe(mockedTableOutput)
})
19 changes: 11 additions & 8 deletions src/__tests__/lib/command/util/apps-util.test.ts
Original file line number Diff line number Diff line change
@@ -4,7 +4,6 @@ import { AppResponse, AppsEndpoint, AppType, PagedApp, SmartThingsClient } from

import {
PropertyTableFieldDefinition,
Table,
TableGenerator,
ValueTableFieldDefinition,
} from '../../../../lib/table-generator.js'
@@ -14,6 +13,12 @@ import {
type ChooseFunction,
chooseOptionsWithDefaults,
} from '../../../../lib/command/util/util-util.js'
import {
mockedTableOutput,
tableMock,
tablePushMock,
tableToStringMock,
} from '../../../test-lib/table-mock.js'


const stringTranslateToIdMock = jest.fn<typeof stringTranslateToId>()
@@ -180,17 +185,15 @@ describe('buildTableOutput', () => {
})

it('creates new table with correct options and adds settings', () => {
const pushMock = jest.fn()
const toStringMock = jest.fn().mockReturnValue('table output')
const newTable = { push: pushMock, toString: toStringMock } as Table
newOutputTableMock.mockReturnValueOnce(newTable)
newOutputTableMock.mockReturnValueOnce(tableMock)

expect(buildTableOutput(mockTableGenerator, { settings: { setting: 'setting value' } })).toEqual('table output')
expect(buildTableOutput(mockTableGenerator, { settings: { setting: 'setting value' } }))
.toBe(mockedTableOutput)
expect(newOutputTableMock).toHaveBeenCalledWith(
expect.objectContaining({ head: ['Key', 'Value'] }),
)
expect(pushMock).toHaveBeenCalledExactlyOnceWith(['setting', 'setting value'])
expect(toStringMock).toHaveBeenCalledExactlyOnceWith()
expect(tablePushMock).toHaveBeenCalledExactlyOnceWith(['setting', 'setting value'])
expect(tableToStringMock).toHaveBeenCalledExactlyOnceWith()
})
})

Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@ import { ValueTableFieldDefinition } from '../../../../lib/table-generator.js'


const selectFromListMock = jest.fn<typeof selectFromList>()
jest.unstable_mockModule('../../../../lib/command/select', () => ({
jest.unstable_mockModule('../../../../lib/command/select.js', () => ({
selectFromList: selectFromListMock,
}))

117 changes: 115 additions & 2 deletions src/__tests__/lib/command/util/devices-util.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import { jest } from '@jest/globals'

import type { Device, DeviceListOptions, DevicesEndpoint, SmartThingsClient } from '@smartthings/core-sdk'
import type {
AttributeState,
Component,
Device,
DeviceListOptions,
DevicesEndpoint,
SmartThingsClient,
} from '@smartthings/core-sdk'
import { APICommand } from '../../../../lib/command/api-command.js'
import type { stringTranslateToId } from '../../../../lib/command/command-util.js'
import { TableCommonListOutputProducer } from '../../../../lib/command/format.js'
import { BuildOutputFormatterFlags } from '../../../../lib/command/output-builder.js'
import type { createChooseFn, ChooseFunction } from '../../../../lib/command/util/util-util.js'
import { ValueTableFieldDefinition } from '../../../../lib/table-generator.js'


const stringTranslateToIdMock = jest.fn<typeof stringTranslateToId>()
@@ -16,7 +27,11 @@ jest.unstable_mockModule('../../../../lib/command/util/util-util.js', () => ({
}))


const { chooseDeviceFn } = await import('../../../../lib/command/util/devices-util.js')
const {
chooseComponentFn,
chooseDeviceFn,
prettyPrintAttribute,
} = await import('../../../../lib/command/util/devices-util.js')

describe('chooseDeviceFn', () => {
const chooseDeviceMock = jest.fn<ChooseFunction<Device>>()
@@ -68,3 +83,101 @@ describe('chooseDeviceFn', () => {
expect(apiDevicesListMock).toHaveBeenCalledExactlyOnceWith(deviceListOptions)
})
})

describe('chooseComponentFn', () => {
const chooseComponentMock = jest.fn<ChooseFunction<Component>>()
// `createChooseFnMock` has its generic typed to `Device` for chooseDeviceFn;
// cast to use `Component` for this chooseComponentFn tests.
const createChooseFnMockForComponent =
createChooseFnMock as unknown as jest.Mock<typeof createChooseFn<Component>>
const components = [{ id: 'main' }, { id: 'other-component' }]
const device = { deviceId: 'device-id', components } as Device

it('uses list of components from device for selection list', async () => {
createChooseFnMockForComponent.mockReturnValueOnce(chooseComponentMock)
const chooseComponent = chooseComponentFn(device)

expect(chooseComponent).toBe(chooseComponentMock)

expect(createChooseFnMockForComponent).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ itemName: 'component' }),
expect.any(Function),
)

const listItems = createChooseFnMockForComponent.mock.calls[0][1]

const client = {} as unknown as SmartThingsClient
expect(await listItems(client)).toBe(components)
})

it('includes " (default)" for main component', async () => {
createChooseFnMockForComponent.mockReturnValueOnce(chooseComponentMock)

expect(chooseComponentFn(device)).toBe(chooseComponentMock)

const fieldDefinition =
(createChooseFnMockForComponent.mock.calls[0][0] as TableCommonListOutputProducer<Component>)
.listTableFieldDefinitions[0] as ValueTableFieldDefinition<Component>

const valueFunction = fieldDefinition.value

expect(valueFunction({ id: 'main' } as Component)).toBe('main (default)')
expect(valueFunction({ id: 'other' } as Component)).toBe('other')
})

const devicesWithNoComponents = [
{},
{ components: undefined },
{ components: null },
{ components: [] },
] as Device[]
it.each(devicesWithNoComponents)(
'returns main for device with no components %#',
async (device) => {
const command = {} as APICommand<BuildOutputFormatterFlags>

// This is the default
const chooseComponent = chooseComponentFn(device)
expect(createChooseFnMock).not.toHaveBeenCalled()
expect(await chooseComponent(command)).toBe('main')

// But it can also be explicitly requested
const chooseComponent2 = chooseComponentFn(device, { defaultToMain: true })
expect(createChooseFnMock).not.toHaveBeenCalled()
expect(await chooseComponent2(command)).toBe('main')
},
)

it.each(devicesWithNoComponents)(
'throws for device with no components by not defaulting to main %#',
device => {
expect(() => chooseComponentFn(device, { defaultToMain: false }))
.toThrow('No components found')
},
)
})

const complicatedAttribute: AttributeState = {
value: {
name: 'Entity name',
id: 'entity-id',
description: 'It is very big and huge and long so the serialized JSON is over 50 characters.',
version: 1,
precision: 120.375,
},
}
const prettyPrintedComplicatedAttribute = JSON.stringify(complicatedAttribute.value, null, 2)

test.each([
{ attribute: { value: null }, expected: '' },
{ attribute: { value: undefined }, expected: '' },
{ attribute: {}, expected: '' },
{ attribute: { value: 100 }, expected: '100' },
{ attribute: { value: 128, unit: 'yobibytes' }, expected: '128 yobibytes' },
{ attribute: { value: 21.5 }, expected: '21.5' },
{ attribute: { value: 'active' }, expected: '"active"' },
{ attribute: { value: { x: 1, y: 2 } }, expected: '{"x":1,"y":2}' },
{ attribute: complicatedAttribute, expected: prettyPrintedComplicatedAttribute },
])('prettyPrintAttribute returns $expected when given $attribute', ({ attribute, expected }) => {
expect(prettyPrintAttribute(attribute)).toBe(expected)
})
24 changes: 23 additions & 1 deletion src/__tests__/lib/util.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { clipToMaximum, delay, sanitize, stringFromUnknown } from '../../lib/util.js'
import { jest } from '@jest/globals'

import { clipToMaximum, delay, fatalError, sanitize, stringFromUnknown } from '../../lib/util.js'


describe('stringFromUnknown', () => {
@@ -48,3 +50,23 @@ test('delay', async () => {
await delay(3)
expect(new Date().getTime()).toBeGreaterThanOrEqual(beforeDate + 2)
})

describe('fatalError', () => {
const exitSpy = jest.spyOn(process, 'exit')
// fake exiting with a special thrown error
.mockImplementation(() => { throw Error('should exit') })
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { /*no-op*/ })

it('prints message when given', () => {
expect(() => fatalError('message for user')).toThrow('should exit')

expect(consoleErrorSpy).toHaveBeenCalledWith('message for user')
expect(exitSpy).toHaveBeenCalledExactlyOnceWith(1)
})

it('uses specified code when given', () => {
expect(() => fatalError(undefined, 12)).toThrow('should exit')

expect(exitSpy).toHaveBeenCalledExactlyOnceWith(12)
})
})
14 changes: 14 additions & 0 deletions src/__tests__/test-lib/table-mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { jest } from '@jest/globals'

import type { Table, TableGenerator } from '../../lib/table-generator.js'


export const mockedTableOutput = 'table output'
export const tablePushMock = jest.fn<Table['push']>()
export const tableToStringMock = jest.fn<Table['toString']>().mockReturnValue(mockedTableOutput)
export const tableMock: Table = {
push: tablePushMock,
toString: tableToStringMock,
}
export const newOutputTableMock = jest.fn<TableGenerator['newOutputTable']>().mockReturnValue(tableMock)
export const tableGeneratorMock = { newOutputTable: newOutputTableMock } as unknown as TableGenerator
104 changes: 104 additions & 0 deletions src/commands/devices/capability-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs'

import { type CapabilityReference, type CapabilityStatus } from '@smartthings/core-sdk'

import {
apiCommand,
apiCommandBuilder,
apiDocsURL,
type APICommandFlags,
} from '../../lib/command/api-command.js'
import { stringTranslateToId } from '../../lib/command/command-util.js'
import {
formatAndWriteItem,
formatAndWriteItemBuilder,
type FormatAndWriteItemFlags,
} from '../../lib/command/format.js'
import { selectFromList, type SelectFromListConfig } from '../../lib/command/select.js'
import {
chooseComponentFn,
chooseDevice,
prettyPrintAttribute,
} from '../../lib/command/util/devices-util.js'
import { type TableGenerator } from '../../lib/table-generator.js'
import { fatalError } from '../../lib/util.js'


export type CommandArgs = APICommandFlags & FormatAndWriteItemFlags & {
deviceIdOrIndex?: string
componentId?: string
capabilityId?: string
}

const command = 'devices:capability-status [device-id-or-index] [component-id] [capability-id]'

const describe = "get the current status of all of a device capability's attributes"

const builder = (yargs: Argv): Argv<CommandArgs> =>
formatAndWriteItemBuilder(apiCommandBuilder(yargs))
.positional('device-id-or-index',
{ describe: 'device id or index in list from devices command', type: 'string' })
.positional('component-id', { describe: 'component id', type: 'string' })
.positional('capability-id', { describe: 'capability id', type: 'string' })
.example([
['$0 devices:capability-status',
'prompt for a device, component, and capability, then display its status'],
['$0 devices:capability-status fa1eb54c-c571-405f-8817-ffb7cd2f5a9d',
'prompt for a component and capability for the specified device'],
['$0 devices:capability-status fa1eb54c-c571-405f-8817-ffb7cd2f5a9d main switch',
'display the status for the specified device, component, and capability'],
])
.epilog(apiDocsURL('getDeviceStatusByCapability'))

export const buildTableOutput = (
tableGenerator: TableGenerator,
capability: CapabilityStatus,
): string => {
const table = tableGenerator.newOutputTable({ head: ['Attribute', 'Value'] })

for (const attributeName of Object.keys(capability)) {
table.push([attributeName, prettyPrintAttribute(capability[attributeName])])
}

return table.toString()
}

const handler = async (argv: ArgumentsCamelCase<CommandArgs>): Promise<void> => {
const command = await apiCommand(argv)

const deviceId = await chooseDevice(command, argv.deviceIdOrIndex, { allowIndex: true })

const device = await command.client.devices.get(deviceId)
const chooseComponent = chooseComponentFn(device)
const componentId = await chooseComponent(command, argv.componentId, { autoChoose: true })

const component = device.components?.find(component => component.id === componentId)
const capabilities = component?.capabilities
if (!capabilities) {
return fatalError(`no capabilities found for component ${componentId} of device ${deviceId}`)
}

const config: SelectFromListConfig<CapabilityReference> = {
itemName: 'capability',
pluralItemName: 'capabilities',
primaryKeyName: 'id',
sortKeyName: 'id',
listTableFieldDefinitions: ['id'],
}
const listItems = async (): Promise<CapabilityReference[]> => capabilities
const preselectedId = await stringTranslateToId(config, argv.capabilityId, listItems)
const capabilityId = await selectFromList(command, config, { preselectedId, listItems })
const capabilityStatus = await command.client.devices.getCapabilityStatus(
deviceId,
componentId,
capabilityId,
)
await formatAndWriteItem(
command,
{ buildTableOutput: data => buildTableOutput(command.tableGenerator, data) },
capabilityStatus,
)
}

const cmd: CommandModule<object, CommandArgs> = { command, describe, builder, handler }
export default cmd
2 changes: 2 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ import { CommandModule } from 'yargs'
import appsCommand from './apps.js'
import configCommand from './config.js'
import devicepreferencesCommand from './devicepreferences.js'
import devicesCapabilityStatusCommand from './devices/capability-status.js'
import devicesPreferencesCommand from './devices/preferences.js'
import locationsCommand from './locations.js'
import locationsCreateCommand from './locations/create.js'
@@ -15,6 +16,7 @@ export const commands: CommandModule<object, any>[] = [
appsCommand,
configCommand,
devicepreferencesCommand,
devicesCapabilityStatusCommand,
devicesPreferencesCommand,
locationsCommand,
locationsCreateCommand,
70 changes: 68 additions & 2 deletions src/lib/command/util/devices-util.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { type Device, type DeviceListOptions, type SmartThingsClient } from '@smartthings/core-sdk'
import {
type AttributeState,
type Component,
type Device,
type DeviceListOptions,
type SmartThingsClient,
} from '@smartthings/core-sdk'

import { type ChooseFunction, createChooseFn } from './util-util.js'


export const chooseDeviceFn = (deviceListOptions?: DeviceListOptions): ChooseFunction<Device> => createChooseFn(
export const chooseDeviceFn = (
deviceListOptions?: DeviceListOptions,
): ChooseFunction<Device> => createChooseFn(
{
itemName: 'device',
primaryKeyName: 'deviceId',
@@ -14,3 +22,61 @@ export const chooseDeviceFn = (deviceListOptions?: DeviceListOptions): ChooseFun
)

export const chooseDevice = chooseDeviceFn()

/**
* @param device The device for which a component is to be selected
* @param options
* defaultToMain: Set this to false to throw an exception if the device has no components.
* The default is to return 'main'.
*/
export const chooseComponentFn = (
device: Device,
options?: { defaultToMain: boolean },
): ChooseFunction<Component> => {
const components = device.components
const defaultToMain = !options || options.defaultToMain !== false
if (!components || components.length === 0) {
// Previously, there were two versions of this function with this behavior being the
// primary difference.
if (defaultToMain) {
return async () => 'main'
} else {
throw Error('No components found')
}
}

return createChooseFn(
{
itemName: 'component',
primaryKeyName: 'id',
sortKeyName: 'id',
listTableFieldDefinitions: [
{
label: 'Id',
value: component => component.id === 'main' ? 'main (default)' : component.id,
},
],
},
async () => components,
)
}

/**
* Return a JSON-formatted value for a capability attribute with the unit appended if there is one.
*
* Since strings and numbers are valid JSON, if value is a string, this will return a quoted
* string or just the number for a number.
*/
export const prettyPrintAttribute = (attribute: AttributeState): string => {
const { unit, value } = attribute
if (value == null) {
return ''
}

let result = JSON.stringify(value)
if (result.length > 50) {
result = JSON.stringify(value, null, 2)
}

return `${result}${unit ? ' ' + unit : ''}`
}
10 changes: 9 additions & 1 deletion src/lib/util.ts
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@ export const stringFromUnknown = (input: unknown): string => {
return input.toString()
}
if (typeof input === 'object') {
// For object, only use the toString if it's not the default
// For object, only use the toString if it's not the default from `Object`.
if (input.toString !== Object.prototype.toString) {
return input.toString()
}
@@ -36,3 +36,11 @@ export const clipToMaximum = (input: string, maxLength: number): string =>
export const sanitize = (input?: string): string => input?.replace(/[\W]/g, '') ?? ''

export const delay = async (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))

export const fatalError = (message?: string, code = 1): never => {
if (message) {
console.error(message)
}
// eslint-disable-next-line no-process-exit
process.exit(code)
}

0 comments on commit e8a7bc2

Please sign in to comment.