diff --git a/eslint.config.cjs b/eslint.config.cjs index b0a366e..078c3eb 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -84,7 +84,7 @@ module.exports = [ ecmaVersion: 2022, sourceType: 'module', parserOptions: { - project: ['./tsconfig.json', './scripts/tsconfig.json'], + project: ['./tsconfig.eslint.json', './scripts/tsconfig.json'], tsconfigRootDir: __dirname, }, globals: { @@ -135,4 +135,4 @@ module.exports = [ }], }, }, -]; \ No newline at end of file +]; diff --git a/src/commands/sdsFileCommands.ts b/src/commands/sdsFileCommands.ts index 3364b44..dc7752f 100644 --- a/src/commands/sdsFileCommands.ts +++ b/src/commands/sdsFileCommands.ts @@ -214,7 +214,7 @@ export function registerSdsFileCommands(args: RegisterSdsFileCommandsArgs): void return; } const cmsisPackRoot = getCmsisPackRoot(process.env); - if (!cmsisPackRoot) { + if (!cmsisPackRoot || !fs.existsSync(cmsisPackRoot)) { void vscode.window.showErrorMessage('Default pack root doesn\'t exist or CMSIS_PACK_ROOT environment variable is not set. Please set it to the CMSIS Pack root directory.'); return; } diff --git a/test/e2e/viewer-panel.spec.ts b/test/e2e/viewer-panel.spec.ts index e928ad1..80c9a2b 100644 --- a/test/e2e/viewer-panel.spec.ts +++ b/test/e2e/viewer-panel.spec.ts @@ -21,6 +21,8 @@ * and message sending (export, etc). */ +// Created using AI + import { test, expect, Page } from '@playwright/test'; import { startServer } from './helpers/webview-server'; import * as http from 'http'; @@ -43,8 +45,8 @@ async function openViewer(page: Page): Promise { await page.waitForSelector('#chart'); } -async function getMessages(page: Page): Promise { - return page.evaluate(() => (window as any).__messages); +async function getMessages(page: Page): Promise<{ command?: string }[]> { + return page.evaluate(() => (window as { __messages?: { command?: string }[] }).__messages || []); } // ── Structure ─────────────────────────────────────────────── @@ -91,12 +93,12 @@ test.describe('Viewer Panel — Structure', () => { test.describe('Viewer Panel — Interactions', () => { test('Export button sends exportCsv message', async ({ page }) => { await openViewer(page); - await page.evaluate(() => { (window as any).__messages = []; }); + await page.evaluate(() => { (window as { __messages?: { command?: string }[] }).__messages = []; }); await page.locator('button[title="Export CSV"]').click(); const msgs = await getMessages(page); - expect(msgs.some((m: any) => m.command === 'exportCsv')).toBe(true); + expect(msgs.some((m: { command?: string }) => m.command === 'exportCsv')).toBe(true); }); test('channel toggle buttons can be clicked', async ({ page }) => { diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..71cfcad --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.eslint.json", + "include": [ + "./**/*.ts", + "../vitest.config.ts", + "../playwright.config.ts" + ] +} diff --git a/test/unit/decimation.test.ts b/test/unit/decimation.test.ts index 6b056b9..8e4aa8d 100644 --- a/test/unit/decimation.test.ts +++ b/test/unit/decimation.test.ts @@ -14,12 +14,14 @@ * limitations under the License. */ +// Created using AI + import { describe, expect, it } from 'vitest'; import { ChartSample } from '../../src/viewer/webview/components/baseChartViewer'; import { decimateExtremaSeries } from '../../src/viewer/webview/components/decimation'; function createSeries(values: number[]): ChartSample[] { - return values.map((y, x) => ({ x, y })); + return values.map((y, x) => ({ x: x, y: y, index: x })); } describe('decimateExtremaSeries', () => { diff --git a/test/unit/sds-diagnostics.test.ts b/test/unit/sds-diagnostics.test.ts new file mode 100644 index 0000000..09989b2 --- /dev/null +++ b/test/unit/sds-diagnostics.test.ts @@ -0,0 +1,236 @@ +/** + * Copyright 2026 Arm Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Created using AI + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const vscodeMockState = vi.hoisted(() => { + const outputChannels: Array<{ + appendLine: ReturnType; + clear: ReturnType; + dispose: ReturnType; + hide: ReturnType; + show: ReturnType; + }> = []; + + const createOutputChannelMock = vi.fn(() => { + const channel = { + appendLine: vi.fn(), + clear: vi.fn(), + dispose: vi.fn(), + hide: vi.fn(), + show: vi.fn(), + }; + outputChannels.push(channel); + return channel; + }); + + return { + createOutputChannelMock, + extensions: [] as Array<{ packageJSON?: { name?: string; version?: string } }>, + outputChannels, + }; +}); + +vi.mock('vscode', () => ({ + extensions: { + get all() { + return vscodeMockState.extensions; + }, + }, + version: '1.118.0-test', + window: { + createOutputChannel: vscodeMockState.createOutputChannelMock, + }, +})); + +import { + diag, + DiagnosticLevel, + DiagnosticSource, + SdsDiagnostics, +} from '../../src/diagnostics/sdsDiagnostics'; + +function lastOutputChannel() { + const channel = vscodeMockState.outputChannels[vscodeMockState.outputChannels.length - 1]; + if (!channel) { + throw new Error('No output channel was created'); + } + return channel; +} + +function appendedLines(): string[] { + return lastOutputChannel().appendLine.mock.calls.map(([line]) => String(line)); +} + +describe('SdsDiagnostics', () => { + beforeEach(() => { + vi.clearAllMocks(); + vscodeMockState.extensions = []; + vscodeMockState.outputChannels.length = 0; + }); + + afterEach(() => { + SdsDiagnostics.getInstance().dispose(); + vi.restoreAllMocks(); + }); + + it('creates a singleton output channel and exposes it through diag()', () => { + const first = SdsDiagnostics.getInstance(); + const second = SdsDiagnostics.getInstance(); + + expect(first).toBe(second); + expect(diag()).toBe(first); + expect(vscodeMockState.createOutputChannelMock).toHaveBeenCalledTimes(1); + expect(vscodeMockState.createOutputChannelMock).toHaveBeenCalledWith('CMSIS SDS Diagnostics'); + expect(first.outputChannel).toBe(lastOutputChannel()); + }); + + it('writes formatted entries, stores history copies, and mirrors warnings and errors', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const diagnostics = SdsDiagnostics.getInstance(); + const error = new Error('disk failed'); + error.stack = [ + 'Error: disk failed', + ' at first', + ' at second', + ' at third', + ' at fourth', + ].join('\n'); + + diagnostics.info(DiagnosticSource.Extension, 'Activated'); + diagnostics.warn(DiagnosticSource.Server, 'Slow response'); + diagnostics.error(DiagnosticSource.Exporter, 'Export failed', error); + diagnostics.error(DiagnosticSource.Viewer, 'Render failed', 'bad frame'); + + const lines = appendedLines(); + expect(lines[0]).toMatch(/\] INFO {2}\[Extension {3}\] Activated$/); + expect(lines[1]).toMatch(/\] WARN {2}\[Server {6}\] Slow response$/); + expect(lines[2]).toContain('ERROR [Exporter ] Export failed'); + expect(lines[2]).toContain('disk failed'); + expect(lines[2]).toContain('Stack: at first'); + expect(lines[2]).toContain(' at third'); + expect(lines[2]).not.toContain(' at fourth'); + expect(lines[3]).toContain('ERROR [Viewer ] Render failed'); + expect(lines[3]).toContain('bad frame'); + expect(warnSpy).toHaveBeenCalledWith('[CMSIS SDS] Server: Slow response'); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('[CMSIS SDS] Exporter: Export failed')); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('disk failed')); + + const history = diagnostics.getHistory(); + expect(history).toHaveLength(4); + expect(history.map((entry) => entry.level)).toEqual([ + DiagnosticLevel.Info, + DiagnosticLevel.Warn, + DiagnosticLevel.Error, + DiagnosticLevel.Error, + ]); + + history.pop(); + expect(diagnostics.getHistory()).toHaveLength(4); + expect(diagnostics.getHistory(2).map((entry) => entry.source)).toEqual([ + DiagnosticSource.Exporter, + DiagnosticSource.Viewer, + ]); + }); + + it('filters entries below the configured minimum level', () => { + const diagnostics = SdsDiagnostics.getInstance(); + + diagnostics.setMinLevel(DiagnosticLevel.Warn); + diagnostics.trace(DiagnosticSource.Recorder, 'Trace detail'); + diagnostics.debug(DiagnosticSource.Recorder, 'Debug detail'); + diagnostics.info(DiagnosticSource.Recorder, 'Info detail'); + diagnostics.warn(DiagnosticSource.Recorder, 'Warning detail'); + + const lines = appendedLines(); + expect(lines.some((line) => line.includes('Diagnostics log level set to WARN'))).toBe(false); + expect(lines.some((line) => line.includes('Trace detail'))).toBe(false); + expect(lines.some((line) => line.includes('Debug detail'))).toBe(false); + expect(lines.some((line) => line.includes('Info detail'))).toBe(false); + expect(lines[lines.length - 1]).toContain('Warning detail'); + expect(diagnostics.getHistory().map((entry) => entry.message)).toEqual(['Warning detail']); + }); + + it('shows, hides, clears, and disposes the output channel', () => { + const diagnostics = SdsDiagnostics.getInstance(); + diagnostics.info(DiagnosticSource.Extension, 'Before clear'); + + diagnostics.show(); + diagnostics.hide(); + diagnostics.clear(); + diagnostics.dispose(); + + const channel = lastOutputChannel(); + expect(channel.show).toHaveBeenCalledWith(true); + expect(channel.hide).toHaveBeenCalledTimes(1); + expect(channel.clear).toHaveBeenCalledTimes(1); + expect(channel.dispose).toHaveBeenCalledTimes(1); + expect(diagnostics.getHistory()).toEqual([]); + expect(appendedLines()).toEqual([ + expect.stringContaining('Before clear'), + '╔═══════════════════════════════════════════════════════════════╗', + expect.stringContaining('CMSIS SDS Diagnostics'), + '╚═══════════════════════════════════════════════════════════════╝', + ]); + + const replacement = SdsDiagnostics.getInstance(); + expect(replacement).not.toBe(diagnostics); + }); + + it('writes the startup banner with installed extension metadata', () => { + vscodeMockState.extensions = [ + { packageJSON: { name: 'other-extension', version: '9.9.9' } }, + { packageJSON: { name: 'arm-cmsis-sds', version: '0.11.0' } }, + ]; + const diagnostics = SdsDiagnostics.getInstance(); + + diagnostics.writeBanner(); + + const lines = appendedLines(); + expect(lines).toContain('║ Arm SDS Diagnostics ║'); + expect(lines).toContain('║ Server & System Messages ║'); + expect(lines).toContain(' VS Code: 1.118.0-test'); + expect(lines).toContain(' Extension: arm-cmsis-sds v0.11.0'); + expect(lines).toContain(` Platform: ${process.platform} (${process.arch})`); + expect(lines.some((line) => line.startsWith(' Started: '))).toBe(true); + }); + + it('writes unknown extension version when the extension is not installed', () => { + const diagnostics = SdsDiagnostics.getInstance(); + + diagnostics.writeBanner(); + + expect(appendedLines()).toContain(' Extension: arm-cmsis-sds vunknown'); + }); + + it('keeps only the most recent history entries', () => { + const diagnostics = SdsDiagnostics.getInstance(); + const appendLineMock = lastOutputChannel().appendLine; + appendLineMock.mockImplementation(() => undefined); + + for (let index = 0; index < 5001; index += 1) { + diagnostics.info(DiagnosticSource.Extension, `message-${index}`); + } + + const history = diagnostics.getHistory(); + expect(history).toHaveLength(5000); + expect(history[0].message).toBe('message-1'); + expect(history[history.length - 1].message).toBe('message-5000'); + }); +}); diff --git a/test/unit/sds-explorer-provider.test.ts b/test/unit/sds-explorer-provider.test.ts index 3ec2ef5..fcd8023 100644 --- a/test/unit/sds-explorer-provider.test.ts +++ b/test/unit/sds-explorer-provider.test.ts @@ -14,9 +14,23 @@ * limitations under the License. */ -import { describe, expect, it, vi } from 'vitest'; +// Created using AI + +import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'fs'; +const watcherMockState = vi.hoisted(() => ({ + watchers: [] as Array<{ + onDidCreate: ReturnType; + onDidDelete: ReturnType; + onDidChange: ReturnType; + dispose: ReturnType; + triggerCreate: () => void; + triggerDelete: () => void; + triggerChange: () => void; + }>, +})); + vi.mock('vscode', () => { class EventEmitter { private listeners: Array<(event: T) => void> = []; @@ -39,6 +53,7 @@ vi.mock('vscode', () => { iconPath?: unknown; command?: unknown; description?: string; + resourceUri?: unknown; constructor(public label: string, public collapsibleState?: number) { } } @@ -47,12 +62,31 @@ vi.mock('vscode', () => { constructor(public id: string) { } } - const createFileSystemWatcher = () => ({ - onDidCreate: vi.fn(), - onDidDelete: vi.fn(), - onDidChange: vi.fn(), - dispose: vi.fn(), - }); + const createFileSystemWatcher = () => { + let onCreate: (() => void) | undefined; + let onDelete: (() => void) | undefined; + let onChange: (() => void) | undefined; + const watcher = { + onDidCreate: vi.fn((listener: () => void) => { + onCreate = listener; + return { dispose: vi.fn() }; + }), + onDidDelete: vi.fn((listener: () => void) => { + onDelete = listener; + return { dispose: vi.fn() }; + }), + onDidChange: vi.fn((listener: () => void) => { + onChange = listener; + return { dispose: vi.fn() }; + }), + dispose: vi.fn(), + triggerCreate: () => onCreate?.(), + triggerDelete: () => onDelete?.(), + triggerChange: () => onChange?.(), + }; + watcherMockState.watchers.push(watcher); + return watcher; + }; return { EventEmitter, @@ -60,7 +94,7 @@ vi.mock('vscode', () => { ThemeIcon, TreeItemCollapsibleState: { None: 0, Collapsed: 1, Expanded: 2 }, workspace: { - workspaceFolders: [{ uri: { fsPath: 'c:/workspace' } }], + workspaceFolders: [{ name: 'workspace', uri: { fsPath: 'c:/workspace' } }], createFileSystemWatcher, }, Uri: { file: (filePath: string) => ({ fsPath: filePath }) }, @@ -95,6 +129,9 @@ vi.mock('../../src/sds/types', async () => { }); import { SdsExplorerProvider, SdsTreeItem } from '../../src/providers/sdsExplorerProvider'; +import * as vscode from 'vscode'; +import { parseMetadataFile } from '../../src/sds/writer'; +import { detectMediaType } from '../../src/sds/types'; function createDirent(name: string, kind: 'file' | 'directory') { return { @@ -105,6 +142,85 @@ function createDirent(name: string, kind: 'file' | 'directory') { } describe('SdsExplorerProvider', () => { + beforeEach(() => { + watcherMockState.watchers.length = 0; + vi.mocked(fs.existsSync).mockReset(); + vi.mocked(fs.existsSync).mockImplementation(() => false); + vi.mocked(fs.readdirSync).mockReset(); + vi.mocked(fs.readdirSync).mockImplementation(() => [] as never); + vi.mocked(fs.statSync).mockReset(); + vi.mocked(fs.statSync).mockImplementation(() => ({ size: 0 }) as never); + vi.mocked(parseMetadataFile).mockReset(); + vi.mocked(detectMediaType).mockReset(); + vi.mocked(detectMediaType).mockReturnValue('sensor'); + (vscode.workspace as unknown as { workspaceFolders?: Array<{ name?: string; uri: { fsPath: string } }> }).workspaceFolders = [ + { name: 'workspace', uri: { fsPath: 'c:/workspace' } }, + ]; + }); + + it('sets icons, commands, and collapsible state for special tree item types', () => { + const group = new SdsTreeItem('stream', 'group', 'stream.sds.yml', 2); + const info = new SdsTreeItem('No files', 'info', '', 0); + const metadata = new SdsTreeItem('stream.sds.yml', 'metadataFile', 'c:/workspace/stream.sds.yml', 0); + + expect((group.iconPath as { id: string }).id).toBe('library'); + expect(group.collapsibleState).toBe(1); + expect((info.iconPath as { id: string }).id).toBe('info'); + expect((metadata.iconPath as { id: string }).id).toBe('file-code'); + expect(metadata.command).toEqual({ + command: 'vscode.open', + title: 'Open Metadata', + arguments: [{ fsPath: 'c:/workspace/stream.sds.yml' }], + }); + }); + + it('fires refresh events from public refresh and file/config watcher callbacks, and disposes watchers', () => { + let configChangeHandler: (() => void) | undefined; + const configManager = { + onDidChangeConfig: vi.fn((handler: () => void) => { + configChangeHandler = handler; + }), + getConfigFile: vi.fn(() => 'active.sdsio.yml'), + getConfig: vi.fn(() => ({ workdir: undefined, metadir: undefined })), + }; + const provider = new SdsExplorerProvider(configManager as never); + let refreshEvents = 0; + provider.onDidChangeTreeData(() => { + refreshEvents += 1; + }); + const item = new SdsTreeItem('stream', 'sdsFile', 'stream.0.sds', 0); + + provider.refresh(); + watcherMockState.watchers[0].triggerCreate(); + watcherMockState.watchers[1].triggerDelete(); + watcherMockState.watchers[2].triggerChange(); + configChangeHandler?.(); + + expect(provider.getTreeItem(item)).toBe(item); + expect(refreshEvents).toBe(5); + + provider.dispose(); + + expect(watcherMockState.watchers.every((watcher) => watcher.dispose.mock.calls.length === 1)).toBe(true); + }); + + it('returns no children without workspace folders and returns element children directly', async () => { + const configManager = { + onDidChangeConfig: vi.fn(), + getConfigFile: vi.fn(() => 'active.sdsio.yml'), + getConfig: vi.fn(() => ({ workdir: undefined, metadir: undefined })), + }; + const child = new SdsTreeItem('child.0.sds', 'sdsFile', 'child.0.sds', 0); + const parent = new SdsTreeItem('parent', 'folder', '', 2, [child]); + const provider = new SdsExplorerProvider(configManager as never); + + (vscode.workspace as unknown as { workspaceFolders: Array<{ uri: { fsPath: string } }> | undefined }).workspaceFolders = undefined; + await expect(provider.getChildren()).resolves.toEqual([]); + + (vscode.workspace as unknown as { workspaceFolders: Array<{ uri: { fsPath: string } }> | undefined }).workspaceFolders = [{ uri: { fsPath: 'c:/workspace' } }]; + await expect(provider.getChildren(parent)).resolves.toEqual([child]); + }); + it('returns two root nodes and includes flags node with description from flags source', async () => { const configManager = { onDidChangeConfig: vi.fn(), @@ -212,4 +328,115 @@ describe('SdsExplorerProvider', () => { ['ml_in', ['ml_in.0', 'ml_in.1.p', 'ml_in.rock.2', 'ml_in.rock.3.p']], ])); }); + + it('scans configured metadata and SDS directories, adds metadata-only groups, and routes media files', async () => { + vi.mocked(fs.readdirSync).mockImplementation((dirPath: fs.PathLike) => { + const normalized = String(dirPath).replace(/\\/g, '/'); + switch (normalized) { + case 'c:/meta': + return [ + createDirent('sensor.sds.yml', 'file'), + createDirent('unused.sds.yml', 'file'), + createDirent('nested', 'directory'), + createDirent('.hidden', 'directory'), + createDirent('node_modules', 'directory'), + createDirent('notes.txt', 'file'), + ] as never; + case 'c:/meta/nested': + return [ + createDirent('image.sds.yml', 'file'), + ] as never; + case 'c:/data': + return [ + createDirent('sensor.0.sds', 'file'), + createDirent('sub', 'directory'), + ] as never; + case 'c:/data/sub': + return [ + createDirent('image.0.sds', 'file'), + ] as never; + default: + return [] as never; + } + }); + vi.mocked(fs.existsSync).mockImplementation((targetPath: fs.PathLike) => { + const normalized = String(targetPath).replace(/\\/g, '/'); + return [ + 'c:/meta', + 'c:/meta/sensor.sds.yml', + 'c:/meta/unused.sds.yml', + 'c:/meta/nested/image.sds.yml', + ].includes(normalized); + }); + vi.mocked(fs.statSync).mockImplementation((targetPath: fs.PathLike) => { + const normalized = String(targetPath).replace(/\\/g, '/'); + return { size: normalized.endsWith('image.0.sds') ? 2 * 1024 * 1024 : 512 } as never; + }); + vi.mocked(parseMetadataFile).mockImplementation((metadataPath: string) => ({ metadataPath }) as never); + vi.mocked(detectMediaType).mockImplementation((metadata: unknown) => { + return String((metadata as { metadataPath: string }).metadataPath).includes('image') ? 'image' : 'sensor'; + }); + const configManager = { + onDidChangeConfig: vi.fn(), + getConfigFile: vi.fn(() => 'active.sdsio.yml'), + getConfig: vi.fn(() => ({ workdir: 'c:/data', metadir: 'c:/meta' })), + }; + const provider = new SdsExplorerProvider(configManager as never); + + const rootItems = await provider.getChildren(); + const filesRoot = rootItems[0]; + const groups = filesRoot.children ?? []; + const imageGroup = groups.find((item) => item.label === 'image'); + const sensorGroup = groups.find((item) => item.label === 'sensor'); + const unusedGroup = groups.find((item) => item.label === 'unused'); + const imageFile = imageGroup?.children?.[0]; + const sensorFile = sensorGroup?.children?.[0]; + + expect(imageGroup?.contextValue).toBe('groupMetadata'); + expect(imageGroup?.filePath.replace(/\\/g, '/')).toBe('c:/meta/nested/image.sds.yml'); + expect(imageFile?.command).toEqual({ + command: 'arm-sds.openMediaViewer', + title: 'Open in Media Viewer', + arguments: [imageFile], + }); + expect((imageFile?.iconPath as { id: string }).id).toBe('paintcan'); + expect(imageFile?.description).toBe('2.0 MB'); + expect(sensorFile?.description).toBe('512 B'); + expect(unusedGroup?.contextValue).toBe('groupMetadata'); + expect(unusedGroup?.description).toBe('0 recordings'); + expect(unusedGroup?.children).toEqual([]); + }); + + it('scans workdir-only configs and sorts groups before standalone SDS files', async () => { + vi.mocked(fs.readdirSync).mockImplementation((dirPath: fs.PathLike) => { + const normalized = String(dirPath).replace(/\\/g, '/'); + if (normalized === 'c:/work') { + return [ + createDirent('beta.0.sds', 'file'), + createDirent('alpha.sds.yml', 'file'), + createDirent('alpha.0.sds', 'file'), + ] as never; + } + return [] as never; + }); + vi.mocked(fs.existsSync).mockImplementation((targetPath: fs.PathLike) => String(targetPath).replace(/\\/g, '/').endsWith('alpha.sds.yml')); + vi.mocked(fs.statSync).mockImplementation((targetPath: fs.PathLike) => { + const normalized = String(targetPath).replace(/\\/g, '/'); + return { size: normalized.endsWith('beta.0.sds') ? 1536 : 1024 } as never; + }); + const configManager = { + onDidChangeConfig: vi.fn(), + getConfigFile: vi.fn(() => 'active.sdsio.yml'), + getConfig: vi.fn(() => ({ workdir: 'c:/work', metadir: undefined })), + }; + const provider = new SdsExplorerProvider(configManager as never); + + const rootItems = await provider.getChildren(); + const files = rootItems[0].children ?? []; + + expect(files.map((item) => item.label)).toEqual(['alpha', 'beta.0.sds']); + expect(files[0].itemType).toBe('group'); + expect(files[1].itemType).toBe('sdsFile'); + expect(files[1].description).toBe('1.5 KB'); + }); }); diff --git a/test/unit/sds-file-commands.test.ts b/test/unit/sds-file-commands.test.ts index ef63d36..0b2d8c8 100644 --- a/test/unit/sds-file-commands.test.ts +++ b/test/unit/sds-file-commands.test.ts @@ -14,7 +14,12 @@ * limitations under the License. */ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +// Created using AI + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; const commandMockState = vi.hoisted(() => { const registeredDisposables: Array<{ @@ -53,6 +58,7 @@ vi.mock('vscode', () => { registerCommand: commandMockState.registerCommandMock, }, Uri, + TreeItemCollapsibleState: { None: 0, Collapsed: 1, Expanded: 2 }, window: { createTerminal: vi.fn(() => ({ sendText: vi.fn(), @@ -67,9 +73,9 @@ vi.mock('vscode', () => { showWarningMessage: vi.fn(), }, workspace: { - asRelativePath: vi.fn(() => ''), + asRelativePath: vi.fn((uri: { fsPath: string }) => uri.fsPath), findFiles: vi.fn(async () => []), - openTextDocument: vi.fn(async () => ({ uri: Uri.file('') })), + openTextDocument: vi.fn(async (uri: Uri | string) => ({ uri })), }, }; }); @@ -105,25 +111,100 @@ vi.mock('../../src/sds', () => ({ SDS_METADATA_EXTENSION: '.sds.yml', })); -import { registerSdsFileCommands } from '../../src/commands/sdsFileCommands'; +import * as vscode from 'vscode'; +import { SdsTreeItem } from '../../src/providers/sdsExplorerProvider'; +import { SdsViewerPanel } from '../../src/viewer/sdsViewerPanel'; +import { SdsMediaViewerPanel } from '../../src/viewer/sdsMediaViewerPanel'; +import { + decodeAllRecords, + exportToCsv, + parseMetadataFile, + parseSdsFile, +} from '../../src/sds'; +import { getCmsisPackRoot, registerSdsFileCommands } from '../../src/commands/sdsFileCommands'; + +function createContext() { + return { + subscriptions: [] as Array<{ dispose: () => void }>, + extensionUri: vscode.Uri.file('/extension'), + }; +} + +function registerCommands() { + const context = createContext(); + const explorerProvider = { refresh: vi.fn() }; + const configManager = {}; + + registerSdsFileCommands({ + context: context as never, + explorerProvider: explorerProvider as never, + configManager: configManager as never, + }); + + return { context, explorerProvider, configManager }; +} + +function getCommand(command: string): (...args: unknown[]) => unknown { + const registration = commandMockState.registeredDisposables.find((disposable) => disposable.command === command); + if (!registration) { + throw new Error(`Command was not registered: ${command}`); + } + return registration.callback; +} + +function resetVscodeMockDefaults(): void { + const asRelativePathMock = vi.mocked(vscode.workspace.asRelativePath) as unknown as ReturnType; + const openTextDocumentMock = vi.mocked(vscode.workspace.openTextDocument) as unknown as ReturnType; + const createTerminalMock = vi.mocked(vscode.window.createTerminal) as unknown as ReturnType; + + asRelativePathMock.mockReset(); + asRelativePathMock.mockImplementation((pathOrUri: string | { fsPath: string }) => { + return typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.fsPath; + }); + openTextDocumentMock.mockReset(); + openTextDocumentMock.mockImplementation(async (uri: unknown) => ({ uri })); + createTerminalMock.mockReset(); + createTerminalMock.mockImplementation(() => ({ + sendText: vi.fn(), + show: vi.fn(), + })); +} describe('registerSdsFileCommands', () => { + let tmpDir: string; + const originalCmsisPackRoot = process.env.CMSIS_PACK_ROOT; + beforeEach(() => { vi.clearAllMocks(); + vi.mocked(SdsViewerPanel.createOrShow).mockReset(); + vi.mocked(SdsMediaViewerPanel.createOrShow).mockReset(); + resetVscodeMockDefaults(); + vi.mocked(vscode.workspace.findFiles).mockReset(); + vi.mocked(vscode.workspace.findFiles).mockResolvedValue([]); + vi.mocked(vscode.window.showOpenDialog).mockReset(); + vi.mocked(vscode.window.showQuickPick).mockReset(); + vi.mocked(vscode.window.showSaveDialog).mockReset(); + vi.mocked(vscode.window.showWarningMessage).mockReset(); commandMockState.registeredDisposables.length = 0; + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sds-file-commands-')); + if (originalCmsisPackRoot === undefined) { + delete process.env.CMSIS_PACK_ROOT; + } else { + process.env.CMSIS_PACK_ROOT = originalCmsisPackRoot; + } }); - it('pushes every command registration disposable into the extension context subscriptions', () => { - const context = { - subscriptions: [] as Array<{ dispose: () => void }>, - extensionUri: { fsPath: 'c:/extension' }, - }; + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + if (originalCmsisPackRoot === undefined) { + delete process.env.CMSIS_PACK_ROOT; + } else { + process.env.CMSIS_PACK_ROOT = originalCmsisPackRoot; + } + }); - registerSdsFileCommands({ - context: context as never, - explorerProvider: { refresh: vi.fn() } as never, - configManager: {} as never, - }); + it('pushes every command registration disposable into the extension context subscriptions', () => { + const { context } = registerCommands(); const expectedCommands = [ 'arm-sds.openViewer', @@ -142,4 +223,343 @@ describe('registerSdsFileCommands', () => { expect(context.subscriptions).toHaveLength(expectedCommands.length); expect(context.subscriptions.every((subscription) => typeof subscription.dispose === 'function')).toBe(true); }); + + it('opens the waveform viewer from a tree item or a selected file', async () => { + const { configManager } = registerCommands(); + const command = getCommand('arm-sds.openViewer'); + const selected = vscode.Uri.file(path.join(tmpDir, 'selected.0.sds')); + + await command({ filePath: path.join(tmpDir, 'tree.0.sds') }); + vi.mocked(vscode.window.showOpenDialog).mockResolvedValueOnce([selected]); + await command(); + + expect(SdsViewerPanel.createOrShow).toHaveBeenNthCalledWith(1, vscode.Uri.file('/extension'), path.join(tmpDir, 'tree.0.sds'), configManager); + expect(SdsViewerPanel.createOrShow).toHaveBeenNthCalledWith(2, vscode.Uri.file('/extension'), selected.fsPath, configManager); + expect(vscode.window.showOpenDialog).toHaveBeenCalledWith({ + canSelectMany: false, + filters: { 'SDS files': ['sds'] }, + title: 'Select SDS data file', + }); + }); + + it('does not open the waveform viewer when selection is cancelled and reports viewer errors', async () => { + registerCommands(); + const command = getCommand('arm-sds.openViewer'); + vi.mocked(vscode.window.showOpenDialog).mockResolvedValueOnce(undefined); + + await command({}); + + expect(SdsViewerPanel.createOrShow).not.toHaveBeenCalled(); + + vi.mocked(SdsViewerPanel.createOrShow).mockImplementationOnce(() => { + throw new Error('viewer exploded'); + }); + + await command(path.join(tmpDir, 'bad.0.sds')); + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Failed to open viewer: viewer exploded'); + }); + + it('creates metadata next to an SDS file and opens it', async () => { + registerCommands(); + const sdsPath = path.join(tmpDir, 'temperature.0.sds'); + const metadataPath = path.join(tmpDir, 'temperature.sds.yml'); + + await getCommand('arm-sds.createMetadata')(sdsPath); + + expect(fs.readFileSync(metadataPath, 'utf-8')).toContain(' name: temperature'); + expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith(metadataPath); + expect(vscode.window.showTextDocument).toHaveBeenCalledWith({ uri: metadataPath }); + }); + + it('creates metadata from file selection and returns when selection is cancelled', async () => { + registerCommands(); + const selected = vscode.Uri.file(path.join(tmpDir, 'selected.0.sds')); + const metadataPath = path.join(tmpDir, 'selected.sds.yml'); + const command = getCommand('arm-sds.createMetadata'); + vi.mocked(vscode.window.showOpenDialog) + .mockResolvedValueOnce([selected]) + .mockResolvedValueOnce(undefined); + + await command(); + await command(); + + expect(fs.readFileSync(metadataPath, 'utf-8')).toContain(' name: selected'); + expect(vscode.workspace.openTextDocument).toHaveBeenCalledTimes(1); + expect(vscode.window.showTextDocument).toHaveBeenCalledTimes(1); + }); + + it('opens existing metadata without overwriting it', async () => { + registerCommands(); + const sdsPath = path.join(tmpDir, 'camera.0.p.sds'); + const metadataPath = path.join(tmpDir, 'camera.sds.yml'); + fs.writeFileSync(metadataPath, 'existing: true\n', 'utf-8'); + + await getCommand('arm-sds.createMetadata')(vscode.Uri.file(sdsPath)); + + expect(fs.readFileSync(metadataPath, 'utf-8')).toBe('existing: true\n'); + expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith(metadataPath); + expect(vscode.window.showTextDocument).toHaveBeenCalledWith({ uri: metadataPath }); + }); + + it('reports invalid metadata paths and write failures', async () => { + registerCommands(); + const command = getCommand('arm-sds.createMetadata'); + + await command(path.join(tmpDir, 'not-an-sds-file.txt')); + await command(path.join(tmpDir, 'missing-parent', 'broken.0.sds')); + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Could not determine metadata file path.'); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to create metadata:')); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(expect.stringContaining('ENOENT')); + }); + + it('reports missing group metadata and opens an existing metadata item', async () => { + registerCommands(); + const metadataPath = path.join(tmpDir, 'group.sds.yml'); + const command = getCommand('arm-sds.openGroupMetadata'); + + await command({ filePath: metadataPath }); + fs.writeFileSync(metadataPath, 'sds:\n name: group\n', 'utf-8'); + await command({ filePath: metadataPath }); + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(`Metadata file not found: ${metadataPath}`); + expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith(metadataPath); + expect(vscode.window.showTextDocument).toHaveBeenCalledWith({ uri: metadataPath }); + }); + + it('reports missing group metadata arguments and document open failures', async () => { + registerCommands(); + const metadataPath = path.join(tmpDir, 'throws.sds.yml'); + fs.writeFileSync(metadataPath, 'sds:\n name: throws\n', 'utf-8'); + vi.mocked(vscode.workspace.openTextDocument).mockRejectedValueOnce(new Error('document failed')); + const command = getCommand('arm-sds.openGroupMetadata'); + + await command(); + await command(metadataPath); + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('No metadata file found for this SDS group.'); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Failed to open metadata: document failed'); + }); + + it('exports CSV when metadata exists and reports the decoded sample count', async () => { + registerCommands(); + const sdsPath = path.join(tmpDir, 'accel.0.sds'); + const metadataPath = path.join(tmpDir, 'accel.sds.yml'); + const csvPath = path.join(tmpDir, 'accel.csv'); + const metadata = { sds: { content: [{ value: 'x', type: 'float' }] } }; + const parsed = { records: [] }; + const samples = [{ x: 1 }, { x: 2 }]; + fs.writeFileSync(metadataPath, 'sds:\n name: accel\n', 'utf-8'); + vi.mocked(vscode.window.showSaveDialog).mockResolvedValueOnce(vscode.Uri.file(csvPath)); + vi.mocked(parseMetadataFile).mockReturnValueOnce(metadata as never); + vi.mocked(parseSdsFile).mockReturnValueOnce(parsed as never); + vi.mocked(decodeAllRecords).mockReturnValueOnce(samples as never); + + await getCommand('arm-sds.exportCsv')(sdsPath); + + expect(vscode.window.showSaveDialog).toHaveBeenCalledWith({ + defaultUri: vscode.Uri.file(sdsPath.replace(/\.sds$/, '.csv')), + filters: { 'CSV files': ['csv'] }, + title: 'Export SDS to CSV', + }); + expect(parseMetadataFile).toHaveBeenCalledWith(metadataPath); + expect(parseSdsFile).toHaveBeenCalledWith(sdsPath); + expect(decodeAllRecords).toHaveBeenCalledWith(parsed, metadata); + expect(exportToCsv).toHaveBeenCalledWith(samples, metadata.sds.content, csvPath, true); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith('Exported 2 samples to accel.csv'); + }); + + it('exports CSV from file selection and reports selection failures', async () => { + registerCommands(); + const sdsPath = path.join(tmpDir, 'picked.0.sds'); + fs.writeFileSync(path.join(tmpDir, 'picked.sds.yml'), 'sds:\n name: picked\n', 'utf-8'); + const command = getCommand('arm-sds.exportCsv'); + vi.mocked(vscode.window.showOpenDialog) + .mockResolvedValueOnce([vscode.Uri.file(sdsPath)]) + .mockRejectedValueOnce(new Error('dialog failed')); + vi.mocked(vscode.window.showSaveDialog).mockResolvedValueOnce(undefined); + + await command(); + await command(); + + expect(vscode.window.showSaveDialog).toHaveBeenCalledWith({ + defaultUri: vscode.Uri.file(sdsPath.replace(/\.sds$/, '.csv')), + filters: { 'CSV files': ['csv'] }, + title: 'Export SDS to CSV', + }); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Export failed: dialog failed'); + }); + + it('does not export CSV without metadata or when save is cancelled', async () => { + registerCommands(); + const command = getCommand('arm-sds.exportCsv'); + const missingMetadataSds = path.join(tmpDir, 'missing.0.sds'); + const cancelledSds = path.join(tmpDir, 'cancelled.0.sds'); + fs.writeFileSync(path.join(tmpDir, 'cancelled.sds.yml'), 'sds:\n name: cancelled\n', 'utf-8'); + vi.mocked(vscode.window.showSaveDialog).mockResolvedValueOnce(undefined); + + await command(missingMetadataSds); + await command(cancelledSds); + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('No metadata (.sds.yml) file found. Cannot decode data for CSV export.'); + expect(exportToCsv).not.toHaveBeenCalled(); + }); + + it('reports CSV decode/export failures after the destination is selected', async () => { + registerCommands(); + const sdsPath = path.join(tmpDir, 'throws.0.sds'); + const metadataPath = path.join(tmpDir, 'throws.sds.yml'); + const csvPath = path.join(tmpDir, 'throws.csv'); + fs.writeFileSync(metadataPath, 'sds:\n name: throws\n', 'utf-8'); + vi.mocked(vscode.window.showSaveDialog).mockResolvedValueOnce(vscode.Uri.file(csvPath)); + vi.mocked(parseMetadataFile).mockImplementationOnce(() => { + throw new Error('metadata parse failed'); + }); + + await getCommand('arm-sds.exportCsv')(sdsPath); + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Export failed: metadata parse failed'); + }); + + it('deletes a file only after confirmation and refreshes the explorer', async () => { + const { explorerProvider } = registerCommands(); + const filePath = path.join(tmpDir, 'delete-me.0.sds'); + fs.writeFileSync(filePath, '', 'utf-8'); + vi.mocked(vscode.window.showWarningMessage).mockResolvedValueOnce('Delete' as never); + + await getCommand('arm-sds.deleteFile')({ filePath }); + + expect(fs.existsSync(filePath)).toBe(false); + expect(explorerProvider.refresh).toHaveBeenCalled(); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith('Deleted delete-me.0.sds'); + }); + + it('returns for missing delete items and reports delete failures', async () => { + const { explorerProvider } = registerCommands(); + const missingPath = path.join(tmpDir, 'missing-delete.0.sds'); + const command = getCommand('arm-sds.deleteFile'); + vi.mocked(vscode.window.showWarningMessage).mockResolvedValueOnce('Delete' as never); + + await command(undefined); + await command({ filePath: missingPath }); + + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1); + expect(explorerProvider.refresh).not.toHaveBeenCalled(); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to delete:')); + }); + + it('opens the media viewer and quick-opens a picked SDS file', async () => { + const { configManager } = registerCommands(); + const mediaPath = path.join(tmpDir, 'image.0.sds'); + const quickPath = path.join(tmpDir, 'quick.0.sds'); + vi.mocked(vscode.workspace.findFiles).mockResolvedValueOnce([vscode.Uri.file(quickPath)]); + vi.mocked(vscode.window.showQuickPick).mockImplementationOnce(async (items: unknown) => { + return Array.isArray(items) ? items[0] : undefined; + }); + + await getCommand('arm-sds.openMediaViewer')(new SdsTreeItem('image', 'sdsFile', mediaPath, vscode.TreeItemCollapsibleState.None)); + await getCommand('arm-sds.quickOpen')(); + + expect(SdsMediaViewerPanel.createOrShow).toHaveBeenCalledWith(vscode.Uri.file('/extension'), mediaPath, configManager); + expect(SdsViewerPanel.createOrShow).toHaveBeenCalledWith(vscode.Uri.file('/extension'), quickPath, configManager); + }); + + it('opens the media viewer from selection, returns on cancel, and reports viewer errors', async () => { + const { configManager } = registerCommands(); + const selected = vscode.Uri.file(path.join(tmpDir, 'selected-media.0.sds')); + const command = getCommand('arm-sds.openMediaViewer'); + vi.mocked(vscode.window.showOpenDialog) + .mockResolvedValueOnce([selected]) + .mockResolvedValueOnce(undefined); + + await command(); + await command(); + + expect(SdsMediaViewerPanel.createOrShow).toHaveBeenCalledWith(vscode.Uri.file('/extension'), selected.fsPath, configManager); + expect(SdsMediaViewerPanel.createOrShow).toHaveBeenCalledTimes(1); + + vi.mocked(SdsMediaViewerPanel.createOrShow).mockImplementationOnce(() => { + throw new Error('media exploded'); + }); + + await command(path.join(tmpDir, 'bad-media.0.sds')); + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Failed to open media viewer: media exploded'); + }); + + it('shows an information message when quick open has no SDS files', async () => { + registerCommands(); + vi.mocked(vscode.workspace.findFiles).mockResolvedValueOnce([]); + + await getCommand('arm-sds.quickOpen')(); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith('No SDS files found in workspace.'); + expect(vscode.window.showQuickPick).not.toHaveBeenCalled(); + }); + + it('returns when quick open is cancelled and reports quick open failures', async () => { + registerCommands(); + const quickPath = path.join(tmpDir, 'cancelled.0.sds'); + const command = getCommand('arm-sds.quickOpen'); + vi.mocked(vscode.workspace.findFiles) + .mockResolvedValueOnce([vscode.Uri.file(quickPath)]) + .mockRejectedValueOnce(new Error('find failed')); + vi.mocked(vscode.window.showQuickPick).mockResolvedValueOnce(undefined); + + await command(); + await command(); + + expect(SdsViewerPanel.createOrShow).not.toHaveBeenCalled(); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Quick open failed: find failed'); + }); + + it('validates sds-check prerequisites and runs the latest bundled checker', () => { + registerCommands(); + const command = getCommand('arm-sds.sdsCheck'); + const sdsPath = path.join(tmpDir, 'checked.0.sds'); + fs.writeFileSync(sdsPath, '', 'utf-8'); + + command(); + command(path.join(tmpDir, 'missing.0.sds')); + + process.env.CMSIS_PACK_ROOT = path.join(tmpDir, 'does-not-exist'); + command(sdsPath); + + process.env.CMSIS_PACK_ROOT = tmpDir; + command(sdsPath); + + const packRoot = path.join(tmpDir, 'ARM', 'SDS'); + fs.mkdirSync(packRoot, { recursive: true }); + command(sdsPath); + + fs.mkdirSync(path.join(packRoot, '1.0.0', 'utilities'), { recursive: true }); + const missingCheckerPath = path.join(packRoot, '1.0.0', 'utilities', 'sds-check.py'); + command(sdsPath); + + fs.mkdirSync(path.join(packRoot, '2.0.0', 'utilities'), { recursive: true }); + const checkerPath = path.join(packRoot, '2.0.0', 'utilities', 'sds-check.py'); + fs.writeFileSync(checkerPath, '', 'utf-8'); + const terminal = { sendText: vi.fn(), show: vi.fn() }; + vi.mocked(vscode.window.createTerminal).mockReturnValueOnce(terminal as never); + + command(vscode.Uri.file(sdsPath)); + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('No SDS file selected for sds-check.'); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(`Selected SDS file does not exist: ${path.join(tmpDir, 'missing.0.sds')}`); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Default pack root doesn\'t exist or CMSIS_PACK_ROOT environment variable is not set. Please set it to the CMSIS Pack root directory.'); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(`SDS Pack root directory does not exist: ${packRoot}`); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(`No version folders found in SDS Pack root: ${packRoot}`); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(`sds-check.py not found in SDS Pack: ${missingCheckerPath}`); + expect(vscode.window.createTerminal).toHaveBeenCalledWith('SDS Check'); + expect(terminal.show).toHaveBeenCalled(); + expect(terminal.sendText).toHaveBeenCalledWith(`python "${checkerPath}" -i "${sdsPath}"`); + }); + + it('resolves CMSIS pack roots from the environment or platform default', () => { + const defaultSuffix = os.platform() === 'win32' ? path.join('arm', 'packs') : path.join('.cache', 'arm', 'packs'); + + expect(getCmsisPackRoot({ CMSIS_PACK_ROOT: '/custom/packs' })).toBe('/custom/packs'); + expect(getCmsisPackRoot({})).toContain(defaultSuffix); + }); }); diff --git a/test/unit/sds-flags-provider-launcher.test.ts b/test/unit/sds-flags-provider-launcher.test.ts index 84fed4f..13d0d1d 100644 --- a/test/unit/sds-flags-provider-launcher.test.ts +++ b/test/unit/sds-flags-provider-launcher.test.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +// Created using AI + import { EventEmitter } from 'events'; import { beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/test/unit/sds-parser-writer.test.ts b/test/unit/sds-parser-writer.test.ts index f69c33a..1fc4a7a 100644 --- a/test/unit/sds-parser-writer.test.ts +++ b/test/unit/sds-parser-writer.test.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +// Created using AI + /** * Unit tests for SDS binary parser and writer. * @@ -39,6 +41,11 @@ import { parseSdsBuffer, decodeRecord, decodeAllRecords, + decodeMediaFrames, + decodeImageFrameToRGBA, + decodeAudioBlock, + parseSdsRecordIterator, + indexSdsRecords, getSdsFileStats, writeSdsFile, encodeRecords, @@ -52,9 +59,12 @@ import { SdsRecord, SdsMetadata, SdsContentValue, + SdsDataType, SdsDecodedSample, sdsDataTypeSize, sdsFrameSize, + detectMediaType, + compareMetadata, } from '../../src/sds'; // ── Helpers ───────────────────────────────────────────────── @@ -102,6 +112,10 @@ describe('sdsDataTypeSize', () => { expect(sdsDataTypeSize('float')).toBe(4); expect(sdsDataTypeSize('double')).toBe(8); }); + + it('falls back to 4 bytes for unknown types', () => { + expect(sdsDataTypeSize('fixed32' as SdsDataType)).toBe(4); + }); }); describe('sdsFrameSize', () => { @@ -124,7 +138,7 @@ describe('sdsFrameSize', () => { it('handles bit-field notation (uint32_t:1)', () => { const content: SdsContentValue[] = [ - { value: 'flags', type: 'uint32_t:1' as any }, + { value: 'flags', type: 'uint32_t:1' as SdsDataType }, ]; expect(sdsFrameSize(content)).toBe(4); }); @@ -252,6 +266,17 @@ describe('decodeRecord', () => { expect(sample.timeSeconds).toBeCloseTo(1.0, 4); }); + + it('uses uint32 decoding for unknown base types', () => { + const data = Buffer.alloc(4); + data.writeUInt32LE(0x12345678, 0); + const record: SdsRecord = { timestamp: 50, dataSize: data.length, data }; + const content: SdsContentValue[] = [{ value: 'raw', type: 'fixed32' as SdsDataType }]; + + const sample = decodeRecord(record, content); + + expect(sample.values['raw']).toBe(0x12345678); + }); }); describe('decodeAllRecords', () => { @@ -300,8 +325,8 @@ describe('encodeRecords / decodeAllRecords roundtrip', () => { }; const originalSamples: SdsDecodedSample[] = [ - { timestamp: 0, timeSeconds: 0, values: { x: 1.5, y: -2.5 } }, - { timestamp: 10, timeSeconds: 0.01, values: { x: 3.0, y: 4.0 } }, + { timestamp: 0, timeSeconds: 0, values: { x: 1.5, y: -2.5 }, index: 0 }, + { timestamp: 10, timeSeconds: 0.01, values: { x: 3.0, y: 4.0 }, index: 1 }, ]; const records = encodeRecords(originalSamples, content); @@ -326,7 +351,7 @@ describe('encodeRecords / decodeAllRecords roundtrip', () => { }; const original: SdsDecodedSample[] = [ - { timestamp: 0, timeSeconds: 0, values: { temp: 25.0 } }, + { timestamp: 0, timeSeconds: 0, values: { temp: 25.0 }, index: 0 }, ]; const records = encodeRecords(original, content); @@ -354,12 +379,12 @@ describe('data type roundtrip', () => { for (const tc of testCases) { it(`roundtrips ${tc.type} (value=${tc.value})`, () => { - const content: SdsContentValue[] = [{ value: 'v', type: tc.type as any }]; + const content: SdsContentValue[] = [{ value: 'v', type: tc.type as SdsDataType }]; const metadata: SdsMetadata = { sds: { name: 'T', frequency: 1, content }, }; const samples: SdsDecodedSample[] = [ - { timestamp: 0, timeSeconds: 0, values: { v: tc.value } }, + { timestamp: 0, timeSeconds: 0, values: { v: tc.value }, index: 0 }, ]; const records = encodeRecords(samples, content); @@ -399,6 +424,199 @@ describe('getSdsFileStats', () => { expect(stats.maxBlockSize).toBe(12); expect(stats.recordingTimeSeconds).toBeCloseTo(0.2); // 200ms / 1000 }); + + it('uses zero interval and data rate for a single record', () => { + const parsed = parseSdsBuffer(Buffer.concat([ + Buffer.from([0x7B, 0, 0, 0, 4, 0, 0, 0]), + Buffer.from([1, 2, 3, 4]), + ])); + + const stats = getSdsFileStats(parsed); + + expect(stats.recordingIntervalMs).toBe(0); + expect(stats.dataRate).toBe(0); + expect(stats.avgBlockSize).toBe(4); + }); +}); + +describe('media decoding helpers', () => { + it('preserves raw record data when decoding media frames', () => { + const parsed = parseSdsBuffer(Buffer.concat([ + Buffer.from([0, 4, 0, 0, 3, 0, 0, 0]), + Buffer.from([10, 20, 30]), + Buffer.from([0, 8, 0, 0, 2, 0, 0, 0]), + Buffer.from([40, 50]), + ])); + const metadata: SdsMetadata = { + sds: { + name: 'Camera', + frequency: 30, + 'tick-frequency': 2000, + content: [{ value: 'frame', type: 'uint8_t', image: { pixel_format: 'RAW8', width: 1, height: 1 } }], + }, + }; + + const frames = decodeMediaFrames(parsed, metadata); + + expect(frames).toHaveLength(2); + expect(frames[0]).toMatchObject({ timestamp: 1024, timeSeconds: 0.512, frameIndex: 0, mediaType: 'image' }); + expect(frames[1].data).toEqual(Buffer.from([40, 50])); + }); + + it('decodes RGB888, RAW8, RGB565, NV12, NV21, and fallback image frames to RGBA', () => { + expect(Array.from(decodeImageFrameToRGBA(Buffer.from([1, 2, 3]), 1, 1, 'RGB888'))) + .toEqual([1, 2, 3, 255]); + expect(Array.from(decodeImageFrameToRGBA(Buffer.from([9]), 1, 1, 'RAW8'))) + .toEqual([9, 9, 9, 255]); + + const rgb565 = Buffer.alloc(2); + rgb565.writeUInt16LE(0xF800, 0); + expect(Array.from(decodeImageFrameToRGBA(rgb565, 1, 1, 'RGB565'))) + .toEqual([255, 0, 0, 255]); + + const nv12 = decodeImageFrameToRGBA(Buffer.from([50, 60, 70, 80, 128, 128]), 2, 2, 'NV12'); + expect(Array.from(nv12.slice(0, 8))).toEqual([50, 50, 50, 255, 60, 60, 60, 255]); + + const nv21 = decodeImageFrameToRGBA(Buffer.from([50, 60, 70, 80, 128, 128]), 2, 2, 'NV21'); + expect(Array.from(nv21.slice(8, 16))).toEqual([70, 70, 70, 255, 80, 80, 80, 255]); + + expect(Array.from(decodeImageFrameToRGBA(Buffer.from([12]), 1, 1, 'BAYER'))) + .toEqual([12, 12, 12, 255]); + }); + + it('handles truncated image input by leaving missing color channels black', () => { + expect(Array.from(decodeImageFrameToRGBA(Buffer.from([4, 5]), 1, 1, 'RGB888'))) + .toEqual([0, 0, 0, 255]); + expect(Array.from(decodeImageFrameToRGBA(Buffer.from([]), 1, 1, 'NV12'))) + .toEqual([0, 0, 0, 255]); + }); + + it('decodes PCM audio blocks across supported bit depths', () => { + const pcm8 = decodeAudioBlock(Buffer.from([0, 128, 255, 64]), 8000, 8, 2); + expect(pcm8[0][0]).toBeCloseTo(-1); + expect(pcm8[1][0]).toBeCloseTo(0); + expect(pcm8[0][1]).toBeCloseTo(127 / 128); + expect(pcm8[1][1]).toBeCloseTo(-0.5); + + const pcm16Buffer = Buffer.alloc(4); + pcm16Buffer.writeInt16LE(-32768, 0); + pcm16Buffer.writeInt16LE(16384, 2); + const pcm16 = decodeAudioBlock(pcm16Buffer, 16000, 16, 1); + expect(pcm16[0][0]).toBeCloseTo(-1); + expect(pcm16[0][1]).toBeCloseTo(0.5); + + const pcm24Positive = decodeAudioBlock(Buffer.from([0x00, 0x00, 0x40]), 16000, 24, 1); + const pcm24Negative = decodeAudioBlock(Buffer.from([0x00, 0x00, 0xC0]), 16000, 24, 1); + expect(pcm24Positive[0][0]).toBeCloseTo(0.5); + expect(pcm24Negative[0][0]).toBeCloseTo(-0.5); + + const pcm32Buffer = Buffer.alloc(4); + pcm32Buffer.writeFloatLE(0.25, 0); + expect(decodeAudioBlock(pcm32Buffer, 44100, 32, 1)[0][0]).toBeCloseTo(0.25); + expect(decodeAudioBlock(Buffer.from([1, 2]), 44100, 12, 1)[0][0]).toBe(0); + }); +}); + +describe('streaming parser helpers', () => { + it('iterates records without loading the whole file and builds an index', () => { + const records = [ + { timestamp: 10, dataSize: 2, data: Buffer.from([1, 2]) }, + { timestamp: 20, dataSize: 3, data: Buffer.from([3, 4, 5]) }, + ]; + const filePath = path.join(tmpDir, 'stream.0.sds'); + writeSdsFile(filePath, records); + + const iterated = Array.from(parseSdsRecordIterator(filePath)); + const index = indexSdsRecords(filePath); + + expect(iterated.map(record => record.recordIndex)).toEqual([0, 1]); + expect(iterated[1].data).toEqual(Buffer.from([3, 4, 5])); + expect(index).toEqual([ + { recordIndex: 0, timestamp: 10, dataSize: 2, dataOffset: 8 }, + { recordIndex: 1, timestamp: 20, dataSize: 3, dataOffset: 18 }, + ]); + }); + + it('stops iterating and indexing at a truncated record', () => { + const filePath = path.join(tmpDir, 'truncated-stream.0.sds'); + writeSdsFile(filePath, [{ timestamp: 1, dataSize: 1, data: Buffer.from([9]) }]); + const truncatedHeader = Buffer.alloc(8); + truncatedHeader.writeUInt32LE(2, 0); + truncatedHeader.writeUInt32LE(10, 4); + fs.appendFileSync(filePath, truncatedHeader); + + expect(Array.from(parseSdsRecordIterator(filePath))).toHaveLength(1); + expect(indexSdsRecords(filePath)).toHaveLength(1); + }); +}); + +describe('metadata helpers', () => { + it('detects media type priority and defaults to sensor', () => { + expect(detectMediaType({ sds: { name: 'Empty', frequency: 1, content: [] } })).toBe('sensor'); + expect(detectMediaType({ sds: { name: 'Sensor', frequency: 1, content: [{ value: 'x', type: 'float' }] } })).toBe('sensor'); + expect(detectMediaType({ + sds: { + name: 'Video', + frequency: 30, + content: [{ value: 'video', type: 'uint8_t', video: { pixel_format: 'NV12', width: 2, height: 2, fps: 30 } }], + }, + })).toBe('video'); + expect(detectMediaType({ + sds: { + name: 'Mic', + frequency: 1, + content: [ + { value: 'audio', type: 'int16_t', audio: { sample_rate: 16000, bit_depth: 16, audio_channels: 1 } }, + ], + }, + })).toBe('audio'); + expect(detectMediaType({ + sds: { + name: 'Camera', + frequency: 30, + content: [{ value: 'frame', type: 'uint8_t', image: { pixel_format: 'RAW8', width: 1, height: 1 } }], + }, + })).toBe('image'); + }); + + it('reports metadata conflicts for scalar fields and content differences', () => { + const existing: SdsMetadata = { + sds: { + name: 'A', + frequency: 10, + 'tick-frequency': 1000, + content: [{ value: 'x', type: 'float' }], + }, + }; + + expect(compareMetadata(existing, existing)).toEqual([]); + expect(compareMetadata(existing, { + sds: { + name: 'B', + frequency: 20, + 'tick-frequency': 2000, + content: [{ value: 'y', type: 'int16_t' }], + }, + })).toEqual([ + { field: 'name', existingValue: 'A', newValue: 'B' }, + { field: 'frequency', existingValue: 10, newValue: 20 }, + { field: 'tick-frequency', existingValue: 1000, newValue: 2000 }, + { field: 'content[0].value', existingValue: 'x', newValue: 'y' }, + { field: 'content[0].type', existingValue: 'float', newValue: 'int16_t' }, + ]); + expect(compareMetadata(existing, { + sds: { + name: 'A', + frequency: 10, + content: [ + { value: 'x', type: 'float' }, + { value: 'y', type: 'float' }, + ], + }, + })).toEqual([ + { field: 'content.length', existingValue: 1, newValue: 2 }, + ]); + }); }); describe('metadata YAML roundtrip', () => { @@ -487,6 +705,73 @@ describe('metadata YAML roundtrip', () => { expect(parsed.sds.content[0].video!.fps).toBe(30); }); + it('writes metadata into missing directories and preserves optional media fields', () => { + const meta: SdsMetadata = { + sds: { + name: 'Media', + frequency: 60, + content: [ + { + value: 'image', + type: 'uint8_t', + image: { pixel_format: 'RGB888', width: 2, height: 1, stride_bytes: 8 }, + }, + { + value: 'audio', + type: 'int16_t', + audio: { sample_rate: 48000, bit_depth: 24, audio_channels: 2, codec: 'pcm', frame_size: 128 }, + }, + { + value: 'video', + type: 'uint8_t', + video: { + pixel_format: 'NV12', + width: 4, + height: 2, + fps: 29.97, + codec: 'raw', + stride_bytes: 4, + keyframe_interval: 15, + }, + }, + ], + }, + }; + const filePath = path.join(tmpDir, 'nested', 'metadata', 'Media.sds.yml'); + + writeMetadataFile(filePath, meta); + const text = fs.readFileSync(filePath, 'utf-8'); + const parsed = parseMetadataFile(filePath); + + expect(text).toContain('stride_bytes: 8'); + expect(text).toContain('codec: pcm'); + expect(text).toContain('frame_size: 128'); + expect(text).toContain('keyframe_interval: 15'); + expect(parsed.sds.content[0].image!.stride_bytes).toBe(8); + expect(parsed.sds.content[1].audio).toMatchObject({ codec: 'pcm', frame_size: 128 }); + expect(parsed.sds.content[2].video).toMatchObject({ codec: 'raw', stride_bytes: 4, keyframe_interval: 15 }); + }); + + it('parses quoted scalar and content values', () => { + const parsed = parseMetadataString(` +sds: + name: "Quoted Stream" + description: 'quoted description' + frequency: 12.5 + tick-frequency: 250 + content: + - value: "channel one" + type: 'int16_t' + unit: "m/s" +`); + + expect(parsed.sds.name).toBe('Quoted Stream'); + expect(parsed.sds.description).toBe('quoted description'); + expect(parsed.sds.frequency).toBe(12.5); + expect(parsed.sds['tick-frequency']).toBe(250); + expect(parsed.sds.content[0]).toMatchObject({ value: 'channel one', type: 'int16_t', unit: 'm/s' }); + }); + it('handles scale and offset in metadata', () => { const meta: SdsMetadata = { sds: { @@ -517,8 +802,8 @@ describe('CSV export / import roundtrip', () => { }; const samples: SdsDecodedSample[] = [ - { timestamp: 0, timeSeconds: 0, values: { x: 1.5, y: 2.5 } }, - { timestamp: 10, timeSeconds: 0.01, values: { x: 3.0, y: 4.0 } }, + { timestamp: 0, timeSeconds: 0, values: { x: 1.5, y: 2.5 }, index: 0 }, + { timestamp: 10, timeSeconds: 0.01, values: { x: 3.0, y: 4.0 }, index: 1 }, ]; const csvPath = path.join(tmpDir, 'export.csv'); @@ -540,6 +825,48 @@ describe('CSV export / import roundtrip', () => { const text = fs.readFileSync(csvPath, 'utf-8'); expect(text).toBe(''); }); + + it('exports absolute timestamps and zeroes missing values', () => { + const csvPath = path.join(tmpDir, 'absolute.csv'); + const content: SdsContentValue[] = [ + { value: 'x', type: 'float' }, + { value: 'y', type: 'float' }, + ]; + const samples: SdsDecodedSample[] = [ + { timestamp: 5250, timeSeconds: 5.25, values: { x: 1 }, index: 0 }, + ]; + + exportToCsv(samples, content, csvPath, false); + + expect(fs.readFileSync(csvPath, 'utf-8')).toBe('timestamp_s,x,y\n5.250000,1.000000,0\n'); + }); + + it('imports CSV while skipping short rows and defaulting non-numeric cells to zero', () => { + const csvPath = path.join(tmpDir, 'messy.csv'); + fs.writeFileSync(csvPath, [ + 'timestamp_s,x,y', + '0.005,1,NaN', + '0.010', + '0.015,3,4', + ].join('\n'), 'utf-8'); + + const imported = importFromCsv(csvPath, 'Messy', 50, 'float', 2000); + + expect(imported.records).toHaveLength(2); + expect(imported.records[0].timestamp).toBe(10); + expect(imported.records[0].data.readFloatLE(0)).toBeCloseTo(1); + expect(imported.records[0].data.readFloatLE(4)).toBeCloseTo(0); + expect(imported.records[1].timestamp).toBe(30); + expect(imported.records[1].data.readFloatLE(4)).toBeCloseTo(4); + expect(imported.metadata.sds).toMatchObject({ name: 'Messy', frequency: 50 }); + }); + + it('rejects CSV files without data rows', () => { + const csvPath = path.join(tmpDir, 'header-only.csv'); + fs.writeFileSync(csvPath, 'timestamp_s,x\n', 'utf-8'); + + expect(() => importFromCsv(csvPath, 'Bad', 1)).toThrow('CSV file must contain at least a header row and one data row'); + }); }); describe('findNextFileIndex', () => { @@ -574,4 +901,12 @@ describe('findNextFileIndex', () => { fs.writeFileSync(path.join(tmpDir, 'My.Sensor.0.sds'), ''); expect(findNextFileIndex(tmpDir, 'My.Sensor')).toBe(1); }); + + it('recognizes optional payload suffixes and matches names case-insensitively', () => { + fs.writeFileSync(path.join(tmpDir, 'Stream.7.raw.sds'), ''); + fs.writeFileSync(path.join(tmpDir, 'Stream.10.bin.p.sds'), ''); + fs.writeFileSync(path.join(tmpDir, 'stream.12.SDS'), ''); + + expect(findNextFileIndex(tmpDir, 'Stream')).toBe(13); + }); }); diff --git a/test/unit/sdsio-config-commands.test.ts b/test/unit/sdsio-config-commands.test.ts new file mode 100644 index 0000000..07ceac0 --- /dev/null +++ b/test/unit/sdsio-config-commands.test.ts @@ -0,0 +1,280 @@ +/** + * Copyright 2026 Arm Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Created using AI + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const commandMockState = vi.hoisted(() => { + const registeredDisposables: Array<{ + command: string; + callback: (...args: unknown[]) => unknown; + dispose: () => void; + }> = []; + + const registerCommandMock = vi.fn((command: string, callback: (...args: unknown[]) => unknown) => { + const disposable = { + command, + callback, + dispose: vi.fn(), + }; + registeredDisposables.push(disposable); + return disposable; + }); + + return { + registerCommandMock, + registeredDisposables, + }; +}); + +vi.mock('vscode', () => { + class Uri { + constructor(public fsPath: string) { } + + static file(fsPath: string): Uri { + return new Uri(fsPath); + } + } + + return { + commands: { + executeCommand: vi.fn(), + registerCommand: commandMockState.registerCommandMock, + }, + Uri, + window: { + showErrorMessage: vi.fn(), + showInformationMessage: vi.fn(), + showInputBox: vi.fn(), + showOpenDialog: vi.fn(), + showQuickPick: vi.fn(), + showTextDocument: vi.fn(), + showWarningMessage: vi.fn(), + }, + workspace: { + asRelativePath: vi.fn((uri: Uri) => uri.fsPath), + findFiles: vi.fn(async () => []), + getWorkspaceFolder: vi.fn(), + openTextDocument: vi.fn(async (uri: Uri) => ({ uri })), + workspaceFolders: undefined, + }, + }; +}); + +import * as vscode from 'vscode'; +import { registerSdsioConfigCommands } from '../../src/commands/sdsioConfigCommands'; + +function createContext() { + return { + subscriptions: [] as Array<{ dispose: () => void }>, + }; +} + +function registerCommands(overrides: Partial[0]> = {}) { + const context = createContext(); + const args = { + context: context as never, + configManager: { + getConfigFile: vi.fn(() => undefined), + }, + configExtension: '.sdsio.yml', + configTemplate: 'sdsio:\n workdir: .\n', + setActiveConfig: vi.fn(async () => undefined), + resolveConfigPathFromSettings: vi.fn(() => undefined), + ensureWorkspaceConfigFile: vi.fn(), + ...overrides, + }; + + registerSdsioConfigCommands(args as Parameters[0]); + return { context, args }; +} + +function getCommand(command: string): (...args: unknown[]) => unknown { + const registration = commandMockState.registeredDisposables.find((disposable) => disposable.command === command); + if (!registration) { + throw new Error(`Command was not registered: ${command}`); + } + return registration.callback; +} + +describe('registerSdsioConfigCommands', () => { + let tmpDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + commandMockState.registeredDisposables.length = 0; + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sdsio-config-commands-')); + (vscode.workspace as unknown as { workspaceFolders: Array<{ uri: { fsPath: string } }> | undefined }).workspaceFolders = undefined; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('pushes every command registration into the extension context subscriptions', () => { + const { context } = registerCommands(); + + expect(commandMockState.registerCommandMock.mock.calls.map(([command]) => command)).toEqual([ + 'arm-sds.sds.newConfig', + 'arm-sds.sds.openConfig', + 'arm-sds.sds.selectConfig', + 'arm-sds.sds.closeConfig', + 'arm-sds.sds.editConfig', + ]); + expect(context.subscriptions).toEqual(commandMockState.registeredDisposables); + }); + + it('creates a new workspace config file and activates it', async () => { + const { args } = registerCommands(); + (vscode.workspace as unknown as { workspaceFolders: Array<{ uri: { fsPath: string } }> }).workspaceFolders = [{ uri: { fsPath: tmpDir } }]; + vi.mocked(vscode.window.showInputBox).mockImplementationOnce(async (options: unknown) => { + const validateInput = (options as { validateInput: (value: string) => string | undefined }).validateInput; + expect(validateInput('')).toBe('Name cannot be empty.'); + expect(validateInput('a/b')).toBe('Do not include path separators or drive notation.'); + expect(validateInput('target')).toBeUndefined(); + return ' target-a '; + }); + + await getCommand('arm-sds.sds.newConfig')(); + + const targetPath = path.join(tmpDir, 'target-a.sdsio.yml'); + expect(fs.readFileSync(targetPath, 'utf-8')).toBe('sdsio:\n workdir: .\n'); + expect(args.setActiveConfig).toHaveBeenCalledWith(targetPath, true); + expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith(vscode.Uri.file(targetPath)); + expect(vscode.window.showTextDocument).toHaveBeenCalledWith({ uri: vscode.Uri.file(targetPath) }); + }); + + it('opens and activates an existing config when creation would overwrite it', async () => { + const { args } = registerCommands(); + (vscode.workspace as unknown as { workspaceFolders: Array<{ uri: { fsPath: string } }> }).workspaceFolders = [{ uri: { fsPath: tmpDir } }]; + const targetPath = path.join(tmpDir, 'existing.sdsio.yml'); + fs.writeFileSync(targetPath, 'already here\n', 'utf-8'); + vi.mocked(vscode.window.showInputBox).mockResolvedValueOnce('existing'); + + await getCommand('arm-sds.sds.newConfig')(); + + expect(fs.readFileSync(targetPath, 'utf-8')).toBe('already here\n'); + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith('Configuration already exists: existing.sdsio.yml'); + expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith(vscode.Uri.file(targetPath)); + expect(args.setActiveConfig).toHaveBeenCalledWith(targetPath, true); + }); + + it('reports when new config is requested without a workspace folder', async () => { + registerCommands(); + + await getCommand('arm-sds.sds.newConfig')(); + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Open a workspace folder before creating an SDS configuration.'); + expect(vscode.window.showInputBox).not.toHaveBeenCalled(); + }); + + it('opens and activates a selected config already inside the workspace', async () => { + const { args } = registerCommands(); + const selectedUri = vscode.Uri.file(path.join(tmpDir, 'in-workspace.sdsio.yml')); + vi.mocked(vscode.window.showOpenDialog).mockResolvedValueOnce([selectedUri]); + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValueOnce({ uri: { fsPath: tmpDir } } as never); + + await getCommand('arm-sds.sds.openConfig')(); + + expect(args.setActiveConfig).toHaveBeenCalledWith(selectedUri.fsPath, true); + expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith(selectedUri); + expect(vscode.window.showTextDocument).toHaveBeenCalledWith({ uri: selectedUri }); + expect(vscode.commands.executeCommand).not.toHaveBeenCalled(); + }); + + it('opens the selected config folder when the file is outside the workspace', async () => { + const { args } = registerCommands(); + const selectedPath = path.join(tmpDir, 'external.sdsio.yml'); + const selectedUri = vscode.Uri.file(selectedPath); + vi.mocked(vscode.window.showOpenDialog).mockResolvedValueOnce([selectedUri]); + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValueOnce(undefined); + + await getCommand('arm-sds.sds.openConfig')(); + + expect(args.ensureWorkspaceConfigFile).toHaveBeenCalledWith(tmpDir, 'external.sdsio.yml'); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('vscode.openFolder', vscode.Uri.file(tmpDir), false); + }); + + it('selects a config from the current workspace', async () => { + const { args } = registerCommands(); + const first = vscode.Uri.file(path.join(tmpDir, 'a.sdsio.yml')); + const second = vscode.Uri.file(path.join(tmpDir, 'b.sdsio.yml')); + vi.mocked(vscode.workspace.findFiles).mockResolvedValueOnce([first, second]); + vi.mocked(vscode.window.showQuickPick).mockImplementationOnce(async (items: unknown) => { + return Array.isArray(items) ? items[1] : undefined; + }); + + await getCommand('arm-sds.sds.selectConfig')(); + + expect(vscode.window.showQuickPick).toHaveBeenCalledWith([ + { label: 'a.sdsio.yml', description: first.fsPath, uri: first }, + { label: 'b.sdsio.yml', description: second.fsPath, uri: second }, + ], { + placeHolder: 'Select SDS configuration file', + matchOnDescription: true, + }); + expect(args.setActiveConfig).toHaveBeenCalledWith(second.fsPath, true); + }); + + it('reports when no workspace configs are available', async () => { + registerCommands(); + vi.mocked(vscode.workspace.findFiles).mockResolvedValueOnce([]); + + await getCommand('arm-sds.sds.selectConfig')(); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith('No .sdsio.yml files found in the current workspace.'); + expect(vscode.window.showQuickPick).not.toHaveBeenCalled(); + }); + + it('closes the active config', async () => { + const { args } = registerCommands(); + + await getCommand('arm-sds.sds.closeConfig')(); + + expect(args.setActiveConfig).toHaveBeenCalledWith(undefined, true); + }); + + it('edits the active config from the manager or persisted settings', async () => { + const managerPath = path.join(tmpDir, 'manager.sdsio.yml'); + const settingsPath = path.join(tmpDir, 'settings.sdsio.yml'); + fs.writeFileSync(managerPath, 'manager\n', 'utf-8'); + fs.writeFileSync(settingsPath, 'settings\n', 'utf-8'); + const configManager = { getConfigFile: vi.fn<() => string | undefined>(() => managerPath) }; + const resolveConfigPathFromSettings = vi.fn(() => settingsPath); + registerCommands({ configManager: configManager as never, resolveConfigPathFromSettings }); + + await getCommand('arm-sds.sds.editConfig')(); + configManager.getConfigFile.mockReturnValueOnce(undefined); + await getCommand('arm-sds.sds.editConfig')(); + + expect(vscode.workspace.openTextDocument).toHaveBeenNthCalledWith(1, vscode.Uri.file(managerPath)); + expect(vscode.workspace.openTextDocument).toHaveBeenNthCalledWith(2, vscode.Uri.file(settingsPath)); + expect(resolveConfigPathFromSettings).toHaveBeenCalledTimes(1); + }); + + it('reports when there is no active config to edit', async () => { + registerCommands(); + + await getCommand('arm-sds.sds.editConfig')(); + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith('No active SDS configuration file is selected.'); + expect(vscode.workspace.openTextDocument).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/sdsio-config-lifecycle.test.ts b/test/unit/sdsio-config-lifecycle.test.ts new file mode 100644 index 0000000..44a77b7 --- /dev/null +++ b/test/unit/sdsio-config-lifecycle.test.ts @@ -0,0 +1,296 @@ +/** + * Copyright 2026 Arm Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Created using AI + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const vscodeMockState = vi.hoisted(() => { + const state = { + asRelativePathMock: vi.fn((uri: { fsPath: string }) => uri.fsPath), + configValue: '', + executeCommandMock: vi.fn(async () => undefined), + getWorkspaceFolderMock: vi.fn(), + onDidChangeConfigurationMock: vi.fn((listener: (event: { affectsConfiguration: (section: string) => boolean }) => void) => { + state.configurationChangeListener = listener; + return { dispose: vi.fn() }; + }), + updateMock: vi.fn(async () => undefined), + workspaceFolders: [] as Array<{ name: string; uri: { fsPath: string } }>, + configurationChangeListener: undefined as ((event: { affectsConfiguration: (section: string) => boolean }) => void) | undefined, + }; + + return state; +}); + +vi.mock('vscode', () => ({ + commands: { + executeCommand: vscodeMockState.executeCommandMock, + }, + ConfigurationTarget: { + Workspace: 1, + }, + Uri: { + file: (fsPath: string) => ({ fsPath }), + }, + workspace: { + asRelativePath: vscodeMockState.asRelativePathMock, + getConfiguration: vi.fn(() => ({ + get: vi.fn(() => vscodeMockState.configValue), + update: vscodeMockState.updateMock, + })), + getWorkspaceFolder: vscodeMockState.getWorkspaceFolderMock, + get workspaceFolders() { + return vscodeMockState.workspaceFolders; + }, + onDidChangeConfiguration: vscodeMockState.onDidChangeConfigurationMock, + }, +})); + +import * as vscode from 'vscode'; +import { setupSdsioConfigLifecycle } from '../../src/config/sdsioConfigLifecycle'; + +function createContext() { + return { + subscriptions: [] as Array<{ dispose: () => void }>, + }; +} + +function createHarness() { + const context = createContext(); + const configManager = { + setConfigFile: vi.fn(), + }; + const explorerTreeView = { + title: 'Files', + }; + const lifecycle = setupSdsioConfigLifecycle( + context as never, + configManager as never, + explorerTreeView as never, + '.sdsio.yml' + ); + + return { configManager, context, explorerTreeView, lifecycle }; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +function createWorkspaceFolder(name: string, fsPath: string) { + return { + name, + uri: { fsPath }, + }; +} + +describe('setupSdsioConfigLifecycle', () => { + let tmpDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sdsio-config-lifecycle-')); + vscodeMockState.asRelativePathMock.mockImplementation((uri: { fsPath: string }) => uri.fsPath); + vscodeMockState.configValue = ''; + vscodeMockState.configurationChangeListener = undefined; + vscodeMockState.getWorkspaceFolderMock.mockReturnValue(undefined); + vscodeMockState.updateMock.mockResolvedValue(undefined); + vscodeMockState.workspaceFolders = []; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('initializes from a configured workspace-relative file and updates explorer UI', async () => { + const configPath = path.join(tmpDir, 'target.sdsio.yml'); + fs.writeFileSync(configPath, 'sdsio:\n', 'utf-8'); + vscodeMockState.configValue = 'target.sdsio.yml'; + vscodeMockState.workspaceFolders = [createWorkspaceFolder('root', tmpDir)]; + + const { configManager, context, explorerTreeView, lifecycle } = createHarness(); + await flushPromises(); + + expect(lifecycle.resolveConfigPathFromSettings()).toBe(configPath); + expect(configManager.setConfigFile).toHaveBeenCalledWith(configPath); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('setContext', 'arm-sds.sdsio.hasConfig', true); + expect(explorerTreeView.title).toBe('target'); + expect(context.subscriptions).toHaveLength(1); + expect(vscode.workspace.onDidChangeConfiguration).toHaveBeenCalledTimes(1); + }); + + it('persists a selected config as a single-workspace relative path and can clear it', async () => { + const configPath = path.join(tmpDir, 'configs', 'target.sdsio.yml'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, 'sdsio:\n', 'utf-8'); + const owningFolder = createWorkspaceFolder('root', tmpDir); + vscodeMockState.workspaceFolders = [owningFolder]; + vscodeMockState.getWorkspaceFolderMock.mockReturnValue(owningFolder); + const { configManager, explorerTreeView, lifecycle } = createHarness(); + await flushPromises(); + vi.clearAllMocks(); + + await lifecycle.setActiveConfig(configPath, true); + + expect(configManager.setConfigFile).toHaveBeenCalledWith(configPath); + expect(explorerTreeView.title).toBe('target'); + expect(vscodeMockState.updateMock).toHaveBeenCalledWith( + 'configFile', + ['configs', 'target.sdsio.yml'].join('/'), + vscode.ConfigurationTarget.Workspace + ); + + await lifecycle.setActiveConfig(undefined, true); + + expect(configManager.setConfigFile).toHaveBeenLastCalledWith(undefined); + expect(explorerTreeView.title).toBe('Files'); + expect(vscodeMockState.updateMock).toHaveBeenLastCalledWith( + 'configFile', + '', + vscode.ConfigurationTarget.Workspace + ); + }); + + it('persists multi-root configs with the owning workspace folder name', async () => { + const appRoot = path.join(tmpDir, 'app'); + const docsRoot = path.join(tmpDir, 'docs'); + const configPath = path.join(appRoot, 'nested', 'board.sdsio.yml'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, 'sdsio:\n', 'utf-8'); + const appFolder = createWorkspaceFolder('app', appRoot); + vscodeMockState.workspaceFolders = [ + appFolder, + createWorkspaceFolder('docs', docsRoot), + ]; + vscodeMockState.getWorkspaceFolderMock.mockReturnValue(appFolder); + const { lifecycle } = createHarness(); + await flushPromises(); + vi.clearAllMocks(); + + await lifecycle.setActiveConfig(configPath, true); + + expect(vscodeMockState.updateMock).toHaveBeenCalledWith( + 'configFile', + 'app/nested/board.sdsio.yml', + vscode.ConfigurationTarget.Workspace + ); + }); + + it('falls back to asRelativePath when persisting a config outside workspace ownership', async () => { + const configPath = path.join(tmpDir, 'external.sdsio.yml'); + fs.writeFileSync(configPath, 'sdsio:\n', 'utf-8'); + vscodeMockState.getWorkspaceFolderMock.mockReturnValue(undefined); + vscodeMockState.asRelativePathMock.mockReturnValue('external/target.sdsio.yml'); + const { lifecycle } = createHarness(); + await flushPromises(); + vi.clearAllMocks(); + + await lifecycle.setActiveConfig(configPath, true); + + expect(vscode.workspace.asRelativePath).toHaveBeenCalledWith({ fsPath: configPath }, true); + expect(vscodeMockState.updateMock).toHaveBeenCalledWith( + 'configFile', + 'external/target.sdsio.yml', + vscode.ConfigurationTarget.Workspace + ); + }); + + it('resolves multi-root folder-prefixed settings and returns undefined for missing files', async () => { + const appRoot = path.join(tmpDir, 'app'); + const docsRoot = path.join(tmpDir, 'docs'); + const configPath = path.join(appRoot, 'configs', 'target.sdsio.yml'); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, 'sdsio:\n', 'utf-8'); + vscodeMockState.workspaceFolders = [ + createWorkspaceFolder('app', appRoot), + createWorkspaceFolder('docs', docsRoot), + ]; + vscodeMockState.configValue = ['app', 'configs', 'target.sdsio.yml'].join(path.sep); + const { lifecycle } = createHarness(); + await flushPromises(); + + expect(lifecycle.resolveConfigPathFromSettings()).toBe(configPath); + + vscodeMockState.configValue = 'missing.sdsio.yml'; + + expect(lifecycle.resolveConfigPathFromSettings()).toBeUndefined(); + }); + + it('applies external configuration changes and ignores changes caused by its own persistence', async () => { + const activePath = path.join(tmpDir, 'active.sdsio.yml'); + const changedPath = path.join(tmpDir, 'changed.sdsio.yml'); + fs.writeFileSync(activePath, 'sdsio:\n', 'utf-8'); + fs.writeFileSync(changedPath, 'sdsio:\n', 'utf-8'); + vscodeMockState.workspaceFolders = [createWorkspaceFolder('root', tmpDir)]; + const { configManager, lifecycle } = createHarness(); + await flushPromises(); + vi.clearAllMocks(); + + vscodeMockState.configurationChangeListener?.({ + affectsConfiguration: vi.fn(() => false), + }); + await flushPromises(); + + expect(configManager.setConfigFile).not.toHaveBeenCalled(); + + vscodeMockState.configValue = 'changed.sdsio.yml'; + vscodeMockState.configurationChangeListener?.({ + affectsConfiguration: vi.fn((section) => section === 'cmsis-sds.sdsio.configFile'), + }); + await flushPromises(); + + expect(configManager.setConfigFile).toHaveBeenCalledWith(changedPath); + + vi.clearAllMocks(); + vscodeMockState.getWorkspaceFolderMock.mockReturnValue(createWorkspaceFolder('root', tmpDir)); + vscodeMockState.updateMock.mockImplementationOnce(async () => { + vscodeMockState.configurationChangeListener?.({ + affectsConfiguration: vi.fn((section) => section === 'cmsis-sds.sdsio.configFile'), + }); + }); + + await lifecycle.setActiveConfig(activePath, true); + + expect(configManager.setConfigFile).toHaveBeenCalledTimes(1); + expect(configManager.setConfigFile).toHaveBeenCalledWith(activePath); + + vscodeMockState.configurationChangeListener?.({ + affectsConfiguration: vi.fn((section) => section === 'cmsis-sds.sdsio.configFile'), + }); + await flushPromises(); + + expect(configManager.setConfigFile).toHaveBeenCalledTimes(2); + expect(configManager.setConfigFile).toHaveBeenLastCalledWith(changedPath); + }); + + it('normalizes missing active config paths to undefined without persisting when requested not to', async () => { + const missingPath = path.join(tmpDir, 'missing.sdsio.yml'); + const { configManager, explorerTreeView, lifecycle } = createHarness(); + await flushPromises(); + vi.clearAllMocks(); + + await lifecycle.setActiveConfig(missingPath, false); + + expect(configManager.setConfigFile).toHaveBeenCalledWith(undefined); + expect(explorerTreeView.title).toBe('Files'); + expect(vscodeMockState.updateMock).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/sdsio-interface-commands.test.ts b/test/unit/sdsio-interface-commands.test.ts new file mode 100644 index 0000000..f0050ef --- /dev/null +++ b/test/unit/sdsio-interface-commands.test.ts @@ -0,0 +1,220 @@ +/** + * Copyright 2026 Arm Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Created using AI + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const commandMockState = vi.hoisted(() => { + const registeredDisposables: Array<{ + command: string; + callback: (...args: unknown[]) => unknown; + dispose: () => void; + }> = []; + + const registerCommandMock = vi.fn((command: string, callback: (...args: unknown[]) => unknown) => { + const disposable = { + command, + callback, + dispose: vi.fn(), + }; + registeredDisposables.push(disposable); + return disposable; + }); + + return { + registerCommandMock, + registeredDisposables, + }; +}); + +vi.mock('vscode', () => ({ + commands: { + executeCommand: vi.fn(), + registerCommand: commandMockState.registerCommandMock, + }, + window: { + showWarningMessage: vi.fn(), + }, +})); + +vi.mock('../../src/providers/sdsExplorerProvider', () => ({ + SdsExplorerProvider: class { }, + SdsTreeItem: class { }, +})); + +import * as vscode from 'vscode'; +import { registerSdsioInterfaceCommands } from '../../src/commands/sdsioInterfaceCommands'; + +function createContext() { + return { + subscriptions: [] as Array<{ dispose: () => void }>, + }; +} + +function createService() { + let changeListener: (() => void) | undefined; + + const service = { + canConnect: vi.fn(() => true), + canDisconnect: vi.fn(() => false), + canPlay: vi.fn(() => true), + canRecord: vi.fn(() => false), + canStop: vi.fn(() => true), + connectServer: vi.fn(async () => true), + disconnectServer: vi.fn(async () => undefined), + onDidChange: vi.fn((listener: () => void) => { + changeListener = listener; + return { dispose: vi.fn() }; + }), + play: vi.fn(), + record: vi.fn(), + renameFlag: vi.fn(async () => undefined), + setEnabledByTreeItems: vi.fn(), + stop: vi.fn(), + triggerChange: () => changeListener?.(), + }; + + return service; +} + +function createTreeView() { + let checkboxListener: ((changes: { items: Array<[Record, unknown]> }) => void) | undefined; + + return { + treeView: { + onDidChangeCheckboxState: vi.fn((listener: (changes: { items: Array<[Record, unknown]> }) => void) => { + checkboxListener = listener; + return { dispose: vi.fn() }; + }), + }, + triggerCheckboxChange: (items: Array<[Record, unknown]>) => { + checkboxListener?.({ items }); + }, + }; +} + +function registerCommands() { + const context = createContext(); + const service = createService(); + const explorerProvider = { refresh: vi.fn() }; + const { treeView, triggerCheckboxChange } = createTreeView(); + + registerSdsioInterfaceCommands({ + context: context as never, + sdsIoControlService: service as never, + explorerProvider: explorerProvider as never, + explorerTreeView: treeView as never, + }); + + return { context, service, explorerProvider, treeView, triggerCheckboxChange }; +} + +function getCommand(command: string): (...args: unknown[]) => unknown { + const registration = commandMockState.registeredDisposables.find((disposable) => disposable.command === command); + if (!registration) { + throw new Error(`Command was not registered: ${command}`); + } + return registration.callback; +} + +describe('registerSdsioInterfaceCommands', () => { + beforeEach(() => { + vi.clearAllMocks(); + commandMockState.registeredDisposables.length = 0; + }); + + it('registers commands, listeners, and initial command contexts', () => { + const { context } = registerCommands(); + + expect(commandMockState.registerCommandMock.mock.calls.map(([command]) => command)).toEqual([ + 'arm-sds.sdsinterface.connect', + 'arm-sds.sdsinterface.disconnect', + 'arm-sds.sdsinterface.play', + 'arm-sds.sdsinterface.record', + 'arm-sds.sdsinterface.stop', + 'arm-sds.sdsinterface.rename', + ]); + expect(context.subscriptions).toHaveLength(8); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('setContext', 'arm-sds.sdsio.canConnect', true); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('setContext', 'arm-sds.sdsio.canDisconnect', false); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('setContext', 'arm-sds.sdsio.canPlay', true); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('setContext', 'arm-sds.sdsio.canRecord', false); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('setContext', 'arm-sds.sdsio.canStop', true); + }); + + it('refreshes command contexts and the explorer when service state changes', () => { + const { service, explorerProvider } = registerCommands(); + vi.mocked(vscode.commands.executeCommand).mockClear(); + + service.canConnect.mockReturnValue(false); + service.canDisconnect.mockReturnValue(true); + service.triggerChange(); + + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('setContext', 'arm-sds.sdsio.canConnect', false); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('setContext', 'arm-sds.sdsio.canDisconnect', true); + expect(explorerProvider.refresh).toHaveBeenCalled(); + }); + + it('forwards checkbox changes only for SDS flag tree items', () => { + const { service, triggerCheckboxChange } = registerCommands(); + const flagChange = [{ itemType: 'sdsFlag', label: '0' }, true] as [Record, unknown]; + const fileChange = [{ itemType: 'sdsFile', label: 'data' }, false] as [Record, unknown]; + + triggerCheckboxChange([fileChange]); + triggerCheckboxChange([fileChange, flagChange]); + + expect(service.setEnabledByTreeItems).toHaveBeenCalledTimes(1); + expect(service.setEnabledByTreeItems).toHaveBeenCalledWith([flagChange]); + }); + + it('connects and disconnects through the control service', async () => { + const { service } = registerCommands(); + vi.mocked(vscode.commands.executeCommand).mockClear(); + + await getCommand('arm-sds.sdsinterface.connect')(); + await getCommand('arm-sds.sdsinterface.disconnect')(); + + expect(service.connectServer).toHaveBeenCalledTimes(1); + expect(service.disconnectServer).toHaveBeenCalledTimes(1); + expect(vscode.commands.executeCommand).toHaveBeenCalledTimes(10); + }); + + it('plays and records after connecting, or warns when connection fails', async () => { + const { service } = registerCommands(); + service.connectServer.mockResolvedValueOnce(false); + + await getCommand('arm-sds.sdsinterface.play')(); + + service.connectServer.mockResolvedValueOnce(true); + await getCommand('arm-sds.sdsinterface.record')(); + + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith('Unable to connect to SDSIO monitor server.'); + expect(service.play).not.toHaveBeenCalled(); + expect(service.record).toHaveBeenCalledTimes(1); + }); + + it('stops and renames flags through the control service', async () => { + const { service } = registerCommands(); + const item = { itemType: 'sdsFlag', filePath: 'flag-0' }; + + getCommand('arm-sds.sdsinterface.stop')(); + await getCommand('arm-sds.sdsinterface.rename')(item); + + expect(service.stop).toHaveBeenCalledTimes(1); + expect(service.renameFlag).toHaveBeenCalledWith(item); + }); +}); diff --git a/test/unit/sdsio-monitor-client.test.ts b/test/unit/sdsio-monitor-client.test.ts index f4bc4f8..aecd1f0 100644 --- a/test/unit/sdsio-monitor-client.test.ts +++ b/test/unit/sdsio-monitor-client.test.ts @@ -14,10 +14,87 @@ * limitations under the License. */ -import { describe, it, expect, vi } from 'vitest'; -import { HEADER_SIZE, MON_FLAGS, SdsioMonitorClient } from '../../src/recorder/sdsio/sdsIoMonitorClient'; +// Created using AI + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const netMockState = vi.hoisted(() => ({ + sockets: [] as Array<{ + destroyed: boolean; + destroy: ReturnType; + emit: (eventName: string, ...args: unknown[]) => boolean; + on: (eventName: string, listener: (...args: never[]) => void) => unknown; + removeAllListeners: ReturnType; + write: ReturnType; + }>, + createConnection: vi.fn(), +})); + +vi.mock('net', async () => { + const { EventEmitter } = await vi.importActual('events'); + + class FakeSocket extends EventEmitter { + destroyed = false; + + write = vi.fn(); + + destroy = vi.fn(() => { + this.destroyed = true; + }); + + removeAllListeners = vi.fn((eventName?: string) => { + EventEmitter.prototype.removeAllListeners.call(this, eventName); + return this; + }); + } + + netMockState.createConnection.mockImplementation(() => { + const socket = new FakeSocket(); + netMockState.sockets.push(socket as never); + return socket; + }); + + return { + createConnection: netMockState.createConnection, + }; +}); + +import { + HEADER_SIZE, + MON_CLOSE, + MON_FLAGS, + MON_INFO, + MON_OPEN, + SdsioMonitorClient, +} from '../../src/recorder/sdsio/sdsIoMonitorClient'; + +function writeHeader(cmd: number, arg1 = 0, arg2 = 0, arg3 = 0): Buffer { + const header = Buffer.alloc(HEADER_SIZE); + header.writeUInt32LE(cmd, 0); + header.writeUInt32LE(arg1 >>> 0, 4); + header.writeUInt32LE(arg2 >>> 0, 8); + header.writeUInt32LE(arg3 >>> 0, 12); + return header; +} + +function filePayload(fileName: string): Buffer { + const encoded = Buffer.from(fileName, 'utf-8'); + const len = Buffer.alloc(4); + len.writeUInt32LE(encoded.length, 0); + return Buffer.concat([len, encoded]); +} + +function fileFrame(cmd: number, fileName: string, mode = 0): Buffer { + const payload = filePayload(fileName); + return Buffer.concat([writeHeader(cmd, 0, mode, 0), payload]); +} describe('SdsioMonitorClient flag send API', () => { + beforeEach(() => { + vi.clearAllMocks(); + netMockState.sockets.length = 0; + }); + function connectedClientWithWrite(writeImpl?: (data: Buffer) => void): SdsioMonitorClient { const client = new SdsioMonitorClient(); const write = vi.fn((data: Buffer) => { @@ -91,4 +168,237 @@ describe('SdsioMonitorClient flag send API', () => { expect(() => client.setFlag(8)).toThrow(/0\.\.7/); expect(() => client.clearFlag(1.5)).toThrow(/0\.\.7/); }); + + it('mode helpers and user bit arrays write expected masks', () => { + const writes: Buffer[] = []; + const client = connectedClientWithWrite((data) => { + writes.push(data); + }); + + expect(client.startRecording()).toBe(true); + expect(client.startPlayback()).toBe(true); + expect(client.stopRecordingOrPlayback()).toBe(true); + expect(client.sendUserFlagBits([true, false, 1, 0, true, false, 0, 1])).toBe(true); + + expect(writes[0].readUInt32LE(4)).toBe(0x80000000); + expect(writes[0].readUInt32LE(8)).toBe(0x20000000); + expect(writes[1].readUInt32LE(4)).toBe(0xa0000000); + expect(writes[1].readUInt32LE(8)).toBe(0); + expect(writes[2].readUInt32LE(4)).toBe(0); + expect(writes[2].readUInt32LE(8)).toBe(0xa0000000); + expect(writes[3].readUInt32LE(4)).toBe(0x95); + expect(writes[3].readUInt32LE(8)).toBe(0x6a); + expect(() => client.sendUserFlagBits([true])).toThrow('Expected exactly 8 bits, got 1'); + }); + + it('emits an error and returns false when socket write throws', () => { + const client = connectedClientWithWrite(() => { + throw new Error('write failed'); + }); + const errors: string[] = []; + client.on('error', (message) => { + errors.push(message); + }); + + expect(client.sendFlags(1, 2)).toBe(false); + + expect(errors).toEqual(['Failed to send FLAGS: write failed']); + }); +}); + +describe('SdsioMonitorClient socket lifecycle', () => { + beforeEach(() => { + vi.clearAllMocks(); + netMockState.sockets.length = 0; + }); + + it('starts once, connects using configured host and port, and emits connection events', async () => { + const client = new SdsioMonitorClient({ host: 'localhost', port: 9000 }); + const events: string[] = []; + client.on('log', (message) => events.push(message)); + client.on('connected', () => events.push('connected')); + + await client.start(); + await client.start(); + netMockState.sockets[0].emit('connect'); + + expect(netMockState.createConnection).toHaveBeenCalledTimes(1); + expect(netMockState.createConnection).toHaveBeenCalledWith({ host: 'localhost', port: 9000 }); + expect(client.isConnected).toBe(true); + expect(events).toEqual(['Monitor connected to localhost:9000', 'connected']); + }); + + it('schedules a reconnect after socket errors and close events while running', async () => { + vi.useFakeTimers(); + try { + const client = new SdsioMonitorClient({ reconnectDelayMs: 25 }); + const events: string[] = []; + client.on('error', (message) => events.push(message)); + client.on('log', (message) => events.push(message)); + client.on('disconnected', () => events.push('disconnected')); + + await client.start(); + netMockState.sockets[0].emit('error', new Error('ECONNREFUSED')); + netMockState.sockets[0].emit('close'); + + expect(netMockState.createConnection).toHaveBeenCalledTimes(1); + expect(events).toEqual([ + 'Socket error: ECONNREFUSED', + 'Monitor disconnected', + 'disconnected', + ]); + + await vi.advanceTimersByTimeAsync(24); + expect(netMockState.createConnection).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(netMockState.createConnection).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('stop clears reconnect timers, destroys sockets, and prevents reconnects', async () => { + vi.useFakeTimers(); + try { + const client = new SdsioMonitorClient({ reconnectDelayMs: 25 }); + await client.start(); + const socket = netMockState.sockets[0]; + socket.emit('close'); + + client.stop(); + await vi.advanceTimersByTimeAsync(25); + + expect(netMockState.createConnection).toHaveBeenCalledTimes(1); + expect(socket.destroy).toHaveBeenCalledTimes(1); + expect(socket.removeAllListeners).toHaveBeenCalledTimes(1); + expect(client.isConnected).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('cleanup ignores socket destroy failures', async () => { + const client = new SdsioMonitorClient(); + await client.start(); + const socket = netMockState.sockets[0]; + socket.destroy.mockImplementationOnce(() => { + throw new Error('destroy failed'); + }); + + expect(() => client.stop()).not.toThrow(); + expect(client.isConnected).toBe(false); + }); + + it('does not connect when the internal connect guard sees a stopped client', () => { + const client = new SdsioMonitorClient(); + + (client as unknown as { _connect: () => void })._connect(); + + expect(netMockState.createConnection).not.toHaveBeenCalled(); + }); + + it('does not schedule reconnects while stopped', () => { + vi.useFakeTimers(); + try { + const client = new SdsioMonitorClient({ reconnectDelayMs: 25 }); + + (client as unknown as { _scheduleReconnect: () => void })._scheduleReconnect(); + vi.advanceTimersByTime(25); + + expect(netMockState.createConnection).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('SdsioMonitorClient protocol parsing', () => { + beforeEach(() => { + vi.clearAllMocks(); + netMockState.sockets.length = 0; + }); + + it('accumulates split frames and emits open, close, info, flags, and unknown messages', () => { + const client = new SdsioMonitorClient(); + const opened: unknown[] = []; + const closed: string[] = []; + const infos: unknown[] = []; + const logs: string[] = []; + client.on('open', (message) => opened.push(message)); + client.on('close', (fileName) => closed.push(fileName)); + client.on('info', (info) => infos.push(info)); + client.on('log', (message) => logs.push(message)); + + const openFrame = fileFrame(MON_OPEN, 'recording.sds', 1); + const idleOpenFrame = fileFrame(MON_OPEN, 'idle.sds', 0); + const closeFrame = fileFrame(MON_CLOSE, 'recording.sds'); + const infoFrame = writeHeader(MON_INFO, 0x1234, 0xffffffff, 0); + const infoFrameWithPayload = Buffer.concat([ + writeHeader(MON_INFO, 0x5, 42, 3), + Buffer.from('err'), + ]); + const flagsFrame = writeHeader(MON_FLAGS, 1, 2, 3); + const unknownFrame = writeHeader(99); + const firstChunk = openFrame.subarray(0, HEADER_SIZE + 5); + const secondChunk = Buffer.concat([ + openFrame.subarray(HEADER_SIZE + 5), + idleOpenFrame, + closeFrame, + infoFrame, + infoFrameWithPayload, + flagsFrame, + unknownFrame, + ]); + + (client as unknown as { _onData: (data: Buffer) => void })._onData(firstChunk); + expect(opened).toEqual([]); + + (client as unknown as { _onData: (data: Buffer) => void })._onData(secondChunk); + + expect(opened).toEqual([ + { mode: 1, fileName: 'recording.sds' }, + { mode: 0, fileName: 'idle.sds' }, + ]); + expect(closed).toEqual(['recording.sds']); + expect(infos).toEqual([ + { sdsFlags: 0x1234, sdsIdleRate: undefined }, + { sdsFlags: 0x5, sdsIdleRate: 42 }, + ]); + expect(logs).toEqual(['Unknown monitor message: 6', 'Unknown monitor message: 99']); + }); + + it('ignores malformed open and close payloads', () => { + const client = new SdsioMonitorClient(); + const opened: unknown[] = []; + const closed: string[] = []; + client.on('open', (message) => opened.push(message)); + client.on('close', (fileName) => closed.push(fileName)); + + (client as unknown as { _onData: (data: Buffer) => void })._onData(writeHeader(MON_OPEN)); + (client as unknown as { _handleOpen: (header: unknown, payload: Buffer) => void })._handleOpen( + { arg2: 0 }, + Buffer.from([10, 0, 0, 0, 65]) + ); + (client as unknown as { _handleClose: (payload: Buffer) => void })._handleClose(Buffer.alloc(0)); + (client as unknown as { _handleClose: (payload: Buffer) => void })._handleClose(Buffer.from([10, 0, 0, 0, 65])); + + expect(opened).toEqual([]); + expect(closed).toEqual([]); + }); + + it('emits frame processing errors from socket data handlers and protects against listener errors', async () => { + const client = new SdsioMonitorClient(); + const errors: string[] = []; + client.on('open', () => { + throw new Error('listener failed'); + }); + client.on('error', (message) => errors.push(message)); + await client.start(); + const socket = netMockState.sockets[0]; + + socket.emit('data', fileFrame(MON_OPEN, 'listener.sds')); + socket.emit('data', undefined); + + expect(errors).toEqual([expect.stringContaining('Frame processing error:')]); + }); }); diff --git a/test/unit/viewer-panel-utils.test.ts b/test/unit/viewer-panel-utils.test.ts new file mode 100644 index 0000000..500f22c --- /dev/null +++ b/test/unit/viewer-panel-utils.test.ts @@ -0,0 +1,210 @@ +/** + * Copyright 2026 Arm Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Created using AI + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const vscodeMockState = vi.hoisted(() => ({ + decimationPreset: 'accuracy', +})); + +const webviewBusMockState = vi.hoisted(() => ({ + handleIncoming: vi.fn(), + register: vi.fn(), + sendInit: vi.fn(), + unregister: vi.fn(), +})); + +vi.mock('vscode', () => { + class Disposable { + constructor(private readonly callback: () => void) { } + + dispose(): void { + this.callback(); + } + } + + class Uri { + constructor(public readonly fsPath: string) { } + + static file(fsPath: string): Uri { + return new Uri(fsPath); + } + + static joinPath(base: Uri, ...segments: string[]): Uri { + return new Uri([base.fsPath, ...segments].join('/')); + } + } + + return { + Disposable, + Uri, + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn((_section: string, fallback: string) => vscodeMockState.decimationPreset || fallback), + })), + }, + }; +}); + +vi.mock('../../src/webview/webview-bus', () => ({ + webviewBus: webviewBusMockState, +})); + +vi.mock('../../src/webview/guard', () => ({ + isMessage: vi.fn(), +})); + +import * as vscode from 'vscode'; +import { isMessage } from '../../src/webview/guard'; +import { ViewerSettings } from '../../src/viewer/viewerSettings'; +import { + buildViewerWebviewHtml, + generateNonce, + registerViewerWebview, + resolveMetadataPathForSdsFile, +} from '../../src/viewer/viewerPanelUtils'; + +type MockWebview = { + cspSource: string; + asWebviewUri: ReturnType; + onDidReceiveMessage: ReturnType; +}; + +describe('viewerPanelUtils', () => { + let tmpDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'viewer-utils-')); + vscodeMockState.decimationPreset = 'accuracy'; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves metadata from mirrored metadir, direct metadir, same directory, and invalid names', () => { + const workdir = path.join(tmpDir, 'work'); + const metadir = path.join(tmpDir, 'meta'); + const nestedSds = path.join(workdir, 'captures', 'stream.3.sds'); + const mirroredMetadata = path.join(metadir, 'captures', 'stream.sds.yml'); + fs.mkdirSync(path.dirname(mirroredMetadata), { recursive: true }); + fs.writeFileSync(mirroredMetadata, 'sds:\n'); + + const configManager = { + getConfig: () => ({ workdir, metadir }), + }; + + expect(resolveMetadataPathForSdsFile(nestedSds, '.sds.yml', configManager as never)).toBe(mirroredMetadata); + + fs.rmSync(path.join(metadir, 'captures'), { recursive: true, force: true }); + const directMetadata = path.join(metadir, 'stream.sds.yml'); + fs.writeFileSync(directMetadata, 'sds:\n'); + expect(resolveMetadataPathForSdsFile(nestedSds, '.sds.yml', configManager as never)).toBe(directMetadata); + + fs.rmSync(directMetadata); + const sameDirMetadata = path.join(path.dirname(nestedSds), 'stream.sds.yml'); + fs.mkdirSync(path.dirname(sameDirMetadata), { recursive: true }); + fs.writeFileSync(sameDirMetadata, 'sds:\n'); + expect(resolveMetadataPathForSdsFile(nestedSds, '.sds.yml')).toBe(sameDirMetadata); + + expect(resolveMetadataPathForSdsFile(path.join(tmpDir, 'not-sds.txt'), '.sds.yml')).toBeUndefined(); + expect(resolveMetadataPathForSdsFile(nestedSds, '.sds.yml', { getConfig: () => ({}) } as never)).toBe(sameDirMetadata); + }); + + it('generates nonce values with the requested length and allowed characters', () => { + const nonce = generateNonce(24); + + expect(nonce).toHaveLength(24); + expect(nonce).toMatch(/^[A-Za-z0-9]+$/); + }); + + it('builds webview HTML with resource URIs, nonce, CSP, and escaped initial state', () => { + const webview: MockWebview = { + cspSource: 'vscode-resource:', + asWebviewUri: vi.fn((uri: { fsPath: string }) => `webview://${uri.fsPath}`), + onDidReceiveMessage: vi.fn(), + }; + + const html = buildViewerWebviewHtml({ + webview: webview as never, + extensionUri: vscode.Uri.file('/extension'), + styleFile: 'viewer.css', + scriptFile: 'viewer.js', + title: 'Viewer', + initialState: { danger: '', value: 1 }, + }); + + expect(webview.asWebviewUri).toHaveBeenCalledTimes(2); + expect(html).toContain('Viewer'); + expect(html).toContain('webview:///extension/out/viewer.css'); + expect(html).toContain('webview:///extension/out/viewer.js'); + expect(html).toContain('script-src'); + expect(html).toContain('\\u003c/script>'); + }); + + it('registers webviews with the bus and forwards only valid messages', () => { + let messageHandler: ((message: unknown) => void) | undefined; + const incomingDisposable = { dispose: vi.fn() }; + const webview: MockWebview = { + cspSource: 'vscode-resource:', + asWebviewUri: vi.fn(), + onDidReceiveMessage: vi.fn((handler: (message: unknown) => void) => { + messageHandler = handler; + return incomingDisposable; + }), + }; + + const disposable = registerViewerWebview(webview as never); + + expect(webviewBusMockState.register).toHaveBeenCalledWith(webview); + expect(webviewBusMockState.sendInit).toHaveBeenCalledWith(webview); + + vi.mocked(isMessage).mockReturnValueOnce(false); + messageHandler?.({ nope: true }); + expect(webviewBusMockState.handleIncoming).not.toHaveBeenCalled(); + + const validMessage = { command: 'refresh' }; + vi.mocked(isMessage).mockReturnValueOnce(true); + messageHandler?.(validMessage); + expect(webviewBusMockState.handleIncoming).toHaveBeenCalledWith(webview, validMessage); + + disposable.dispose(); + expect(incomingDisposable.dispose).toHaveBeenCalled(); + expect(webviewBusMockState.unregister).toHaveBeenCalledWith(webview); + }); +}); + +describe('ViewerSettings', () => { + beforeEach(() => { + vscodeMockState.decimationPreset = 'accuracy'; + }); + + it('returns performance only for the performance setting', () => { + vscodeMockState.decimationPreset = 'performance'; + expect(ViewerSettings.getDecimationPreset()).toBe('performance'); + }); + + it('falls back to accuracy for unknown settings', () => { + vscodeMockState.decimationPreset = 'something-else'; + expect(ViewerSettings.getDecimationPreset()).toBe('accuracy'); + }); +}); diff --git a/test/unit/viewer-panels.test.ts b/test/unit/viewer-panels.test.ts new file mode 100644 index 0000000..24d5d63 --- /dev/null +++ b/test/unit/viewer-panels.test.ts @@ -0,0 +1,684 @@ +/** + * Copyright 2026 Arm Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Created using AI + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +type MessageHandler = (message: unknown) => unknown; + +type MockWebview = { + html: string; + cspSource: string; + asWebviewUri: ReturnType; + postMessage: ReturnType; + onDidReceiveMessage: ReturnType; + messageHandlers: MessageHandler[]; +}; + +type MockPanel = { + title: string; + webview: MockWebview; + iconPath?: unknown; + reveal: ReturnType; + dispose: ReturnType; + onDidDispose: ReturnType; + triggerDispose: () => void; +}; + +type SdsRecordLike = { + timestamp: number; + dataSize: number; + data: Buffer; +}; + +const vscodeMockState = vi.hoisted(() => ({ + activeTextEditor: undefined as { viewColumn?: number } | undefined, + createdPanels: [] as MockPanel[], + createWebviewPanelMock: vi.fn(), + executeCommandMock: vi.fn(async () => undefined), + showErrorMessageMock: vi.fn(), +})); + +const sdsMockState = vi.hoisted(() => ({ + decodeAllRecords: vi.fn(), + decodeAudioBlock: vi.fn(), + decodeImageFrameToRGBA: vi.fn(), + detectMediaType: vi.fn(), + getSdsFileStats: vi.fn(), + indexSdsRecords: vi.fn(), + parseMetadataFile: vi.fn(), + parseSdsFile: vi.fn(), +})); + +const viewerUtilsMockState = vi.hoisted(() => ({ + buildViewerWebviewHtml: vi.fn((options: { title: string; initialState: Record }) => JSON.stringify({ + title: options.title, + initialState: options.initialState, + })), + registerViewerWebview: vi.fn(() => ({ dispose: vi.fn() })), + resolveMetadataPathForSdsFile: vi.fn(), +})); + +const viewerSettingsMockState = vi.hoisted(() => ({ + decimationPreset: 'accuracy', + getDecimationPreset: vi.fn(), +})); + +vi.mock('vscode', () => { + class ThemeIcon { + constructor(public readonly id: string) { } + } + + class Uri { + constructor(public readonly fsPath: string) { } + + static file(fsPath: string): Uri { + return new Uri(fsPath); + } + + static joinPath(base: Uri, ...segments: string[]): Uri { + return new Uri([base.fsPath, ...segments].join('/')); + } + } + + function createWebview(): MockWebview { + const webview: MockWebview = { + html: '', + cspSource: 'vscode-resource:', + asWebviewUri: vi.fn((uri: { fsPath: string }) => `webview://${uri.fsPath}`), + postMessage: vi.fn(async () => true), + onDidReceiveMessage: vi.fn((handler: MessageHandler) => { + webview.messageHandlers.push(handler); + return { dispose: vi.fn() }; + }), + messageHandlers: [], + }; + return webview; + } + + vscodeMockState.createWebviewPanelMock.mockImplementation((_viewType: string, title: string) => { + const disposeListeners: Array<() => void> = []; + let didTriggerDispose = false; + const panel: MockPanel = { + title, + webview: createWebview(), + reveal: vi.fn(), + dispose: vi.fn(), + onDidDispose: vi.fn((handler: () => void) => { + disposeListeners.push(handler); + return { dispose: vi.fn() }; + }), + triggerDispose: () => { + if (didTriggerDispose) { + return; + } + didTriggerDispose = true; + for (const listener of [...disposeListeners]) { + listener(); + } + }, + }; + vscodeMockState.createdPanels.push(panel); + return panel; + }); + + return { + ThemeIcon, + Uri, + ViewColumn: { One: 1 }, + commands: { + executeCommand: vscodeMockState.executeCommandMock, + }, + window: { + get activeTextEditor() { + return vscodeMockState.activeTextEditor; + }, + createWebviewPanel: vscodeMockState.createWebviewPanelMock, + showErrorMessage: vscodeMockState.showErrorMessageMock, + }, + }; +}); + +vi.mock('../../src/sds', () => ({ + decodeAllRecords: sdsMockState.decodeAllRecords, + decodeAudioBlock: sdsMockState.decodeAudioBlock, + decodeImageFrameToRGBA: sdsMockState.decodeImageFrameToRGBA, + detectMediaType: sdsMockState.detectMediaType, + getSdsFileStats: sdsMockState.getSdsFileStats, + indexSdsRecords: sdsMockState.indexSdsRecords, + parseMetadataFile: sdsMockState.parseMetadataFile, + parseSdsFile: sdsMockState.parseSdsFile, + SDS_METADATA_EXTENSION: '.sds.yml', +})); + +vi.mock('../../src/viewer/viewerPanelUtils', () => ({ + buildViewerWebviewHtml: viewerUtilsMockState.buildViewerWebviewHtml, + registerViewerWebview: viewerUtilsMockState.registerViewerWebview, + resolveMetadataPathForSdsFile: viewerUtilsMockState.resolveMetadataPathForSdsFile, +})); + +vi.mock('../../src/viewer/viewerSettings', () => ({ + ViewerSettings: { + getDecimationPreset: viewerSettingsMockState.getDecimationPreset, + }, +})); + +import * as vscode from 'vscode'; +import { SdsMediaViewerPanel } from '../../src/viewer/sdsMediaViewerPanel'; +import { SdsViewerPanel } from '../../src/viewer/sdsViewerPanel'; + +function createParsedFile(records: SdsRecordLike[]) { + return { + filePath: '', + records, + totalDataSize: records.reduce((sum, record) => sum + record.dataSize, 0), + totalRecords: records.length, + durationMs: records.length > 1 ? records[records.length - 1].timestamp - records[0].timestamp : 0, + }; +} + +function metadataFile(tmpDir: string, name = 'stream.sds.yml'): string { + const filePath = path.join(tmpDir, name); + fs.writeFileSync(filePath, 'sds:\n'); + return filePath; +} + +function latestPanel(): MockPanel { + const panel = vscodeMockState.createdPanels[vscodeMockState.createdPanels.length - 1]; + if (!panel) { + throw new Error('No webview panel was created'); + } + return panel; +} + +function initialState(panel: MockPanel): Record { + return JSON.parse(panel.webview.html).initialState as Record; +} + +async function dispatchMessage(panel: MockPanel, message: unknown): Promise { + const handler = panel.webview.messageHandlers[panel.webview.messageHandlers.length - 1]; + if (!handler) { + throw new Error('No webview message handler registered'); + } + handler(message); + await Promise.resolve(); + await Promise.resolve(); +} + +describe('SdsViewerPanel', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sds-viewer-panel-')); + vscodeMockState.activeTextEditor = undefined; + vscodeMockState.createdPanels.length = 0; + vscodeMockState.createWebviewPanelMock.mockClear(); + vscodeMockState.executeCommandMock.mockClear(); + vscodeMockState.executeCommandMock.mockResolvedValue(undefined); + vscodeMockState.showErrorMessageMock.mockClear(); + viewerSettingsMockState.decimationPreset = 'accuracy'; + viewerSettingsMockState.getDecimationPreset.mockReset(); + viewerSettingsMockState.getDecimationPreset.mockImplementation(() => viewerSettingsMockState.decimationPreset); + viewerUtilsMockState.buildViewerWebviewHtml.mockClear(); + viewerUtilsMockState.registerViewerWebview.mockClear(); + viewerUtilsMockState.resolveMetadataPathForSdsFile.mockReset(); + viewerUtilsMockState.resolveMetadataPathForSdsFile.mockReturnValue(undefined); + sdsMockState.decodeAllRecords.mockReset(); + sdsMockState.getSdsFileStats.mockReset(); + sdsMockState.parseMetadataFile.mockReset(); + sdsMockState.parseSdsFile.mockReset(); + sdsMockState.getSdsFileStats.mockReturnValue({ totalRecords: 0 }); + sdsMockState.parseSdsFile.mockReturnValue(createParsedFile([])); + }); + + afterEach(() => { + for (const panel of [...vscodeMockState.createdPanels]) { + panel.triggerDispose(); + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('creates a data viewer, decodes metadata-backed samples, and reuses existing panels', () => { + const sdsPath = path.join(tmpDir, 'stream.0.sds'); + const metaPath = metadataFile(tmpDir); + const metadata = { + sds: { + name: 'stream', + frequency: 100, + 'tick-frequency': 2000, + content: [{ value: 'x', type: 'float' }], + }, + }; + const samples = [ + { timestamp: 0, timeSeconds: 0, values: { x: 1 }, index: 0 }, + { timestamp: 2000, timeSeconds: 1, values: { x: 2 }, index: 1 }, + ]; + sdsMockState.parseSdsFile.mockReturnValue(createParsedFile([ + { timestamp: 0, dataSize: 4, data: Buffer.from([1, 2, 3, 4]) }, + { timestamp: 2000, dataSize: 4, data: Buffer.from([5, 6, 7, 8]) }, + ])); + sdsMockState.parseMetadataFile.mockReturnValue(metadata); + sdsMockState.decodeAllRecords.mockReturnValue(samples); + sdsMockState.getSdsFileStats.mockReturnValue({ totalRecords: 2, recordingTimeSeconds: 1 }); + vscodeMockState.activeTextEditor = { viewColumn: 2 }; + + const first = SdsViewerPanel.createOrShow(vscode.Uri.file('/extension'), sdsPath, undefined, metaPath); + const second = SdsViewerPanel.createOrShow(vscode.Uri.file('/extension'), sdsPath, undefined, metaPath); + const panel = latestPanel(); + + expect(first).toBe(second); + expect(vscodeMockState.createWebviewPanelMock).toHaveBeenCalledTimes(1); + expect(panel.reveal).toHaveBeenCalledWith(2); + expect((panel.iconPath as { id: string }).id).toBe('graph-line'); + expect(sdsMockState.parseMetadataFile).toHaveBeenCalledWith(metaPath); + expect(sdsMockState.decodeAllRecords).toHaveBeenCalledWith(expect.anything(), metadata); + expect(viewerUtilsMockState.registerViewerWebview).toHaveBeenCalledWith(panel.webview); + expect(initialState(panel)).toMatchObject({ + channelNames: ['x'], + samples, + stats: { totalRecords: 2, recordingTimeSeconds: 1 }, + metadata, + domainStart: 0, + domainEnd: 1, + fileName: 'stream.0.sds', + decimationPreset: 'accuracy', + }); + }); + + it('shows record data sizes when no metadata is available', () => { + const sdsPath = path.join(tmpDir, 'raw.0.sds'); + sdsMockState.parseSdsFile.mockReturnValue(createParsedFile([ + { timestamp: 100, dataSize: 2, data: Buffer.from([1, 2]) }, + { timestamp: 200, dataSize: 5, data: Buffer.from([1, 2, 3, 4, 5]) }, + ])); + + SdsViewerPanel.createOrShow(vscode.Uri.file('/extension'), sdsPath); + const state = initialState(latestPanel()); + + expect(state.channelNames).toEqual(['data_size']); + expect(state.samples).toMatchObject([ + { timestamp: 100, timeSeconds: 0.1, values: { data_size: 2 }, index: 0 }, + { timestamp: 200, timeSeconds: 0.2, values: { data_size: 5 }, index: 1 }, + ]); + expect(state.domainStart).toBe(0.1); + expect(state.domainEnd).toBe(0.2); + }); + + it('handles export, refresh, visible range requests, and message errors', async () => { + const sdsPath = path.join(tmpDir, 'dense.0.sds'); + const metaPath = metadataFile(tmpDir, 'dense.sds.yml'); + const metadata = { + sds: { + name: 'dense', + frequency: 1000, + content: [{ value: 'x', type: 'float' }], + }, + }; + const samples = Array.from({ length: 3000 }, (_value, index) => ({ + timestamp: index, + timeSeconds: index / 1000, + values: { x: index % 17 }, + index, + })); + sdsMockState.parseSdsFile.mockReturnValue(createParsedFile([])); + sdsMockState.parseMetadataFile.mockReturnValue(metadata); + sdsMockState.decodeAllRecords.mockReturnValue(samples); + + SdsViewerPanel.createOrShow(vscode.Uri.file('/extension'), sdsPath, undefined, metaPath); + const panel = latestPanel(); + const initialParseCount = sdsMockState.parseSdsFile.mock.calls.length; + + await dispatchMessage(panel, { command: 'exportCsv' }); + expect(vscodeMockState.executeCommandMock).toHaveBeenCalledWith('arm-sds.exportCsv', sdsPath); + + await dispatchMessage(panel, { command: 'refresh' }); + expect(sdsMockState.parseSdsFile.mock.calls.length).toBe(initialParseCount + 1); + + await dispatchMessage(panel, { + command: 'requestVisibleRangeData', + requestId: 7, + payload: { rangeStart: 0, rangeEnd: 2.999, plotWidth: 100, quality: 'low' }, + }); + expect(panel.webview.postMessage).toHaveBeenLastCalledWith(expect.objectContaining({ + command: 'visibleRangeData', + requestId: 7, + payload: expect.objectContaining({ + rangeStart: 0, + rangeEnd: 2.999, + quality: 'low', + }), + })); + const visibleRangeCalls = panel.webview.postMessage.mock.calls; + const posted = visibleRangeCalls[visibleRangeCalls.length - 1][0] as { payload: { samples: unknown[] } }; + expect(posted.payload.samples.length).toBeLessThan(samples.length); + + panel.webview.postMessage.mockImplementationOnce(() => { + throw new Error('post failed'); + }); + await dispatchMessage(panel, { + command: 'requestVisibleRangeData', + payload: { rangeStart: 0, rangeEnd: 1, plotWidth: 100, quality: 'high' }, + }); + expect(vscodeMockState.showErrorMessageMock).toHaveBeenCalledWith('Viewer error: post failed'); + }); + + it('renders an error state when loading fails', () => { + sdsMockState.parseSdsFile.mockImplementation(() => { + throw new Error('cannot parse'); + }); + + SdsViewerPanel.createOrShow(vscode.Uri.file('/extension'), path.join(tmpDir, 'broken.0.sds')); + + expect(initialState(latestPanel())).toEqual({ error: 'cannot parse' }); + }); +}); + +describe('SdsMediaViewerPanel', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sds-media-panel-')); + vscodeMockState.activeTextEditor = undefined; + vscodeMockState.createdPanels.length = 0; + vscodeMockState.createWebviewPanelMock.mockClear(); + vscodeMockState.showErrorMessageMock.mockClear(); + viewerSettingsMockState.decimationPreset = 'performance'; + viewerSettingsMockState.getDecimationPreset.mockReset(); + viewerSettingsMockState.getDecimationPreset.mockImplementation(() => viewerSettingsMockState.decimationPreset); + viewerUtilsMockState.buildViewerWebviewHtml.mockClear(); + viewerUtilsMockState.registerViewerWebview.mockClear(); + viewerUtilsMockState.resolveMetadataPathForSdsFile.mockReset(); + viewerUtilsMockState.resolveMetadataPathForSdsFile.mockReturnValue(undefined); + sdsMockState.decodeAudioBlock.mockReset(); + sdsMockState.decodeImageFrameToRGBA.mockReset(); + sdsMockState.detectMediaType.mockReset(); + sdsMockState.getSdsFileStats.mockReset(); + sdsMockState.indexSdsRecords.mockReset(); + sdsMockState.parseMetadataFile.mockReset(); + sdsMockState.parseSdsFile.mockReset(); + sdsMockState.getSdsFileStats.mockReturnValue({ totalRecords: 0 }); + sdsMockState.indexSdsRecords.mockReturnValue([]); + sdsMockState.parseSdsFile.mockReturnValue(createParsedFile([])); + }); + + afterEach(() => { + for (const panel of [...vscodeMockState.createdPanels]) { + panel.triggerDispose(); + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('shows an error when media metadata is missing and reuses existing media panels', () => { + const sdsPath = path.join(tmpDir, 'no-meta.0.sds'); + vscodeMockState.activeTextEditor = { viewColumn: 3 }; + + const first = SdsMediaViewerPanel.createOrShow(vscode.Uri.file('/extension'), sdsPath); + const second = SdsMediaViewerPanel.createOrShow(vscode.Uri.file('/extension'), sdsPath); + const panel = latestPanel(); + + expect(first).toBe(second); + expect(vscodeMockState.createWebviewPanelMock).toHaveBeenCalledTimes(1); + expect(panel.reveal).toHaveBeenCalledWith(3); + expect(initialState(panel)).toMatchObject({ + error: 'No metadata (.sds.yml) found. Media viewer requires metadata to decode frames.', + fileName: 'no-meta.0.sds', + }); + }); + + it('builds image state and responds with decoded frame windows', async () => { + const sdsPath = path.join(tmpDir, 'camera.0.sds'); + fs.writeFileSync(sdsPath, Buffer.alloc(64, 9)); + const metaPath = metadataFile(tmpDir, 'camera.sds.yml'); + const metadata = { + sds: { + name: 'camera', + frequency: 30, + 'tick-frequency': 100, + content: [{ value: 'frame', type: 'uint8_t', image: { pixel_format: 'RAW8', width: 1, height: 1 } }], + }, + }; + sdsMockState.parseMetadataFile.mockReturnValue(metadata); + sdsMockState.detectMediaType.mockReturnValue('image'); + sdsMockState.indexSdsRecords.mockReturnValue([ + { timestamp: 0, dataSize: 4, dataOffset: 0 }, + { timestamp: 10, dataSize: 0, dataOffset: 4 }, + { timestamp: 20, dataSize: 4, dataOffset: 8 }, + { timestamp: 30, dataSize: 100, dataOffset: 999 }, + ]); + sdsMockState.parseSdsFile.mockReturnValue(createParsedFile([])); + sdsMockState.decodeImageFrameToRGBA + .mockReturnValueOnce(new Uint8Array([1, 2, 3, 4])) + .mockImplementationOnce(() => { + throw new Error('decode failed'); + }); + + SdsMediaViewerPanel.createOrShow(vscode.Uri.file('/extension'), sdsPath, undefined, metaPath); + const panel = latestPanel(); + + expect(panel.title).toBe('SDS Image: camera.0.sds'); + expect((panel.iconPath as { id: string }).id).toBe('device-camera'); + expect(initialState(panel)).toMatchObject({ + mediaType: 'image', + image: { + width: 1, + height: 1, + totalFrames: 4, + interval: '30', + pixelFormat: 'RAW8', + }, + decimationPreset: 'performance', + }); + + await dispatchMessage(panel, { + command: 'requestMediaFrameWindow', + requestId: 1, + payload: { mediaType: 'sensor', centerIndex: 1, windowSize: 3, quality: 'high' }, + }); + expect(panel.webview.postMessage).not.toHaveBeenCalled(); + + await dispatchMessage(panel, { + command: 'requestMediaFrameWindow', + requestId: 2, + payload: { mediaType: 'image', centerIndex: 1, windowSize: 4, quality: 'low' }, + }); + + expect(panel.webview.postMessage).toHaveBeenCalledWith({ + command: 'mediaFrameWindowData', + requestId: 2, + payload: { + mediaType: 'image', + rangeStart: 0, + rangeEnd: 4, + quality: 'low', + frames: [{ timestamp: 0, rgbaBase64: 'AQIDBA==' }], + }, + }); + }); + + it('builds video state and decodes requested video frame windows', async () => { + const sdsPath = path.join(tmpDir, 'video.0.sds'); + fs.writeFileSync(sdsPath, Buffer.alloc(16, 7)); + const metaPath = metadataFile(tmpDir, 'video.sds.yml'); + const metadata = { + sds: { + name: 'video', + frequency: 24, + content: [{ value: 'frame', type: 'uint8_t', video: { pixel_format: 'RGB888', width: 2, height: 1, fps: 24, codec: 'raw' } }], + }, + }; + sdsMockState.parseMetadataFile.mockReturnValue(metadata); + sdsMockState.detectMediaType.mockReturnValue('video'); + sdsMockState.indexSdsRecords.mockReturnValue([{ timestamp: 1000, dataSize: 4, dataOffset: 0 }]); + sdsMockState.decodeImageFrameToRGBA.mockReturnValue(new Uint8Array([5, 6, 7, 8])); + + SdsMediaViewerPanel.createOrShow(vscode.Uri.file('/extension'), sdsPath, undefined, metaPath); + const panel = latestPanel(); + + expect(panel.title).toBe('SDS Video: video.0.sds'); + expect((panel.iconPath as { id: string }).id).toBe('device-camera-video'); + expect(initialState(panel)).toMatchObject({ + mediaType: 'video', + video: { + width: 2, + height: 1, + fps: 24, + totalFrames: 1, + codec: 'raw', + pixelFormat: 'RGB888', + }, + }); + + await dispatchMessage(panel, { + command: 'requestMediaFrameWindow', + requestId: 3, + payload: { mediaType: 'video', centerIndex: 0, windowSize: 5 }, + }); + + expect(panel.webview.postMessage).toHaveBeenCalledWith(expect.objectContaining({ + command: 'mediaFrameWindowData', + requestId: 3, + payload: expect.objectContaining({ + mediaType: 'video', + frames: [{ timestamp: 1, rgbaBase64: 'BQYHCA==' }], + }), + })); + }); + + it('builds audio state and serves reduced audio windows', async () => { + const sdsPath = path.join(tmpDir, 'audio.0.sds'); + const metaPath = metadataFile(tmpDir, 'audio.sds.yml'); + const metadata = { + sds: { + name: 'audio', + frequency: 100, + 'tick-frequency': 1000, + content: [{ value: 'samples', type: 'int16_t', audio: { sample_rate: 1000, bit_depth: 16, audio_channels: 1 } }], + }, + }; + const records = Array.from({ length: 200 }, (_value, index) => ({ + timestamp: index * 10, + dataSize: 1, + data: Buffer.from([index]), + })); + records[0].data = Buffer.from([255]); + sdsMockState.parseMetadataFile.mockReturnValue(metadata); + sdsMockState.detectMediaType.mockReturnValue('audio'); + sdsMockState.parseSdsFile.mockReturnValue(createParsedFile(records)); + sdsMockState.decodeAudioBlock.mockImplementation((data: Buffer) => { + if (data[0] === 255) { + throw new Error('bad audio'); + } + return [Float32Array.from([data[0] / 100])]; + }); + + SdsMediaViewerPanel.createOrShow(vscode.Uri.file('/extension'), sdsPath, undefined, metaPath); + const panel = latestPanel(); + const state = initialState(panel); + + expect(panel.title).toBe('SDS Audio: audio.0.sds'); + expect((panel.iconPath as { id: string }).id).toBe('unmute'); + expect(state).toMatchObject({ + mediaType: 'audio', + audio: { + sampleRate: 1000, + bitDepth: 16, + channels: 1, + totalSamples: 199, + totalRecords: 200, + }, + }); + + await dispatchMessage(panel, { + command: 'requestMediaAudioWindow', + requestId: 4, + payload: { rangeStart: 0, rangeEnd: 2, plotWidth: 20, quality: 'low' }, + }); + + const audioWindowCalls = panel.webview.postMessage.mock.calls; + const posted = audioWindowCalls[audioWindowCalls.length - 1][0] as { + command: string; + requestId: number; + payload: { samples: unknown[]; quality: string; rangeStart: number; rangeEnd: number }; + }; + expect(posted).toMatchObject({ + command: 'mediaAudioWindowData', + requestId: 4, + payload: { + quality: 'low', + }, + }); + expect(posted.payload.rangeStart).toBeCloseTo(0.01); + expect(posted.payload.rangeEnd).toBeCloseTo(1.991); + expect(posted.payload.samples.length).toBeLessThan(199); + }); + + it('shows sensor-data and loading errors for unsupported media flows', async () => { + const sdsPath = path.join(tmpDir, 'sensor.0.sds'); + const metaPath = metadataFile(tmpDir, 'sensor.sds.yml'); + sdsMockState.parseMetadataFile.mockReturnValue({ + sds: { + name: 'sensor', + frequency: 1, + content: [{ value: 'x', type: 'float' }], + }, + }); + sdsMockState.detectMediaType.mockReturnValue('sensor'); + + SdsMediaViewerPanel.createOrShow(vscode.Uri.file('/extension'), sdsPath, undefined, metaPath); + expect(initialState(latestPanel())).toMatchObject({ + error: 'This file contains sensor data. Use the standard SDS Viewer instead.', + }); + + sdsMockState.parseMetadataFile.mockImplementation(() => { + throw new Error('metadata broke'); + }); + SdsMediaViewerPanel.createOrShow(vscode.Uri.file('/extension'), path.join(tmpDir, 'broken.0.sds'), undefined, metaPath); + expect(initialState(latestPanel())).toMatchObject({ error: 'metadata broke' }); + }); + + it('reports media message errors from request handlers', async () => { + const sdsPath = path.join(tmpDir, 'post-error.0.sds'); + fs.writeFileSync(sdsPath, Buffer.alloc(8, 1)); + const metaPath = metadataFile(tmpDir, 'post-error.sds.yml'); + sdsMockState.parseMetadataFile.mockReturnValue({ + sds: { + name: 'post-error', + frequency: 1, + content: [{ value: 'frame', type: 'uint8_t', image: { pixel_format: 'RAW8', width: 1, height: 1 } }], + }, + }); + sdsMockState.detectMediaType.mockReturnValue('image'); + sdsMockState.indexSdsRecords.mockReturnValue([{ timestamp: 0, dataSize: 1, dataOffset: 0 }]); + sdsMockState.decodeImageFrameToRGBA.mockReturnValue(new Uint8Array([1, 2, 3, 4])); + + SdsMediaViewerPanel.createOrShow(vscode.Uri.file('/extension'), sdsPath, undefined, metaPath); + const panel = latestPanel(); + panel.webview.postMessage.mockImplementationOnce(() => { + throw new Error('media post failed'); + }); + + await dispatchMessage(panel, { + command: 'requestMediaFrameWindow', + payload: { mediaType: 'image', centerIndex: 0, windowSize: 1 }, + }); + + expect(vscodeMockState.showErrorMessageMock).toHaveBeenCalledWith('Media viewer error: media post failed'); + }); +}); diff --git a/test/unit/workspace-commands.test.ts b/test/unit/workspace-commands.test.ts new file mode 100644 index 0000000..5e78e44 --- /dev/null +++ b/test/unit/workspace-commands.test.ts @@ -0,0 +1,218 @@ +/** + * Copyright 2026 Arm Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Created using AI + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const commandMockState = vi.hoisted(() => { + const registeredDisposables: Array<{ + command: string; + callback: (...args: unknown[]) => unknown; + dispose: () => void; + }> = []; + + const registerCommandMock = vi.fn((command: string, callback: (...args: unknown[]) => unknown) => { + const disposable = { + command, + callback, + dispose: vi.fn(), + }; + registeredDisposables.push(disposable); + return disposable; + }); + + return { + registerCommandMock, + registeredDisposables, + }; +}); + +const diagnosticsMockState = vi.hoisted(() => ({ + diagnostics: { + clear: vi.fn(), + show: vi.fn(), + }, +})); + +vi.mock('vscode', () => { + class Uri { + constructor(public fsPath: string) { } + + static file(fsPath: string): Uri { + return new Uri(fsPath); + } + + static joinPath(base: Uri, ...segments: string[]): Uri { + return new Uri([base.fsPath, ...segments].join('/').replace(/\/+/g, '/')); + } + } + + return { + commands: { + executeCommand: vi.fn(), + registerCommand: commandMockState.registerCommandMock, + }, + Uri, + window: { + showErrorMessage: vi.fn(), + showInformationMessage: vi.fn(), + showInputBox: vi.fn(), + showOpenDialog: vi.fn(), + }, + workspace: { + fs: { + createDirectory: vi.fn(async () => undefined), + writeFile: vi.fn(async () => undefined), + }, + workspaceFolders: undefined, + }, + }; +}); + +vi.mock('../../src/diagnostics/sdsDiagnostics', () => ({ + diag: vi.fn(() => diagnosticsMockState.diagnostics), +})); + +import * as vscode from 'vscode'; +import { diag } from '../../src/diagnostics/sdsDiagnostics'; +import { registerWorkspaceCommands } from '../../src/commands/workspaceCommands'; + +function createContext() { + return { + subscriptions: [] as Array<{ dispose: () => void }>, + }; +} + +function registerCommands() { + const context = createContext(); + registerWorkspaceCommands({ context: context as never }); + return { context }; +} + +function getCommand(command: string): (...args: unknown[]) => unknown { + const registration = commandMockState.registeredDisposables.find((disposable) => disposable.command === command); + if (!registration) { + throw new Error(`Command was not registered: ${command}`); + } + return registration.callback; +} + +describe('registerWorkspaceCommands', () => { + let tmpDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + commandMockState.registeredDisposables.length = 0; + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workspace-commands-')); + (vscode.workspace as unknown as { workspaceFolders: Array<{ uri: { fsPath: string } }> | undefined }).workspaceFolders = undefined; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('pushes every command registration into the extension context subscriptions', () => { + const { context } = registerCommands(); + + expect(commandMockState.registerCommandMock.mock.calls.map(([command]) => command)).toEqual([ + 'arm-sds.initWorkspace', + 'arm-sds.showDiagnostics', + 'arm-sds.clearDiagnostics', + ]); + expect(context.subscriptions).toEqual(commandMockState.registeredDisposables); + }); + + it('opens an existing folder when initializing without a workspace', async () => { + registerCommands(); + const folderUri = vscode.Uri.file(tmpDir); + vi.mocked(vscode.window.showInformationMessage).mockResolvedValueOnce('Open Folder' as never); + vi.mocked(vscode.window.showOpenDialog).mockResolvedValueOnce([folderUri]); + + await getCommand('arm-sds.initWorkspace')(); + + expect(vscode.window.showOpenDialog).toHaveBeenCalledWith({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + openLabel: 'Open as SDS Workspace', + }); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('vscode.openFolder', folderUri); + }); + + it('creates a new folder workspace with starter files', async () => { + registerCommands(); + const parentUri = vscode.Uri.file(tmpDir); + vi.mocked(vscode.window.showInformationMessage).mockResolvedValueOnce('Create New Folder' as never); + vi.mocked(vscode.window.showOpenDialog).mockResolvedValueOnce([parentUri]); + vi.mocked(vscode.window.showInputBox).mockImplementationOnce(async (options: unknown) => { + const validateInput = (options as { validateInput: (value: string) => string | undefined }).validateInput; + expect(validateInput('')).toBe('Name cannot be empty'); + expect(validateInput('a/b')).toBe('Invalid characters in name'); + expect(validateInput('demo')).toBeUndefined(); + return ' demo-project '; + }); + + await getCommand('arm-sds.initWorkspace')(); + + const newFolder = vscode.Uri.joinPath(parentUri, 'demo-project'); + const recordingsFolder = vscode.Uri.joinPath(newFolder, 'sds_recordings'); + const readme = vscode.Uri.joinPath(newFolder, 'README.md'); + const gitignore = vscode.Uri.joinPath(newFolder, '.gitignore'); + + expect(vscode.workspace.fs.createDirectory).toHaveBeenNthCalledWith(1, newFolder); + expect(vscode.workspace.fs.createDirectory).toHaveBeenNthCalledWith(2, recordingsFolder); + expect(vscode.workspace.fs.writeFile).toHaveBeenNthCalledWith(1, readme, expect.any(Buffer)); + expect(Buffer.from(vi.mocked(vscode.workspace.fs.writeFile).mock.calls[0][1] as Uint8Array).toString('utf-8')).toContain('# demo-project'); + expect(vscode.workspace.fs.writeFile).toHaveBeenNthCalledWith(2, gitignore, expect.any(Buffer)); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('vscode.openFolder', newFolder); + }); + + it('creates the recordings directory when a workspace is already open', async () => { + registerCommands(); + (vscode.workspace as unknown as { workspaceFolders: Array<{ uri: { fsPath: string } }> }).workspaceFolders = [{ uri: { fsPath: tmpDir } }]; + + await getCommand('arm-sds.initWorkspace')(); + + expect(fs.existsSync(path.join(tmpDir, 'sds_recordings'))).toBe(true); + }); + + it('reports initialization errors', async () => { + registerCommands(); + const fileAsWorkspace = path.join(tmpDir, 'not-a-directory'); + fs.writeFileSync(fileAsWorkspace, '', 'utf-8'); + (vscode.workspace as unknown as { workspaceFolders: Array<{ uri: { fsPath: string } }> }).workspaceFolders = [{ uri: { fsPath: fileAsWorkspace } }]; + + await getCommand('arm-sds.initWorkspace')(); + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(expect.stringContaining('Workspace init failed:')); + }); + + it('shows and clears diagnostics', () => { + registerCommands(); + + getCommand('arm-sds.showDiagnostics')(); + getCommand('arm-sds.clearDiagnostics')(); + + expect(diag).toHaveBeenCalledTimes(2); + expect(diagnosticsMockState.diagnostics.show).toHaveBeenCalledTimes(1); + expect(diagnosticsMockState.diagnostics.clear).toHaveBeenCalledTimes(1); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith('CMSIS SDS diagnostics log cleared.'); + }); +}); diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json new file mode 100644 index 0000000..a9974fb --- /dev/null +++ b/tsconfig.eslint.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vscode", "vitest/globals"] + }, + "include": [ + "src/**/*", + "test/**/*.ts", + "vitest.config.ts", + "playwright.config.ts" + ], + "exclude": [ + "node_modules", + "out", + "dist", + "coverage" + ] +}