diff --git a/packages/cli/src/__tests__/commands/deviceprofiles.test.ts b/packages/cli/src/__tests__/commands/deviceprofiles.test.ts deleted file mode 100644 index 3596ed24..00000000 --- a/packages/cli/src/__tests__/commands/deviceprofiles.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { outputItemOrList } from '@smartthings/cli-lib' - -import DeviceProfilesCommand from '../../commands/deviceprofiles.js' - - -describe('DevicesProfilesCommand', () => { - const outputItemOrListMock = jest.mocked(outputItemOrList) - - it('uses simple fields by default', async () => { - await expect(DeviceProfilesCommand.run([])).resolves.not.toThrow() - - expect(outputItemOrListMock).toHaveBeenCalledTimes(1) - expect(outputItemOrListMock).toHaveBeenCalledWith( - expect.any(DeviceProfilesCommand), - expect.objectContaining({ - listTableFieldDefinitions: ['name', 'status', 'id'], - }), - undefined, - expect.any(Function), - expect.any(Function), - ) - }) - - it('includes organization with all-organizations flag', async () => { - await expect(DeviceProfilesCommand.run(['--all-organizations'])).resolves.not.toThrow() - - expect(outputItemOrListMock).toHaveBeenCalledTimes(1) - expect(outputItemOrListMock).toHaveBeenCalledWith( - expect.any(DeviceProfilesCommand), - expect.objectContaining({ - listTableFieldDefinitions: ['name', 'status', 'id', 'organization'], - }), - undefined, - expect.any(Function), - expect.any(Function), - ) - }) -}) diff --git a/packages/cli/src/__tests__/lib/commands/deviceprofiles-util.test.ts b/packages/cli/src/__tests__/lib/commands/deviceprofiles-util.test.ts index 5780eb70..070e8659 100644 --- a/packages/cli/src/__tests__/lib/commands/deviceprofiles-util.test.ts +++ b/packages/cli/src/__tests__/lib/commands/deviceprofiles-util.test.ts @@ -27,228 +27,6 @@ import { import * as deviceprofilesUtil from '../../../lib/commands/deviceprofiles-util.js' -describe('entryValues', () => { - it('returns empty string for empty list', () => { - expect(entryValues([])).toBe('') - }) - - it.each` - input | result - ${{ component: 'main', capability: 'cap-id' }} | ${'main/cap-id'} - ${{ component: '', capability: 'cap-id' }} | ${'cap-id'} - `('converts $input to $result', ({ input, result }) => { - expect(entryValues([input])).toBe(result) - }) - - it('combines items with newlines', () => { - const entries = [ - { component: 'main', capability: 'capability-1' }, - { component: 'second', capability: 'capability-2' }, - ] - expect(entryValues(entries)).toBe('main/capability-1\nsecond/capability-2') - }) -}) - -describe('buildTableOutput', () => { - const pushMock = jest.fn() - const tableToStringMock = jest.fn().mockReturnValue('table-output') - const mockTable = { - push: pushMock, - toString: tableToStringMock, - } as Table - const newOutputTableMock = jest.fn().mockReturnValue(mockTable) - const buildTableFromListMock = jest.fn().mockReturnValue('table from list') - const tableGenerator = { - newOutputTable: newOutputTableMock, - buildTableFromList: buildTableFromListMock, - } as unknown as TableGenerator - - const entryValuesSpy = jest.spyOn(deviceprofilesUtil, 'entryValues') - - const baseDeviceProfile: DeviceProfile = { - id: 'device-profile-id', - name:'Device Profile', - components: [], - status: DeviceProfileStatus.PUBLISHED, - } - - it('includes basic info', () => { - expect(buildTableOutput(tableGenerator, baseDeviceProfile)).toBe('table-output') - - expect(newOutputTableMock).toHaveBeenCalledTimes(1) - expect(newOutputTableMock).toHaveBeenCalledWith() - expect(pushMock).toHaveBeenCalledTimes(7) - expect(pushMock).toHaveBeenCalledWith(['Name', 'Device Profile']) - expect(pushMock).toHaveBeenCalledWith(['Id', 'device-profile-id']) - expect(pushMock).toHaveBeenCalledWith(['Device Type', '']) - expect(pushMock).toHaveBeenCalledWith(['OCF Device Type', '']) - expect(pushMock).toHaveBeenCalledWith(['Manufacturer Name', '']) - expect(pushMock).toHaveBeenCalledWith(['Presentation Id', '']) - expect(pushMock).toHaveBeenCalledWith(['Status', 'PUBLISHED']) - expect(buildTableFromListMock).toHaveBeenCalledTimes(0) - }) - - it('includes metadata', () => { - const deviceProfile = { - ...baseDeviceProfile, - metadata: { - deviceType: 'device-type', - ocfDeviceType: 'ocf-device-type', - mnmn: 'manufacturer-name', - vid: 'presentation-id', - }, - } - - expect(buildTableOutput(tableGenerator, deviceProfile)).toBe('table-output') - - expect(newOutputTableMock).toHaveBeenCalledTimes(1) - expect(newOutputTableMock).toHaveBeenCalledWith() - expect(pushMock).toHaveBeenCalledTimes(7) - expect(pushMock).toHaveBeenCalledWith(['Device Type', 'device-type']) - expect(pushMock).toHaveBeenCalledWith(['OCF Device Type', 'ocf-device-type']) - expect(pushMock).toHaveBeenCalledWith(['Manufacturer Name', 'manufacturer-name']) - expect(pushMock).toHaveBeenCalledWith(['Presentation Id', 'presentation-id']) - expect(buildTableFromListMock).toHaveBeenCalledTimes(0) - }) - - it('includes components with capabilities', () => { - const deviceProfile = { - ...baseDeviceProfile, - components: [ - { id: 'main', capabilities: [{ id: 'switch', version: 1 }] }, - { id: 'second', capabilities: [{ id: 'switch', version: 1 }, { id: 'cap-2', version: 1 }] }, - { id: 'third' }, - ], - } - - expect(buildTableOutput(tableGenerator, deviceProfile)).toBe('table-output') - - expect(newOutputTableMock).toHaveBeenCalledTimes(1) - expect(newOutputTableMock).toHaveBeenCalledWith() - expect(pushMock).toHaveBeenCalledTimes(10) - expect(pushMock).toHaveBeenCalledWith(['main component', 'switch']) - expect(pushMock).toHaveBeenCalledWith(['second component', 'switch\ncap-2']) - expect(pushMock).toHaveBeenCalledWith(['third component', '']) - expect(buildTableFromListMock).toHaveBeenCalledTimes(0) - }) - - it('includes dashboard info when view info requested', () => { - const states = [{ component: 'main' } as PresentationDeviceConfigEntry] - const actions = [{ component: 'second' } as PresentationDeviceConfigEntry] - const deviceProfile = { - ...baseDeviceProfile, - view: { dashboard: { states, actions } }, - } - - entryValuesSpy - .mockReturnValueOnce('state entries') - .mockReturnValueOnce('action entries') - - expect(buildTableOutput(tableGenerator, deviceProfile, { includeViewInfo: true })) - .toBe('table-output') - - expect(newOutputTableMock).toHaveBeenCalledTimes(1) - expect(newOutputTableMock).toHaveBeenCalledWith() - expect(entryValuesSpy).toHaveBeenCalledTimes(2) - expect(entryValuesSpy).toHaveBeenCalledWith(states) - expect(entryValuesSpy).toHaveBeenCalledWith(actions) - expect(pushMock).toHaveBeenCalledTimes(9) - expect(pushMock).toHaveBeenCalledWith(['Dashboard states', 'state entries']) - expect(pushMock).toHaveBeenCalledWith(['Dashboard actions', 'action entries']) - expect(buildTableFromListMock).toHaveBeenCalledTimes(0) - }) - - it('includes detail view when view info requested', () => { - const detailView = [{ component: 'main' } as PresentationDeviceConfigEntry] - const deviceProfile = { - ...baseDeviceProfile, - view: { detailView }, - } - - entryValuesSpy - .mockReturnValueOnce('detail view entries') - - expect(buildTableOutput(tableGenerator, deviceProfile, { includeViewInfo: true })) - .toBe('table-output') - - expect(newOutputTableMock).toHaveBeenCalledTimes(1) - expect(newOutputTableMock).toHaveBeenCalledWith() - expect(entryValuesSpy).toHaveBeenCalledTimes(1) - expect(entryValuesSpy).toHaveBeenCalledWith(detailView) - expect(pushMock).toHaveBeenCalledTimes(8) - expect(pushMock).toHaveBeenCalledWith(['Detail view', 'detail view entries']) - expect(buildTableFromListMock).toHaveBeenCalledTimes(0) - }) - - it('includes automation conditions info when view info requested', () => { - const conditions = [{ component: 'main' } as PresentationDeviceConfigEntry] - const actions = [{ component: 'second' } as PresentationDeviceConfigEntry] - const deviceProfile = { - ...baseDeviceProfile, - view: { automation: { conditions, actions } }, - } - - entryValuesSpy - .mockReturnValueOnce('condition entries') - .mockReturnValueOnce('action entries') - - expect(buildTableOutput(tableGenerator, deviceProfile, { includeViewInfo: true })) - .toBe('table-output') - - expect(newOutputTableMock).toHaveBeenCalledTimes(1) - expect(newOutputTableMock).toHaveBeenCalledWith() - expect(entryValuesSpy).toHaveBeenCalledTimes(2) - expect(entryValuesSpy).toHaveBeenCalledWith(conditions) - expect(entryValuesSpy).toHaveBeenCalledWith(actions) - expect(pushMock).toHaveBeenCalledTimes(9) - expect(pushMock).toHaveBeenCalledWith(['Automation conditions', 'condition entries']) - expect(pushMock).toHaveBeenCalledWith(['Automation actions', 'action entries']) - expect(buildTableFromListMock).toHaveBeenCalledTimes(0) - }) - - it('includes "No Preferences" message when preferences requested but not included', () => { - const deviceProfile = { - ...baseDeviceProfile, - } - - entryValuesSpy - .mockReturnValueOnce('state entries') - .mockReturnValueOnce('action entries') - - expect(buildTableOutput(tableGenerator, deviceProfile, { includePreferences: true })) - .toBe('Basic Information\ntable-output\n\nNo preferences') - - expect(newOutputTableMock).toHaveBeenCalledTimes(1) - expect(newOutputTableMock).toHaveBeenCalledWith() - expect(pushMock).toHaveBeenCalledTimes(7) - expect(buildTableFromListMock).toHaveBeenCalledTimes(0) - }) - - it('includes preferences requested', () => { - const deviceProfile = { - ...baseDeviceProfile, - preferences: [{ title: 'pref-request' } as DeviceProfilePreferenceRequest], - } - buildTableFromListMock.mockReturnValueOnce('preferences-table') - - entryValuesSpy - .mockReturnValueOnce('state entries') - .mockReturnValueOnce('action entries') - - expect(buildTableOutput(tableGenerator, deviceProfile, { includePreferences: true })) - .toBe('Basic Information\ntable-output\n\nDevice Preferences\npreferences-table') - - expect(newOutputTableMock).toHaveBeenCalledTimes(1) - expect(newOutputTableMock).toHaveBeenCalledWith() - expect(pushMock).toHaveBeenCalledTimes(7) - expect(buildTableFromListMock).toHaveBeenCalledTimes(1) - expect(buildTableFromListMock).toHaveBeenCalledWith( - deviceProfile.preferences, - expect.arrayContaining(['preferenceId', 'title']), - ) - }) -}) - describe('chooseDeviceProfile', () => { const profile1 = { id: 'device-profile-1' } const deviceProfiles = [profile1] diff --git a/packages/cli/src/commands/deviceprofiles.ts b/packages/cli/src/commands/deviceprofiles.ts deleted file mode 100644 index 07b0fecb..00000000 --- a/packages/cli/src/commands/deviceprofiles.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Flags } from '@oclif/core' - -import { DeviceProfile } from '@smartthings/core-sdk' - -import { - APIOrganizationCommand, - WithOrganization, - allOrganizationsFlags, - outputItemOrList, - forAllOrganizations, - OutputItemOrListConfig, -} from '@smartthings/cli-lib' - -import { buildTableOutput } from '../lib/commands/deviceprofiles-util.js' - - -export default class DeviceProfilesCommand extends APIOrganizationCommand { - static description = 'list all device profiles available in a user account or retrieve a single profile' + - this.apiDocsURL('listDeviceProfiles', 'getDeviceProfile') - - static flags = { - ...APIOrganizationCommand.flags, - ...outputItemOrList.flags, - ...allOrganizationsFlags, - verbose: Flags.boolean({ - description: 'include presentationId and manufacturerName in list output', - char: 'v', - }), - } - - static args = [{ - name: 'id', - description: 'device profile to retrieve; UUID or the number of the profile from list', - }] - - static examples = [ - '$ smartthings deviceprofiles # list all device profiles', - '$ smartthings deviceprofiles bb0fdc5-...-a8bd2ea # show device profile with the specified UUID', - '$ smartthings deviceprofiles 2 # show the second device profile in the list', - '$ smartthings deviceprofiles 3 -j # show the profile in JSON format', - '$ smartthings deviceprofiles 5 -y # show the profile in YAML format', - '$ smartthings deviceprofiles 4 -j -o profile.json # write the profile to the file "profile.json"', - ] - - async run(): Promise { - const config: OutputItemOrListConfig = { - primaryKeyName: 'id', - sortKeyName: 'name', - listTableFieldDefinitions: ['name', 'status', 'id'], - buildTableOutput: (data: DeviceProfile) => buildTableOutput(this.tableGenerator, data), - } - - if (this.flags['all-organizations']) { - config.listTableFieldDefinitions = ['name', 'status', 'id', 'organization'] - } - - if (this.flags.verbose) { - config.listTableFieldDefinitions.push({ label: 'Presentation ID', value: item => item.metadata?.vid ?? '' }) - config.listTableFieldDefinitions.push({ label: 'Manufacturer Name', value: item => item.metadata?.mnmn ?? '' }) - } - - await outputItemOrList(this, config, this.args.id, - () => this.flags['all-organizations'] - ? forAllOrganizations(this.client, (orgClient) => orgClient.deviceProfiles.list()) - : this.client.deviceProfiles.list(), - id => this.client.deviceProfiles.get(id), - ) - } -} diff --git a/packages/cli/src/lib/commands/deviceprofiles-util.ts b/packages/cli/src/lib/commands/deviceprofiles-util.ts index facacf54..a4418935 100644 --- a/packages/cli/src/lib/commands/deviceprofiles-util.ts +++ b/packages/cli/src/lib/commands/deviceprofiles-util.ts @@ -1,10 +1,8 @@ import { DeviceProfile, DeviceProfileCreateRequest, - DeviceProfileRequest, DeviceProfileUpdateRequest, LocaleReference, - PresentationDeviceConfigEntry, } from '@smartthings/core-sdk' import { @@ -14,87 +12,10 @@ import { selectFromList, SelectFromListConfig, stringTranslateToId, - TableGenerator, WithLocales, } from '@smartthings/cli-lib' -export type ViewPresentationDeviceConfigEntry = - Omit & Partial> -export type DeviceView = { - dashboard?: { - states: ViewPresentationDeviceConfigEntry[] - actions: ViewPresentationDeviceConfigEntry[] - } - detailView?: ViewPresentationDeviceConfigEntry[] - automation?: { - conditions: ViewPresentationDeviceConfigEntry[] - actions: ViewPresentationDeviceConfigEntry[] - } -} - -export type DeviceDefinition = DeviceProfile & { - view?: DeviceView -} - -export type DeviceDefinitionRequest = DeviceProfileRequest & { - view?: DeviceView -} - -export const entryValues = (entries: ViewPresentationDeviceConfigEntry[]): string => - entries.map(entry => entry.component ? `${entry.component}/${entry.capability}` : `${entry.capability}`).join('\n') - -export type TableOutputOptions = { - includePreferences?: boolean - includeViewInfo?: boolean -} - -export const buildTableOutput = (tableGenerator: TableGenerator, data: DeviceProfile | DeviceDefinition, - options?: TableOutputOptions): string => { - const table = tableGenerator.newOutputTable() - table.push(['Name', data.name]) - for (const comp of data.components) { - table.push([`${comp.id} component`, comp.capabilities?.map(it => it.id).join('\n') ?? '']) - } - table.push(['Id', data.id]) - table.push(['Device Type', data.metadata?.deviceType ?? '']) - table.push(['OCF Device Type', data.metadata?.ocfDeviceType ?? '']) - table.push(['Manufacturer Name', data.metadata?.mnmn ?? '']) - table.push(['Presentation Id', data.metadata?.vid ?? '']) - table.push(['Status', data.status]) - if (options?.includeViewInfo && 'view' in data && data.view) { - if (data.view.dashboard) { - if (data.view.dashboard.states) { - table.push(['Dashboard states', entryValues(data.view.dashboard.states)]) - } - if (data.view.dashboard.actions) { - table.push(['Dashboard actions', entryValues(data.view.dashboard.actions)]) - } - } - if (data.view.detailView) { - table.push(['Detail view', entryValues(data.view.detailView)]) - } - if (data.view.automation) { - if (data.view.automation.conditions) { - table.push(['Automation conditions', entryValues(data.view.automation.conditions)]) - } - if (data.view.automation.actions) { - table.push(['Automation actions', entryValues(data.view.automation.actions)]) - } - } - } - - if (options?.includePreferences) { - const preferencesInfo = data.preferences?.length - ? 'Device Preferences\n' + tableGenerator.buildTableFromList(data.preferences, - ['preferenceId', 'title', 'preferenceType', { path: 'definition.default' }]) - : 'No preferences' - return `Basic Information\n${table.toString()}\n\n` + - preferencesInfo - } - return table.toString() -} - export const chooseDeviceProfile = async (command: APIOrganizationCommand, deviceProfileFromArg?: string, options?: Partial>): Promise => { const opts = chooseOptionsWithDefaults(options) diff --git a/packages/cli/src/lib/commands/devices-util.ts b/packages/cli/src/lib/commands/devices-util.ts index 7de75895..b7f33908 100644 --- a/packages/cli/src/lib/commands/devices-util.ts +++ b/packages/cli/src/lib/commands/devices-util.ts @@ -226,6 +226,3 @@ 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.') -} diff --git a/src/__tests__/commands/deviceprofiles.test.ts b/src/__tests__/commands/deviceprofiles.test.ts new file mode 100644 index 00000000..b74acc06 --- /dev/null +++ b/src/__tests__/commands/deviceprofiles.test.ts @@ -0,0 +1,243 @@ +import { jest } from '@jest/globals' + +import type { ArgumentsCamelCase, Argv } from 'yargs' + +import type { + DeviceProfile, + DeviceProfilesEndpoint, + OrganizationResponse, + SmartThingsClient, +} from '@smartthings/core-sdk' + +import type { CommandArgs } from '../../commands/deviceprofiles.js' +import type { WithOrganization, forAllOrganizations } from '../../lib/api-helpers.js' +import type { apiDocsURL } from '../../lib/command/api-command.js' +import type { + APIOrganizationCommand, + APIOrganizationCommandFlags, + apiOrganizationCommand, + apiOrganizationCommandBuilder, +} from '../../lib/command/api-organization-command.js' +import type { + AllOrganizationFlags, + allOrganizationsBuilder, +} from '../../lib/command/common-flags.js' +import type { + CustomCommonOutputProducer, + TableCommonListOutputProducer, +} from '../../lib/command/format.js' +import type { outputItemOrList, outputItemOrListBuilder } from '../../lib/command/listing-io.js' +import type { ValueTableFieldDefinition } from '../../lib/table-generator.js' +import type { shortARNorURL, verboseApps } from '../../lib/command/util/apps-util.js' +import type { buildTableOutput } from '../../lib/command/util/deviceprofiles-util.js' +import { buildArgvMock, buildArgvMockStub } from '../test-lib/builder-mock.js' +import { tableGeneratorMock } from '../test-lib/table-mock.js' + + +const forAllOrganizationsMock = jest.fn() +jest.unstable_mockModule('../../lib/api-helpers.js', () => ({ + forAllOrganizations: forAllOrganizationsMock, +})) + +const apiDocsURLMock = jest.fn() +jest.unstable_mockModule('../../lib/command/api-command.js', () => ({ + apiDocsURL: apiDocsURLMock, +})) + +const apiOrganizationCommandMock = jest.fn() +const apiOrganizationCommandBuilderMock = jest.fn() +jest.unstable_mockModule('../../lib/command/api-organization-command.js', () => ({ + apiOrganizationCommand: apiOrganizationCommandMock, + apiOrganizationCommandBuilder: apiOrganizationCommandBuilderMock, +})) + +const allOrganizationsBuilderMock = jest.fn() +jest.unstable_mockModule('../../lib/command/common-flags.js', () => ({ + allOrganizationsBuilder: allOrganizationsBuilderMock, +})) + +const outputItemOrListMock = + jest.fn>() +const outputItemOrListBuilderMock = jest.fn() +jest.unstable_mockModule('../../lib/command/listing-io.js', () => ({ + outputItemOrList: outputItemOrListMock, + outputItemOrListBuilder: outputItemOrListBuilderMock, +})) + +const shortARNorURLMock = jest.fn() +const verboseAppsMock = jest.fn() +jest.unstable_mockModule('../../lib/command/util/apps-util.js', () => ({ + shortARNorURL: shortARNorURLMock, + verboseApps: verboseAppsMock, + tableFieldDefinitions: [], +})) + +const buildTableOutputMock = jest.fn() +jest.unstable_mockModule('../../lib/command/util/deviceprofiles-util.js', () => ({ + buildTableOutput: buildTableOutputMock, +})) + + +const { default: cmd } = await import('../../commands/deviceprofiles.js') + + +test('builder', () => { + const yargsMock = buildArgvMockStub() + const apiOrganizationCommandBuilderArgvMock = buildArgvMockStub() + const { + yargsMock: allOrganizationsBuilderArgvMock, + positionalMock, + optionMock, + exampleMock, + epilogMock, + argvMock, + } = buildArgvMock() + + apiOrganizationCommandBuilderMock.mockReturnValueOnce(apiOrganizationCommandBuilderArgvMock) + allOrganizationsBuilderMock.mockReturnValueOnce(allOrganizationsBuilderArgvMock) + outputItemOrListBuilderMock.mockReturnValueOnce(argvMock) + + const builder = cmd.builder as (yargs: Argv) => Argv + + expect(builder(yargsMock)).toBe(argvMock) + + expect(apiOrganizationCommandBuilderMock).toHaveBeenCalledExactlyOnceWith(yargsMock) + expect(allOrganizationsBuilderMock) + .toHaveBeenCalledExactlyOnceWith(apiOrganizationCommandBuilderArgvMock) + expect(outputItemOrListBuilderMock) + .toHaveBeenCalledExactlyOnceWith(allOrganizationsBuilderArgvMock) + + expect(positionalMock).toHaveBeenCalledTimes(1) + expect(optionMock).toHaveBeenCalledTimes(1) + expect(exampleMock).toHaveBeenCalledTimes(1) + expect(epilogMock).toHaveBeenCalledTimes(1) +}) + +describe('handler', () => { + const deviceProfile1 = { id: 'profile-id-1' } as DeviceProfile & WithOrganization + const deviceProfile2 = { id: 'profile-id-2' } as DeviceProfile & WithOrganization + const profileList = [deviceProfile1, deviceProfile2] + + const apiProfilesListMock = jest.fn() + .mockResolvedValue(profileList) + const apiProfilesGetMock = jest.fn() + + const clientMock = { + deviceProfiles: { + list: apiProfilesListMock, + get: apiProfilesGetMock, + }, + } as unknown as SmartThingsClient + const command = { + client: clientMock, + tableGenerator: tableGeneratorMock, + } as APIOrganizationCommand> + apiOrganizationCommandMock.mockResolvedValue(command) + + const defaultInputArgv = { + profile: 'default', + verbose: false, + idOrIndex: 'argv-profile-id', + } as ArgumentsCamelCase + + it('lists user device profiles without args', async () => { + await expect(cmd.handler(defaultInputArgv)).resolves.not.toThrow() + + expect(apiOrganizationCommandMock).toHaveBeenCalledExactlyOnceWith(defaultInputArgv) + expect(outputItemOrListMock).toHaveBeenCalledExactlyOnceWith( + command, + expect.objectContaining({ primaryKeyName: 'id' }), + 'argv-profile-id', + expect.any(Function), + expect.any(Function), + ) + + apiProfilesListMock.mockResolvedValueOnce(profileList) + const listFunction = outputItemOrListMock.mock.calls[0][3] + + expect(await listFunction()).toStrictEqual([deviceProfile1, deviceProfile2]) + + expect(apiProfilesListMock).toHaveBeenCalledExactlyOnceWith() + }) + + it('lists profiles for all organizations', async () => { + await expect(cmd.handler({ ...defaultInputArgv, allOrganizations: true })) + .resolves.not.toThrow() + + const listFunction = outputItemOrListMock.mock.calls[0][3] + forAllOrganizationsMock.mockResolvedValueOnce(profileList) + + expect(await listFunction()).toBe(profileList) + + expect(apiProfilesListMock).not.toHaveBeenCalled() + expect(forAllOrganizationsMock) + .toHaveBeenCalledExactlyOnceWith(clientMock, expect.any(Function)) + + const perOrgQuery = forAllOrganizationsMock.mock.calls[0][1] + + const organization = { name: 'Organization Name' } as OrganizationResponse + expect(await perOrgQuery(clientMock, organization)).toBe(profileList) + + expect(apiProfilesListMock).toHaveBeenCalledExactlyOnceWith() + }) + + it('lists details of a specified device profile', async () => { + await expect(cmd.handler(defaultInputArgv)).resolves.not.toThrow() + + expect(apiOrganizationCommandMock).toHaveBeenCalledExactlyOnceWith(defaultInputArgv) + expect(outputItemOrListMock).toHaveBeenCalledExactlyOnceWith( + command, + expect.objectContaining({ primaryKeyName: 'id' }), + 'argv-profile-id', + expect.any(Function), + expect.any(Function), + ) + + apiProfilesListMock.mockResolvedValueOnce(profileList) + const getFunction = outputItemOrListMock.mock.calls[0][4] + apiProfilesGetMock.mockResolvedValue(deviceProfile1) + + expect(await getFunction('chosen-device-profile-id')).toStrictEqual(deviceProfile1) + + expect(apiProfilesGetMock).toHaveBeenCalledExactlyOnceWith('chosen-device-profile-id') + + buildTableOutputMock.mockReturnValueOnce('build table output') + const config = outputItemOrListMock.mock.calls[0][1] as + CustomCommonOutputProducer + expect(config.buildTableOutput(deviceProfile1)).toBe('build table output') + expect(buildTableOutputMock).toHaveBeenCalledExactlyOnceWith(tableGeneratorMock, deviceProfile1) + }) + + it('includes extra details with verbose flag', async () => { + await expect(cmd.handler({ ...defaultInputArgv, verbose: true })).resolves.not.toThrow() + + expect(outputItemOrListMock).toHaveBeenCalledExactlyOnceWith( + command, + expect.objectContaining({ + listTableFieldDefinitions: expect.arrayContaining([ + { label: 'Profile Id', value: expect.any(Function) }, + { label: 'Manufacturer Name', value: expect.any(Function) }, + ]), + }), + 'argv-profile-id', + expect.any(Function), + expect.any(Function), + ) + + const config = outputItemOrListMock.mock.calls[0][1] as + TableCommonListOutputProducer + + const profileIdValue = (config.listTableFieldDefinitions[3] as + ValueTableFieldDefinition).value + expect(profileIdValue({} as DeviceProfile)).toBe('') + expect(profileIdValue({ metadata: { vid: 'vid-value' } } as unknown as DeviceProfile)) + .toBe('vid-value') + + const manufacturerNameValue = (config.listTableFieldDefinitions[4] as + ValueTableFieldDefinition).value + expect(manufacturerNameValue({} as DeviceProfile)).toBe('') + expect(manufacturerNameValue({ metadata: { mnmn: 'mnmn-value' } } as unknown as DeviceProfile)) + .toBe('mnmn-value') + + }) +}) diff --git a/src/__tests__/lib/command/util/deviceprofiles-util.test.ts b/src/__tests__/lib/command/util/deviceprofiles-util.test.ts new file mode 100644 index 00000000..7fa1eb1e --- /dev/null +++ b/src/__tests__/lib/command/util/deviceprofiles-util.test.ts @@ -0,0 +1,188 @@ +import { + type DeviceProfile, + type DeviceProfilePreferenceRequest, + DeviceProfileStatus, + type PresentationDeviceConfigEntry, +} from '@smartthings/core-sdk' + +import { + buildTableFromListMock, + mockedTableOutput, + newOutputTableMock, + tableGeneratorMock, + tablePushMock, +} from '../../../test-lib/table-mock.js' + + +const { + buildTableOutput, + entryValues, +} = await import('../../../../lib/command/util/deviceprofiles-util.js') + + +describe('entryValues', () => { + it('returns empty string for empty list', () => { + expect(entryValues([])).toBe('') + }) + + it.each([ + { input: { component: 'main', capability: 'cap-id' }, result: 'main/cap-id' }, + { input: { capability: 'cap-id' }, result: 'cap-id' }, + ])('converts $input to $result', ({ input, result }) => { + expect(entryValues([input])).toBe(result) + }) + + it('combines items with newlines', () => { + const entries = [ + { component: 'main', capability: 'capability-1' }, + { component: 'second', capability: 'capability-2' }, + ] + expect(entryValues(entries)).toBe('main/capability-1\nsecond/capability-2') + }) +}) + +describe('buildTableOutput', () => { + const baseDeviceProfile: DeviceProfile = { + id: 'device-profile-id', + name:'Device Profile', + components: [], + status: DeviceProfileStatus.PUBLISHED, + } + + it('includes basic info', () => { + expect(buildTableOutput(tableGeneratorMock, baseDeviceProfile)).toBe(mockedTableOutput) + + expect(newOutputTableMock).toHaveBeenCalledExactlyOnceWith() + expect(tablePushMock).toHaveBeenCalledTimes(7) + expect(tablePushMock).toHaveBeenCalledWith(['Name', 'Device Profile']) + expect(tablePushMock).toHaveBeenCalledWith(['Id', 'device-profile-id']) + expect(tablePushMock).toHaveBeenCalledWith(['Device Type', '']) + expect(tablePushMock).toHaveBeenCalledWith(['OCF Device Type', '']) + expect(tablePushMock).toHaveBeenCalledWith(['Manufacturer Name', '']) + expect(tablePushMock).toHaveBeenCalledWith(['Presentation Id', '']) + expect(tablePushMock).toHaveBeenCalledWith(['Status', 'PUBLISHED']) + expect(buildTableFromListMock).not.toHaveBeenCalled() + }) + + it('includes metadata', () => { + const deviceProfile = { + ...baseDeviceProfile, + metadata: { + deviceType: 'device-type', + ocfDeviceType: 'ocf-device-type', + mnmn: 'manufacturer-name', + vid: 'presentation-id', + }, + } + + expect(buildTableOutput(tableGeneratorMock, deviceProfile)).toBe(mockedTableOutput) + + expect(newOutputTableMock).toHaveBeenCalledExactlyOnceWith() + expect(tablePushMock).toHaveBeenCalledTimes(7) + expect(tablePushMock).toHaveBeenCalledWith(['Device Type', 'device-type']) + expect(tablePushMock).toHaveBeenCalledWith(['OCF Device Type', 'ocf-device-type']) + expect(tablePushMock).toHaveBeenCalledWith(['Manufacturer Name', 'manufacturer-name']) + expect(tablePushMock).toHaveBeenCalledWith(['Presentation Id', 'presentation-id']) + expect(buildTableFromListMock).not.toHaveBeenCalled() + }) + + it('includes components with capabilities', () => { + const deviceProfile = { + ...baseDeviceProfile, + components: [ + { id: 'main', capabilities: [{ id: 'switch', version: 1 }] }, + { + id: 'second', + capabilities: [{ id: 'switch', version: 1 }, { id: 'cap-2', version: 1 }], + }, + { id: 'third' }, + ], + } + + expect(buildTableOutput(tableGeneratorMock, deviceProfile)).toBe(mockedTableOutput) + + expect(newOutputTableMock).toHaveBeenCalledExactlyOnceWith() + expect(tablePushMock).toHaveBeenCalledTimes(10) + expect(tablePushMock).toHaveBeenCalledWith(['main component', 'switch']) + expect(tablePushMock).toHaveBeenCalledWith(['second component', 'switch\ncap-2']) + expect(tablePushMock).toHaveBeenCalledWith(['third component', '']) + expect(buildTableFromListMock).not.toHaveBeenCalled() + }) + + it('includes dashboard info when view info requested', () => { + const states = [{ capability: 'switch' } as PresentationDeviceConfigEntry] + const actions = [{ capability: 'switchLevel' } as PresentationDeviceConfigEntry] + const deviceProfile = { ...baseDeviceProfile, view: { dashboard: { states, actions } } } + + expect(buildTableOutput(tableGeneratorMock, deviceProfile, { includeViewInfo: true })) + .toBe(mockedTableOutput) + + expect(newOutputTableMock).toHaveBeenCalledExactlyOnceWith() + expect(tablePushMock).toHaveBeenCalledTimes(9) + expect(tablePushMock).toHaveBeenCalledWith(['Dashboard states', 'switch']) + expect(tablePushMock).toHaveBeenCalledWith(['Dashboard actions', 'switchLevel']) + expect(buildTableFromListMock).not.toHaveBeenCalled() + }) + + it('includes detail view when view info requested', () => { + const detailView = [{ capability: 'button' } as PresentationDeviceConfigEntry] + const deviceProfile = { ...baseDeviceProfile, view: { detailView } } + + expect(buildTableOutput(tableGeneratorMock, deviceProfile, { includeViewInfo: true })) + .toBe(mockedTableOutput) + + expect(newOutputTableMock).toHaveBeenCalledExactlyOnceWith() + expect(tablePushMock).toHaveBeenCalledTimes(8) + expect(tablePushMock).toHaveBeenCalledWith(['Detail view', 'button']) + expect(buildTableFromListMock).not.toHaveBeenCalled() + }) + + it('includes automation conditions info when view info requested', () => { + const conditions = [{ capability: 'lock' } as PresentationDeviceConfigEntry] + const actions = [{ capability: 'refresh' } as PresentationDeviceConfigEntry] + const deviceProfile = { + ...baseDeviceProfile, + view: { automation: { conditions, actions } }, + } + + expect(buildTableOutput(tableGeneratorMock, deviceProfile, { includeViewInfo: true })) + .toBe(mockedTableOutput) + + expect(newOutputTableMock).toHaveBeenCalledExactlyOnceWith() + expect(tablePushMock).toHaveBeenCalledTimes(9) + expect(tablePushMock).toHaveBeenCalledWith(['Automation conditions', 'lock']) + expect(tablePushMock).toHaveBeenCalledWith(['Automation actions', 'refresh']) + expect(buildTableFromListMock).not.toHaveBeenCalled() + }) + + it('includes "No Preferences" message when preferences requested but not included', () => { + const deviceProfile = { + ...baseDeviceProfile, + } + + expect(buildTableOutput(tableGeneratorMock, deviceProfile, { includePreferences: true })) + .toBe(`Basic Information\n${mockedTableOutput}\n\nNo preferences`) + + expect(newOutputTableMock).toHaveBeenCalledExactlyOnceWith() + expect(tablePushMock).toHaveBeenCalledTimes(7) + expect(buildTableFromListMock).not.toHaveBeenCalled() + }) + + it('includes preferences requested', () => { + const deviceProfile = { + ...baseDeviceProfile, + preferences: [{ title: 'pref-request' } as DeviceProfilePreferenceRequest], + } + buildTableFromListMock.mockReturnValueOnce('preferences-table') + + expect(buildTableOutput(tableGeneratorMock, deviceProfile, { includePreferences: true })) + .toBe(`Basic Information\n${mockedTableOutput}\n\nDevice Preferences\npreferences-table`) + + expect(newOutputTableMock).toHaveBeenCalledExactlyOnceWith() + expect(tablePushMock).toHaveBeenCalledTimes(7) + expect(buildTableFromListMock).toHaveBeenCalledExactlyOnceWith( + deviceProfile.preferences, + expect.arrayContaining(['preferenceId', 'title']), + ) + }) +}) diff --git a/src/__tests__/test-lib/table-mock.ts b/src/__tests__/test-lib/table-mock.ts index 158c61da..64fdc03f 100644 --- a/src/__tests__/test-lib/table-mock.ts +++ b/src/__tests__/test-lib/table-mock.ts @@ -10,5 +10,17 @@ export const tableMock: Table = { push: tablePushMock, toString: tableToStringMock, } -export const newOutputTableMock = jest.fn().mockReturnValue(tableMock) -export const tableGeneratorMock = { newOutputTable: newOutputTableMock } as unknown as TableGenerator + +export const mockedItemTableOutput = 'table from item' +export const mockedListTableOutput = 'table from list' +export const buildTableFromItemMock = jest.fn() + .mockReturnValue(mockedItemTableOutput) +export const buildTableFromListMock = jest.fn() + .mockReturnValue(mockedListTableOutput) +export const newOutputTableMock = jest.fn() + .mockReturnValue(tableMock) +export const tableGeneratorMock = { + buildTableFromItem: buildTableFromItemMock, + buildTableFromList: buildTableFromListMock, + newOutputTable: newOutputTableMock, +} as unknown as TableGenerator diff --git a/src/commands/deviceprofiles.ts b/src/commands/deviceprofiles.ts new file mode 100644 index 00000000..699ebfa3 --- /dev/null +++ b/src/commands/deviceprofiles.ts @@ -0,0 +1,93 @@ +import { type ArgumentsCamelCase, type Argv, type CommandModule } from 'yargs' + +import { type DeviceProfile } from '@smartthings/core-sdk' + +import { forAllOrganizations, WithOrganization } from '../lib/api-helpers.js' +import { buildTableOutput } from '../lib/command/util/deviceprofiles-util.js' +import { + apiOrganizationCommand, + apiOrganizationCommandBuilder, + type APIOrganizationCommandFlags, +} from '../lib/command/api-organization-command.js' +import { apiDocsURL } from '../lib/command/api-command.js' +import { AllOrganizationFlags, allOrganizationsBuilder } from '../lib/command/common-flags.js' +import { + outputItemOrList, + outputItemOrListBuilder, + type OutputItemOrListConfig, + type OutputItemOrListFlags, +} from '../lib/command/listing-io.js' + + +export type CommandArgs = APIOrganizationCommandFlags & AllOrganizationFlags & OutputItemOrListFlags & { + verbose: boolean + idOrIndex?: string +} + +const command = 'deviceprofiles [id-or-index]' + +const describe = 'get a specific device profile or a list device profiles' + +const builder = (yargs: Argv): Argv => + outputItemOrListBuilder(allOrganizationsBuilder(apiOrganizationCommandBuilder(yargs))) + .positional( + 'id-or-index', + { describe: 'the device profile id or number from list', type: 'string' }, + ) + .option( + 'verbose', + { + alias: 'v', + describe: 'include presentationId and manufacturerName in list output', + type: 'boolean', + default: false, + }, + ) + .example([ + ['$0 deviceprofiles', 'list all device profiles'], + ['$0 deviceprofiles 8bd382bb-07e8-48d3-8b11-5f0b508b1729', 'display details for a device profile by id'], + [ + '$0 deviceprofiles 2', + 'display details for the first device profile in the list retrieved by running "$0 device profiles"', + ], + ]) + .epilog(apiDocsURL('listDeviceProfiles', 'getDeviceProfile')) + +const handler = async (argv: ArgumentsCamelCase): Promise => { + const command = await apiOrganizationCommand(argv) + + const config: OutputItemOrListConfig = { + primaryKeyName: 'id', + sortKeyName: 'name', + listTableFieldDefinitions: ['name', 'status', 'id'], + buildTableOutput: (data: DeviceProfile) => buildTableOutput(command.tableGenerator, data), + } + + if (argv.allOrganizations) { + config.listTableFieldDefinitions = ['name', 'status', 'id', 'organization'] + } + + if (argv.verbose) { + config.listTableFieldDefinitions.push({ + label: 'Profile Id', + value: item => item.metadata?.vid ?? '', + }) + config.listTableFieldDefinitions.push({ + label: 'Manufacturer Name', + value: item => item.metadata?.mnmn ?? '', + }) + } + + await outputItemOrList( + command, + config, + argv.idOrIndex, + () => argv.allOrganizations + ? forAllOrganizations(command.client, (orgClient) => orgClient.deviceProfiles.list()) + : command.client.deviceProfiles.list(), + id => command.client.deviceProfiles.get(id), + ) +} + +const cmd: CommandModule = { command, describe, builder, handler } +export default cmd diff --git a/src/commands/index.ts b/src/commands/index.ts index 01c86591..a2f7ce60 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -3,6 +3,7 @@ import { CommandModule } from 'yargs' import appsCommand from './apps.js' import configCommand from './config.js' import devicepreferencesCommand from './devicepreferences.js' +import deviceprofilesCommand from './deviceprofiles.js' import devicesCapabilityStatusCommand from './devices/capability-status.js' import devicesPreferencesCommand from './devices/preferences.js' import locationsCommand from './locations.js' @@ -16,6 +17,7 @@ export const commands: CommandModule[] = [ appsCommand, configCommand, devicepreferencesCommand, + deviceprofilesCommand, devicesCapabilityStatusCommand, devicesPreferencesCommand, locationsCommand, diff --git a/src/lib/command/util/deviceprofiles-util.ts b/src/lib/command/util/deviceprofiles-util.ts new file mode 100644 index 00000000..ea3fbba1 --- /dev/null +++ b/src/lib/command/util/deviceprofiles-util.ts @@ -0,0 +1,86 @@ +import { + DeviceProfile, + DeviceProfileRequest, + PresentationDeviceConfigEntry, +} from '@smartthings/core-sdk' + +import { TableGenerator } from '../../table-generator.js' + + +export type ViewPresentationDeviceConfigEntry = + Omit & Partial> +export type DeviceView = { + dashboard?: { + states: ViewPresentationDeviceConfigEntry[] + actions: ViewPresentationDeviceConfigEntry[] + } + detailView?: ViewPresentationDeviceConfigEntry[] + automation?: { + conditions: ViewPresentationDeviceConfigEntry[] + actions: ViewPresentationDeviceConfigEntry[] + } +} + +export type DeviceDefinition = DeviceProfile & { + view?: DeviceView +} + +export type DeviceDefinitionRequest = DeviceProfileRequest & { + view?: DeviceView +} + +export const entryValues = (entries: ViewPresentationDeviceConfigEntry[]): string => + entries.map(entry => entry.component + ? `${entry.component}/${entry.capability}` + : `${entry.capability}`).join('\n') + +export type TableOutputOptions = { + includePreferences?: boolean + includeViewInfo?: boolean +} + +export const buildTableOutput = (tableGenerator: TableGenerator, data: DeviceProfile | DeviceDefinition, + options?: TableOutputOptions): string => { + const table = tableGenerator.newOutputTable() + table.push(['Name', data.name]) + for (const comp of data.components) { + table.push([`${comp.id} component`, comp.capabilities?.map(it => it.id).join('\n') ?? '']) + } + table.push(['Id', data.id]) + table.push(['Device Type', data.metadata?.deviceType ?? '']) + table.push(['OCF Device Type', data.metadata?.ocfDeviceType ?? '']) + table.push(['Manufacturer Name', data.metadata?.mnmn ?? '']) + table.push(['Presentation Id', data.metadata?.vid ?? '']) + table.push(['Status', data.status]) + if (options?.includeViewInfo && 'view' in data && data.view) { + if (data.view.dashboard) { + if (data.view.dashboard.states) { + table.push(['Dashboard states', entryValues(data.view.dashboard.states)]) + } + if (data.view.dashboard.actions) { + table.push(['Dashboard actions', entryValues(data.view.dashboard.actions)]) + } + } + if (data.view.detailView) { + table.push(['Detail view', entryValues(data.view.detailView)]) + } + if (data.view.automation) { + if (data.view.automation.conditions) { + table.push(['Automation conditions', entryValues(data.view.automation.conditions)]) + } + if (data.view.automation.actions) { + table.push(['Automation actions', entryValues(data.view.automation.actions)]) + } + } + } + + if (options?.includePreferences) { + const preferencesInfo = data.preferences?.length + ? 'Device Preferences\n' + tableGenerator.buildTableFromList(data.preferences, + ['preferenceId', 'title', 'preferenceType', { path: 'definition.default' }]) + : 'No preferences' + return `Basic Information\n${table.toString()}\n\n` + + preferencesInfo + } + return table.toString() +}