diff --git a/analysis_options.yaml b/analysis_options.yaml index 4cce8f7b5..7e3e12621 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,6 +9,7 @@ analyzer: exclude: - doc/tutorials/chapter_9/rohd_vf_example - packages/rohd_hierarchy + - packages/rohd_waveform - rohd_devtools_extension - rohd_extension diff --git a/packages/rohd_hierarchy/CHANGELOG.md b/packages/rohd_hierarchy/CHANGELOG.md new file mode 100644 index 000000000..2961546a1 --- /dev/null +++ b/packages/rohd_hierarchy/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +- Initial release of source-agnostic hierarchy models, addressing, adapters, and search utilities. diff --git a/packages/rohd_hierarchy/README.md b/packages/rohd_hierarchy/README.md index f99e9cc88..d426f8b1a 100644 --- a/packages/rohd_hierarchy/README.md +++ b/packages/rohd_hierarchy/README.md @@ -1,6 +1,12 @@ # rohd_hierarchy -An incremental design dictionary for hardware module hierarchies. +Source-agnostic hierarchy models, addressing, and search utilities for hardware +module navigation. + +`rohd_hierarchy` represents a hardware design as an incremental design +dictionary: a tree of module occurrences and signals, plus compact addresses +that let tools refer to any object without repeatedly sending full paths or +structural context. ## Motivation @@ -37,7 +43,7 @@ occurrence and every signal reachable by walking the hierarchy tree. ### Compact, canonical addressing `rohd_hierarchy` assigns each object a **canonical address** — a short -sequence of child indices (e.g. `0.2.4`) that uniquely identifies it within +sequence of child indices, such as `0.2.4`, that uniquely identifies it within the tree. Addresses are **relative within each occurrence**: an occurrence's address @@ -54,7 +60,7 @@ At each step, both sides agree on the addresses, so subsequent data requests (waveform samples, signal values, schematic fragments) carry only the compact address, not the full path or structural description. -## Package overview +## What It Provides `rohd_hierarchy` is a source-agnostic Dart package that implements this dictionary model. It provides data models, search utilities, and adapter @@ -76,7 +82,7 @@ transport layer. direction, and optional value. Signals with a `direction` serve as ports (input, output, inout). -### Services & adapters +### Services and adapters - **`HierarchyService`** — A mixin providing tree-walking search and navigation: `searchSignals()`, `searchOccurrences()`, @@ -115,8 +121,8 @@ transport layer. ```dart import 'package:rohd_hierarchy/rohd_hierarchy.dart'; -final dict = NetlistHierarchyAdapter.fromJson(netlistJsonString); -final root = dict.root; // the top-level dictionary table +final hierarchy = NetlistHierarchyAdapter.fromJson(netlistJsonString); +final root = hierarchy.root; // The top-level dictionary table. ``` ### Wrapping an existing tree @@ -126,7 +132,7 @@ parser, a ROHD simulation, or any other source), wrap it to gain search and address resolution: ```dart -final dict = BaseHierarchyAdapter.fromTree(rootNode); +final hierarchy = BaseHierarchyAdapter.fromTree(rootNode); ``` ### Incremental expansion by a remote agent @@ -136,7 +142,7 @@ dictionary one level at a time: ```dart // Agent receives the root table -final root = dict.root; +final root = hierarchy.root; // Agent picks a child to expand (e.g. child 2) final child = root.children[2]; @@ -151,14 +157,15 @@ Once both sides share the dictionary, data requests use addresses only: ```dart // Resolve a human-readable pathname to a canonical address -final addr = dict.pathnameToAddress('Counter/clk'); +final address = hierarchy.pathnameToAddress('Counter/clk'); -// Send the compact address over the wire: "0.1" -final wire = addr!.toDotString(); +// Send the compact address over the wire, for example: "0". +final wireAddress = address!.toDotString(); -// The other side resolves it back -final resolved = dict.occurrenceByAddress(OccurrenceAddress.fromDotString(wire)); -final pathname = dict.addressToPathname(addr!); +// The other side resolves it back as a signal address. +final resolvedSignal = + hierarchy.signalByAddress(OccurrenceAddress.fromDotString(wireAddress)); +final pathname = hierarchy.addressToPathname(address, asSignal: true); ``` ### Searching the dictionary @@ -169,13 +176,13 @@ Segments are split on `/` or `.` and matched as case-sensitive substrings: ```dart // Find all signals whose path contains 'cpu' then 'clk' -final signals = dict.searchSignals('cpu/clk'); +final signals = hierarchy.searchSignals('cpu/clk'); // Find occurrences containing 'counter' -final modules = dict.searchOccurrences('counter'); +final modules = hierarchy.searchOccurrences('counter'); // Tab-completion for partial paths -final completions = dict.autocompletePaths('Top/CPU/'); +final completions = hierarchy.autocompletePaths('Top/CPU/'); ``` #### Regex / glob search @@ -185,22 +192,22 @@ and `?` are auto-converted. Use `**` to match across hierarchy levels: ```dart // All 'clk' signals anywhere in the design -final clocks = dict.searchSignalsRegex('Top/**/clk'); +final clocks = hierarchy.searchSignalsRegex('Top/**/clk'); // Signals named d0–d15 in any regfile -final data = dict.searchSignalsRegex('Top/**/regfile/d[0-9]+'); +final data = hierarchy.searchSignalsRegex('Top/**/regfile/d[0-9]+'); // Either 'clk' or 'reset' anywhere -final resets = dict.searchSignalsRegex('Top/**/(clk|reset)'); +final resets = hierarchy.searchSignalsRegex('Top/**/(clk|reset)'); // All cache channels ch0–ch2 -final channels = dict.searchOccurrencesRegex('Top/mem_ctrl/ch[0-2]'); +final channels = hierarchy.searchOccurrencesRegex('Top/mem_ctrl/ch[0-2]'); // Signals containing 'mux' in their name -final muxed = dict.searchSignalsRegex('Top/**/.*mux.*'); +final muxed = hierarchy.searchSignalsRegex('Top/**/.*mux.*'); // All signals in a specific module -final all = dict.searchSignalsRegex('Top/CPU/ALU/*'); +final all = hierarchy.searchSignalsRegex('Top/CPU/ALU/*'); ``` ### Constructing occurrences manually @@ -244,3 +251,7 @@ print(root.signals.first.path()); // 'Counter/clk' | **Canonical** | `buildAddresses()` assigns deterministic indices in tree order. The same design always produces the same addresses. | | **No global namespace** | Each occurrence's address table is self-contained. Adding or removing a sibling subtree does not invalidate addresses in unrelated parts of the tree. | | **Transport-independent** | The package defines the dictionary model, not the wire protocol. Any transport (VM service, JSON-RPC, gRPC, WebSocket) can carry the compact addresses. | + +---------------- +Copyright (C) 2026 Intel Corporation +SPDX-License-Identifier: BSD-3-Clause diff --git a/packages/rohd_waveform/.gitignore b/packages/rohd_waveform/.gitignore new file mode 100644 index 000000000..3cceda557 --- /dev/null +++ b/packages/rohd_waveform/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/rohd_waveform/CHANGELOG.md b/packages/rohd_waveform/CHANGELOG.md new file mode 100644 index 000000000..af2a17b8f --- /dev/null +++ b/packages/rohd_waveform/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +- Initial release of waveform data models, service APIs, repositories, and signal data helpers. diff --git a/packages/rohd_waveform/LICENSE b/packages/rohd_waveform/LICENSE new file mode 100644 index 000000000..9e00cc822 --- /dev/null +++ b/packages/rohd_waveform/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (C) 2021-2026 Intel Corporation + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/rohd_waveform/README.md b/packages/rohd_waveform/README.md new file mode 100644 index 000000000..fbdf88556 --- /dev/null +++ b/packages/rohd_waveform/README.md @@ -0,0 +1,58 @@ +# rohd_waveform + +Pure-Dart waveform data services and models for ROHD wave viewers. + +This package supplies waveform value data to remote clients, viewers, and +debugging tools while using [`rohd_hierarchy`](../rohd_hierarchy) for module +and signal structure. It defines the boundary between a waveform source, such +as a simulator, debugger, VCD/Wellen reader, or DevTools connection, and a UI +that needs signal values over time. + +## Service + +`rohd_waveform` provides a small service layer for requesting, caching, and +streaming waveform values: + +- `SignalWaveformApi` defines the source-facing API for loading waveform data, + streaming incremental updates, reading the current simulation time, and + retrieving value snapshots. +- `SignalWaveformRepository` wraps a `SignalWaveformApi`, waits for asynchronous + sources to become ready, caches signal metadata and waveform values, and can + synthesize computed sub-field waveforms from parent signal data. +- `SignalDataService` exposes a signal-centric interface for clients that work + with `SignalOccurrence` objects from `rohd_hierarchy`. +- `RepositorySignalDataService` adapts the repository cache into `WaveData` + objects for wave viewers and other clients. + +The package does not include a waveform backend. Applications provide a +concrete `SignalWaveformApi` implementation for their data source, while this +package handles the shared service contract, caching, and transfer models. + +## Models + +The package also includes waveform-specific models used across the service +boundary: + +- `ModuleStructure` — top-level waveform structure metadata and hierarchy roots. +- `WaveformData` — transfer payload returned by a `SignalWaveformApi` fetch or + stream. It carries a signal ID, data points, and whether the values were + computed, but it does not provide hierarchy metadata or lookup behavior. +- `SignalWaveform` — repository/client-side waveform state for one signal. It + can be built from `WaveformData`, appended to over time, queried by time or + range, and linked back to `SignalOccurrence` metadata through a lookup + function. +- `WaveformUpdateEvent` — event payload for waveform update notifications. +- `WaveData` — combined `SignalOccurrence` metadata and waveform data returned + by `SignalDataService`. +- `Data`, `WaveFormat`, and `MetaData` — waveform data primitives. + +In short, use `WaveformData` at the service/API boundary and `SignalWaveform` +inside clients or repositories that need cached state, incremental appends, +metadata access, or waveform queries. + +For hierarchy types such as `HierarchyOccurrence` and `SignalOccurrence`, +import `package:rohd_hierarchy/rohd_hierarchy.dart` directly. + +---------------- +Copyright (C) 2026 Intel Corporation +SPDX-License-Identifier: BSD-3-Clause diff --git a/packages/rohd_waveform/analysis_options.yaml b/packages/rohd_waveform/analysis_options.yaml new file mode 100644 index 000000000..f04c6cf0f --- /dev/null +++ b/packages/rohd_waveform/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/rohd_waveform/lib/rohd_waveform.dart b/packages/rohd_waveform/lib/rohd_waveform.dart new file mode 100644 index 000000000..e20d7ce3b --- /dev/null +++ b/packages/rohd_waveform/lib/rohd_waveform.dart @@ -0,0 +1,23 @@ +// Copyright (C) 2024-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// rohd_waveform.dart +// Waveform data models and APIs for wave viewers. +// +// 2026 July 15 +// Author: Desmond Kirkpatrick + +/// Waveform data models and APIs for wave viewers. +/// +/// This library provides waveform-specific data models: +/// - `ModuleStructure` - top-level waveform structure +/// - `SignalWaveform` - waveform data with backpointer to signal metadata +/// - `Data`, `WaveFormat`, and `MetaData` - waveform data primitives +/// +/// For hierarchy types such as `HierarchyOccurrence` and `SignalOccurrence`, +/// import 'package:rohd_hierarchy/rohd_hierarchy.dart' directly. +library; + +export 'src/models/models.dart'; +export 'src/waveform_api.dart'; +export 'src/waveform_repository.dart'; diff --git a/packages/rohd_waveform/lib/src/models/data.dart b/packages/rohd_waveform/lib/src/models/data.dart new file mode 100644 index 000000000..269a07e30 --- /dev/null +++ b/packages/rohd_waveform/lib/src/models/data.dart @@ -0,0 +1,34 @@ +// Copyright (C) 2024-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// data.dart +// An entity that describes the data of a signal. +// +// 2024 January 29 +// Author: Desmond Kirkpatrick + +/// A class that represents the data of a signal. +/// +/// It contains a time and a value. +class Data { + /// The time of the data. + int time; + + /// The value of the data. + String value; + + /// Creates a new instance of [Data]. + /// + /// Requires [time] and [value] as parameters. + Data({required this.time, required this.value}); + + /// Converts the [Data] instance into a JSON Map. + Map toJson() => {'time': time, 'value': value}; + + /// Creates a new instance of [Data] from a JSON Map. + factory Data.fromJson(Map json) => + Data(time: json['time'] as int, value: json['value'] as String); + + /// Creates an empty data point at time zero with value `0`. + factory Data.empty() => Data(time: 0, value: '0'); +} diff --git a/packages/rohd_waveform/lib/src/models/metadata.dart b/packages/rohd_waveform/lib/src/models/metadata.dart new file mode 100644 index 000000000..ce64362a9 --- /dev/null +++ b/packages/rohd_waveform/lib/src/models/metadata.dart @@ -0,0 +1,107 @@ +// Copyright (C) 2024-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// metadata.dart +// An entity that describes the metadata of a module structure. +// +// 2024 January 29 +// Author: Desmond Kirkpatrick + +import 'package:equatable/equatable.dart'; +import 'package:rohd_waveform/src/models/wave_format.dart'; + +/// A class that represents the metadata of a module structure. +/// +/// It contains source, timescale, date, and time range information. +class MetaData extends Equatable { + /// The source of the metadata. + final String source; + + /// The timescale of the metadata (e.g., "1ns", "100ps"). + final String timescale; + + /// The timescale factor (e.g., 1, 10, 100). + /// + /// This is optional and populated when loading from waveform files. + final int? timescaleFactor; + + /// The date of the metadata. + final String date; + + /// The version string (if available). + /// + /// This is optional and populated when loading from waveform files. + final String? version; + + /// The file format. + /// + /// This is optional and populated when loading from waveform files. + final WaveFormat? format; + + /// The start time of the waveform in timescale units. + final int startTime; + + /// The end time of the waveform in timescale units. + final int endTime; + + /// Creates a new instance of [MetaData]. + /// + /// Requires [source], [timescale], and [date] as parameters. + /// [startTime] and [endTime] default to 0 if not provided. + /// [timescaleFactor], [version], and [format] are optional. + const MetaData({ + required this.source, + required this.timescale, + required this.date, + this.startTime = 0, + this.endTime = 0, + this.timescaleFactor, + this.version, + this.format, + }); + + /// Converts the [MetaData] instance into a JSON Map. + Map toJson() => { + 'source': source, + 'timescale': timescale, + 'date': date, + 'startTime': startTime, + 'endTime': endTime, + if (timescaleFactor != null) 'timescaleFactor': timescaleFactor, + if (version != null) 'version': version, + if (format != null) 'format': format!.name, + }; + + /// Creates a new instance of [MetaData] from a JSON Map. + factory MetaData.fromJson(Map json) => MetaData( + source: json['source'] as String, + timescale: json['timescale'] as String, + date: json['date'] as String, + startTime: (json['startTime'] ?? 0) as int, + endTime: (json['endTime'] ?? 0) as int, + timescaleFactor: json['timescaleFactor'] as int?, + version: json['version'] as String?, + format: json['format'] != null + ? WaveFormat.fromString(json['format'] as String) + : null, + ); + + /// Creates an empty metadata object. + factory MetaData.empty() => const MetaData( + source: '', + timescale: '', + date: '', + ); + + @override + List get props => [ + source, + timescale, + timescaleFactor, + date, + version, + format, + startTime, + endTime, + ]; +} diff --git a/packages/rohd_waveform/lib/src/models/models.dart b/packages/rohd_waveform/lib/src/models/models.dart new file mode 100644 index 000000000..841dcc13d --- /dev/null +++ b/packages/rohd_waveform/lib/src/models/models.dart @@ -0,0 +1,21 @@ +// Copyright (C) 2024-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// models.dart +// Barrel file for waveform-specific model exports. +// +// 2026 July 15 +// Author: Desmond Kirkpatrick + +// Note: For hierarchy types (HierarchyOccurrence, SignalOccurrence, Port), +// import 'package:rohd_hierarchy/rohd_hierarchy.dart' directly. + +// Waveform-specific models +export 'data.dart'; +export 'metadata.dart'; +export 'module_structure.dart'; +export 'signal_data_service.dart'; +export 'signal_waveform.dart'; +export 'wave_format.dart'; +export 'waveform_data.dart'; +export 'waveform_update_event.dart'; diff --git a/packages/rohd_waveform/lib/src/models/module_structure.dart b/packages/rohd_waveform/lib/src/models/module_structure.dart new file mode 100644 index 000000000..12de158fe --- /dev/null +++ b/packages/rohd_waveform/lib/src/models/module_structure.dart @@ -0,0 +1,114 @@ +// Copyright (C) 2024-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// module_structure.dart +// An entity that describe the module structure of signals simulation. +// +// 2024 January 29 +// Author: Desmond Kirkpatrick + +import 'package:equatable/equatable.dart'; +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:rohd_waveform/rohd_waveform.dart'; + +/// A class that represents the structure of a module hierarchy. +/// +/// It contains metadata and a list of root modules +/// (HierarchyOccurrence objects). +/// This unified representation works with all data sources (waveform files, +/// ROHD inspector JSON, Yosys, etc.) through the adapter pattern. +class ModuleStructure extends Equatable { + /// The metadata of the module structure. + final MetaData metadata; + + /// The root modules in the hierarchy tree. + /// Each HierarchyOccurrence contains its children and ports with waveform + /// data. + final List modules; + + /// Optional pre-built [HierarchyService] for this structure. + /// + /// When set (e.g. from an external hierarchy source like DevTools), this + /// service is used directly for search and navigation instead of + /// re-wrapping the raw [modules] nodes. This preserves the original + /// adapter's internal data (flat maps, connectivity, etc.) that may not + /// be present in the [HierarchyOccurrence] objects themselves. + final HierarchyService? hierarchyService; + + /// Creates a new instance of [ModuleStructure]. + /// + /// Requires [metadata] and [modules] as parameters. + const ModuleStructure({ + required this.metadata, + required this.modules, + this.hierarchyService, + }); + + /// Get all signal IDs in the structure (flattened from all signals). + /// + /// Uses [SignalOccurrence.path()] to produce unique identifiers. + /// Includes both ports and internal signals so that the waveform viewer + /// can select any signal in the hierarchy, not just ports. + List get allSignalIds { + final ids = []; + void traverse(HierarchyOccurrence node) { + for (final signal in node.signals) { + ids.add(signal.path()); + } + node.children.forEach(traverse); + } + + modules.forEach(traverse); + return ids; + } + + /// Creates an empty module structure. + factory ModuleStructure.empty() => + ModuleStructure(metadata: MetaData.empty(), modules: const []); + + /// Finds the first module that has signals (directly or in descendants). + /// + /// This is useful for GHW files where standard libraries may be listed + /// as top-level modules but contain no actual signals. This method helps + /// skip empty modules and find the actual design hierarchy. + /// + /// Returns null if no module with signals is found. + HierarchyOccurrence? get firstModuleWithSignals { + if (modules.isEmpty) { + return null; + } + for (final module in modules) { + if (module.signals.isNotEmpty) { + return module; + } + } + return null; + } + + /// Creates a copy of this ModuleStructure with only the first real module + /// with signals. + /// + /// This wraps the found module as the single root in the module list, + /// effectively skipping empty standard library modules. + /// + /// Returns the original structure if no module with signals is found. + ModuleStructure withFirstRealModule() { + final realModule = firstModuleWithSignals; + if (realModule == null) { + return this; + } + // If the first module is already the real one, return as-is + if (modules.first == realModule) { + return this; + } + // Otherwise, wrap the real module as the single root + return ModuleStructure( + metadata: metadata, + modules: [realModule], + hierarchyService: hierarchyService, + ); + } + + @override + List get props => [metadata, modules, hierarchyService]; +} diff --git a/packages/rohd_waveform/lib/src/models/signal_data_service.dart b/packages/rohd_waveform/lib/src/models/signal_data_service.dart new file mode 100644 index 000000000..898d52b2b --- /dev/null +++ b/packages/rohd_waveform/lib/src/models/signal_data_service.dart @@ -0,0 +1,102 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_data_service.dart +// Abstraction layer for fetching signal waveform data. +// +// This service decouples the wave display layer from the data source +// (repository, debugger, simulator, etc.) while respecting shared models. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:rohd_waveform/rohd_waveform.dart'; + +/// Abstraction for fetching signal waveform data by signal occurrence. +/// +/// Signals come from the shared module hierarchy and are never modified. +/// This service fetches waveform data for a given signal without knowing the +/// data source (VCD file, debugger, simulator, etc.). +/// +/// Usage: +/// ```dart +/// final service = RepositorySignalDataService(repository); +/// final port = hierarchy.modules[0].signals[0]; +/// final waveData = await service.getSignalData(port); +/// print('SignalOccurrence: ${waveData.signalName}, Points: +/// ${waveData.data.length}'); +/// ``` +abstract class SignalDataService { + /// Fetch waveform data for a signal occurrence. + /// + /// [port] is the signal definition from the loaded module structure. + /// Returns `WaveData` combining that shared model with its waveform data. + /// + /// This method can be implemented differently in each app: + /// - Wave Viewer: Fetch from cached VCD waveform data + /// - Debugger: Fetch from live debugger state + /// - Simulator: Fetch from running simulation + Future getSignalData(SignalOccurrence port); + + /// Get all signals for a module. + /// + /// [module] is a HierarchyOccurrence from ModuleStructure + /// Returns the module's signals. + /// This method allows for caching/optimization per app. + /// + /// Default implementation returns module.signals directly. + List getSignalsForModule(HierarchyOccurrence module) => + module.signals; + + /// Get all ports (signals with direction) for a module. + /// + /// [module] is a HierarchyOccurrence from ModuleStructure + /// Returns only ports from the module's signals. + /// + /// Default implementation filters signals by isPort. + List getPortsForModule(HierarchyOccurrence module) => + module.signals.where((s) => s.isPort).toList(); +} + +/// Combined data model: signal definition + waveform data. +/// +/// This is app-specific (not shared across apps). Each app wraps +/// the shared signal occurrence with its own `WaveData` representation. +/// +/// The signal occurrence is immutable, while data points are fetched +/// from the app's data source. +class WaveData { + /// The signal definition from the shared module structure. + /// This SignalOccurrence is never modified by the service. + final SignalOccurrence port; + + /// The waveform data points [time, value] pairs. + /// Data type and structure depends on the source (VCD, debugger, etc.). + final List data; + + /// Optional metadata specific to this waveform. + /// Can include timing info, source hints, flags, etc. + final Map? metadata; + + /// Creates a combined signal definition and waveform payload. + WaveData({required this.port, required this.data, this.metadata}); + + /// Get signal name from the shared signal model. + String get signalName => port.name; + + /// Get signal direction from the shared signal model. + String get signalDirection => port.direction ?? 'inout'; + + /// Get signal width from the shared signal model. + int get signalWidth => port.width; + + /// Get signal type for VCD rendering. Defaults to 'wire'. + String get signalType => 'wire'; + + /// Check if waveform has any data points. + bool get hasData => data.isNotEmpty; + + /// Get number of data points. + int get dataPointCount => data.length; +} diff --git a/packages/rohd_waveform/lib/src/models/signal_waveform.dart b/packages/rohd_waveform/lib/src/models/signal_waveform.dart new file mode 100644 index 000000000..c73aec8a5 --- /dev/null +++ b/packages/rohd_waveform/lib/src/models/signal_waveform.dart @@ -0,0 +1,367 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_waveform.dart +// Waveform data for a signal, indexed by signal ID. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'package:collection/collection.dart'; +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; + +import 'package:rohd_waveform/src/models/data.dart'; +import 'package:rohd_waveform/src/models/waveform_data.dart'; + +/// Function type for looking up signal metadata by ID. +typedef SignalLookup = SignalOccurrence? Function(String signalId); + +/// Waveform data for a signal. +/// +/// This class holds the time-series waveform data for a signal and provides +/// efficient lookup methods. The signal's structural metadata (name, type, +/// width, etc.) is stored in `rohd_hierarchy.SignalOccurrence` and can be +/// accessed via the `signal` property if a lookup function is registered. +/// +/// Pattern: Similar to SchematicPortData in vscode-schematic-viewer, this +/// class maintains waveform data with a backpointer (`signalId`) to the +/// corresponding metadata in the hierarchy. +/// +/// Usage: +/// ```dart +/// // Register the signal lookup function (typically done once by repository) +/// SignalWaveform.signalLookup = (id) => repository.getSignalById(id); +/// +/// // Now all SignalWaveform instances can access their metadata +/// final waveform = SignalWaveform(signalId: 'clk'); +/// print(waveform.name); // Uses lookup to get SignalOccurrence.name +/// ``` +class SignalWaveform { + /// The ID of the signal this waveform data belongs to. + /// Use this to look up SignalOccurrence metadata from the hierarchy. + final String signalId; + + /// The waveform data points (time, value pairs). + final List data; + + /// Static signal lookup function for resolving signal metadata. + /// Set this via [signalLookup] before accessing metadata properties. + static SignalLookup? signalLookup; + + /// Clears the signal lookup function. + static void clearSignalLookup() { + signalLookup = null; + } + + /// Whether this waveform was computed/synthesized (e.g. gate evaluation) + /// rather than directly fetched from the VM service. + bool _isComputed; + + /// Whether this waveform was computed/synthesized (e.g. gate evaluation) + /// rather than directly fetched from the VM service. + bool get isComputed => _isComputed; + + /// Creates a new SignalWaveform. + SignalWaveform({ + required this.signalId, + List? data, + bool isComputed = false, + }) : _isComputed = isComputed, + data = data ?? []; + + /// Creates an empty SignalWaveform for a signal. + factory SignalWaveform.empty(String signalId) => + SignalWaveform(signalId: signalId); + + /// Creates a SignalWaveform from WaveformData. + factory SignalWaveform.fromWaveformData(WaveformData waveformData) => + SignalWaveform( + signalId: waveformData.signalId, + data: List.from(waveformData.data), + isComputed: waveformData.isComputed, + ); + + /// Creates a copy of an existing SignalWaveform. + /// + /// This is needed when the same signal is added to the monitor list multiple + /// times (e.g., for performance studies or side-by-side comparison). Each + /// monitor entry must have its own SignalWaveform instance to avoid sharing + /// state between rows in the waveform panel. + factory SignalWaveform.copyFrom(SignalWaveform other) => SignalWaveform( + signalId: other.signalId, + data: List.from(other.data), + isComputed: other.isComputed, + ); + + // ───────────────────────────────────────────────────────────────────────── + // Metadata accessors (via backpointer lookup) + // ───────────────────────────────────────────────────────────────────────── + + /// The corresponding SignalOccurrence metadata, if available. + /// Returns null if no lookup function is registered or signal not found. + SignalOccurrence? get signal => signalLookup?.call(signalId); + + /// Alias for signalId for convenience. + String get id => signalId; + + /// Whether this waveform represents a computed sub-field of another signal. + bool get isSubField => signalId.contains('#'); + + /// The canonical hierarchical path for waveform lookup. Delegates to + /// [SignalOccurrence.path] when available, falls back to [signalId]. + String get hierarchyPath => signal?.path() ?? signalId; + + /// The signal name from metadata or reproducible sub-field metadata. + String get name => signal?.name ?? _derivedSubFieldName ?? signalId; + + /// The signal type for VCD rendering. Always 'wire' in post-synthesis. + String get type => 'wire'; + + /// The signal width in bits from metadata or reproducible sub-field data. + int get width => signal?.width ?? _derivedSubFieldWidth ?? 1; + + /// The signal direction (from metadata). Returns null for internal signals. + String? get direction => signal?.direction; + + /// The full hierarchical path (from metadata). + String? get fullPath => signal?.path(); + + /// Whether this signal is a port (has direction). + bool get isPort => signal?.isPort ?? false; + + String? get _parentSignalId { + final separatorIndex = signalId.indexOf('#'); + if (separatorIndex < 0) { + return null; + } + return signalId.substring(0, separatorIndex); + } + + String? get _subFieldLabel { + final separatorIndex = signalId.indexOf('#'); + if (separatorIndex < 0 || separatorIndex == signalId.length - 1) { + return null; + } + return signalId.substring(separatorIndex + 1); + } + + ({int high, int low})? get _bitSlice { + final label = _subFieldLabel; + if (label == null) { + return null; + } + final match = RegExp(r'^b\[(\d+)(?::(\d+))?\]$').firstMatch(label); + if (match == null) { + return null; + } + final high = int.parse(match.group(1)!); + return (high: high, low: int.parse(match.group(2) ?? '$high')); + } + + String? get _derivedSubFieldName { + final parentId = _parentSignalId; + final label = _subFieldLabel; + if (parentId == null || label == null) { + return null; + } + + final parentName = signalLookup?.call(parentId)?.name ?? + parentId.substring(parentId.lastIndexOf('/') + 1); + final bitSlice = _bitSlice; + if (bitSlice != null) { + final range = bitSlice.high == bitSlice.low + ? '${bitSlice.high}' + : '${bitSlice.high}:${bitSlice.low}'; + return '$parentName[$range]'; + } + + if (label.startsWith('[')) { + return '$parentName${label.replaceAll('.', '')}'; + } + return '${parentName}_${label.replaceAll('.', '_')}'; + } + + int? get _derivedSubFieldWidth { + final bitSlice = _bitSlice; + if (bitSlice != null) { + return (bitSlice.high - bitSlice.low).abs() + 1; + } + + final parentId = _parentSignalId; + final label = _subFieldLabel; + final logicType = + parentId == null ? null : signalLookup?.call(parentId)?.logicType; + if (label == null || logicType == null) { + return null; + } + + Map? currentType = Map.from(logicType); + for (final segment in label.split('.')) { + if (currentType == null) { + return null; + } + final descriptors = SignalOccurrence.subFieldDescriptorsForType( + currentType, + '', + ); + final descriptor = descriptors.where((d) => d.fieldLabel == segment); + if (descriptor.isEmpty) { + return null; + } + final match = descriptor.first; + if (segment == label.split('.').last) { + return match.width; + } + currentType = match.subLogicType; + } + return null; + } + + // ───────────────────────────────────────────────────────────────────────── + // Waveform data properties + // ───────────────────────────────────────────────────────────────────────── + + /// Whether this waveform has any data points. + bool get isEmpty => data.isEmpty; + + /// Whether this waveform has data points. + bool get isNotEmpty => data.isNotEmpty; + + /// The number of data points in this waveform. + int get length => data.length; + + /// Appends waveform data points. + /// + /// If [sortByTime] is true, the data will be sorted by time after appending. + void appendData(List newData, {bool sortByTime = false}) { + data.addAll(newData); + if (sortByTime) { + data.sort((a, b) => a.time.compareTo(b.time)); + if (data.length > 1) { + // Keep the last value for duplicate timestamps to reflect most recent + // updates when overlapping windows are appended. + final deduped = []; + for (final point in data) { + if (deduped.isNotEmpty && deduped.last.time == point.time) { + deduped[deduped.length - 1] = point; + } else { + deduped.add(point); + } + } + data + ..clear() + ..addAll(deduped); + } + } + } + + /// Appends waveform data from a [WaveformData] object. + void appendWaveformData( + WaveformData waveformData, { + bool sortByTime = false, + }) { + appendData(waveformData.data, sortByTime: sortByTime); + if (waveformData.isComputed) { + _isComputed = true; + } + } + + /// Clears all waveform data. + void clearData() { + data.clear(); + } + + int _lowerBoundIndexForTime(int time) => data.lowerBound( + Data(time: time, value: ''), + (a, b) => a.time.compareTo(b.time), + ); + + /// Gets the value at a specific time using binary search. + /// + /// Returns the value of the last data point at or before the given time. + /// If no data point exists at or before the time, returns the first value. + String getValueByTime(int time) { + if (data.isEmpty) { + return ''; + } + + final idx = _lowerBoundIndexForTime(time + 1) - 1; + return data[idx < 0 ? 0 : idx].value; + } + + /// Finds the index of the data point at or after the given time. + /// Returns -1 if no data point is at or after the given time. + /// O(log n) complexity. + int getNextDataPointIndexAfter(int time) { + if (data.isEmpty) { + return -1; + } + + final idx = _lowerBoundIndexForTime(time); + return idx == data.length ? -1 : idx; + } + + /// Finds the index of the data point at or before the given time. + /// Returns -1 if no data point is at or before the given time. + /// O(log n) complexity. + int getPreviousDataPointIndexBefore(int time) { + if (data.isEmpty) { + return -1; + } + + final idx = _lowerBoundIndexForTime(time + 1) - 1; + return idx < 0 ? -1 : idx; + } + + /// Gets the next data point index from the current time. + /// If there is a data point at exactly the current time, returns the index + /// of the first data point at a strictly later time. + /// Handles duplicate timestamps correctly. + /// Returns -1 if there is no next data point. + int getNextDataPointIndex(int currentTime) { + var idx = getNextDataPointIndexAfter(currentTime); + if (idx == -1) { + return -1; + } + // Skip past all entries at currentTime to find a strictly later one. + while (idx < data.length && data[idx].time == currentTime) { + idx++; + } + return idx < data.length ? idx : -1; + } + + /// Gets the previous data point index from the current time. + /// If there is a data point at exactly the current time, returns the index + /// of the last data point at a strictly earlier time. + /// Handles duplicate timestamps correctly. + /// Returns -1 if there is no previous data point. + int getPreviousDataPointIndex(int currentTime) { + var idx = getPreviousDataPointIndexBefore(currentTime); + if (idx == -1) { + return -1; + } + // Skip back past all entries at currentTime to find a strictly earlier one. + while (idx >= 0 && data[idx].time == currentTime) { + idx--; + } + return idx >= 0 ? idx : -1; + } + + @override + String toString() => 'SignalWaveform($signalId, ${data.length} points)'; + + /// Converts to JSON. + Map toJson() => { + 'signalId': signalId, + 'data': data.map((e) => e.toJson()).toList(), + }; + + /// Creates from JSON. + factory SignalWaveform.fromJson(Map json) => SignalWaveform( + signalId: json['signalId'] as String, + data: (json['data'] as List?) + ?.map((e) => Data.fromJson(e as Map)) + .toList() ?? + [], + ); +} diff --git a/packages/rohd_waveform/lib/src/models/wave_format.dart b/packages/rohd_waveform/lib/src/models/wave_format.dart new file mode 100644 index 000000000..c241c6363 --- /dev/null +++ b/packages/rohd_waveform/lib/src/models/wave_format.dart @@ -0,0 +1,62 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// wave_format.dart +// Waveform file format enumeration. +// +// 2026 January 03 +// Author: Desmond Kirkpatrick + +/// Supported waveform file formats. +enum WaveFormat { + /// Value Change Dump format (IEEE 1364). + /// + /// VCD is the most widely supported format, but can produce very large + /// files for complex simulations. + vcd, + + /// Fast Signal Trace format (GTKWave). + /// + /// FST is a compressed binary format that is much more efficient than VCD. + /// It supports random access and is the recommended format for large + /// simulations. + fst, + + /// GHDL Waveform format. + /// + /// GHW is the native format for GHDL simulations. Reading is supported, + /// but writing is not. + ghw, + + /// Unknown or unsupported format. + unknown(extension: ''); + + const WaveFormat({String? extension}) : _extension = extension; + + final String? _extension; + + /// Returns the file extension for this format. + String get extension => _extension ?? '.$name'; + + /// Parses a format from a file path or extension. + static WaveFormat fromPath(String path) { + final lower = path.toLowerCase(); + for (final format in values) { + if (format.extension.isNotEmpty && lower.endsWith(format.extension)) { + return format; + } + } + return WaveFormat.unknown; + } + + /// Parses a format from a string name. + static WaveFormat fromString(String name) { + final lower = name.toLowerCase(); + for (final format in values) { + if (format.name == lower) { + return format; + } + } + return WaveFormat.unknown; + } +} diff --git a/packages/rohd_waveform/lib/src/models/waveform_data.dart b/packages/rohd_waveform/lib/src/models/waveform_data.dart new file mode 100644 index 000000000..15cffdf6a --- /dev/null +++ b/packages/rohd_waveform/lib/src/models/waveform_data.dart @@ -0,0 +1,71 @@ +// Copyright (C) 2024-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// waveform_data.dart +// An entity that represents waveform data for a signal. +// +// 2024 December 30 +// Author: Desmond Kirkpatrick + +import 'package:rohd_waveform/src/models/data.dart'; + +/// A class that represents waveform data for a specific signal. +/// +/// This class is used to transfer waveform data separately from the +/// signal structure, enabling incremental data loading and updates. +class WaveformData { + /// The unique identifier of the signal this waveform data belongs to. + final String signalId; + + /// The list of data points in the waveform. + final List data; + + /// Whether this waveform was computed/synthesized (e.g. gate evaluation) + /// rather than directly fetched from the VM service. + final bool isComputed; + + /// Creates a new instance of [WaveformData]. + /// + /// Requires [signalId] and [data] as parameters. + WaveformData({ + required this.signalId, + required this.data, + this.isComputed = false, + }); + + /// Converts the [WaveformData] instance into a JSON Map. + Map toJson() => { + 'signalId': signalId, + 'data': data.map((e) => e.toJson()).toList(), + }; + + /// Creates a new instance of [WaveformData] from a JSON Map. + factory WaveformData.fromJson(Map json) => WaveformData( + signalId: json['signalId'] as String, + data: (json['data'] as List) + .map((e) => Data.fromJson(e as Map)) + .toList(), + ); + + /// Creates an empty waveform payload for [signalId]. + factory WaveformData.empty(String signalId) => + WaveformData(signalId: signalId, data: []); + + /// Returns the number of data points in the waveform. + int get length => data.length; + + /// Returns true if the waveform has no data points. + bool get isEmpty => data.isEmpty; + + /// Returns true if the waveform has data points. + bool get isNotEmpty => data.isNotEmpty; + + /// Returns the time of the first data point, or null if empty. + int? get startTime => data.isEmpty ? null : data.first.time; + + /// Returns the time of the last data point, or null if empty. + int? get endTime => data.isEmpty ? null : data.last.time; + + @override + String toString() => 'WaveformData($signalId, ${data.length} points)'; +} diff --git a/packages/rohd_waveform/lib/src/models/waveform_update_event.dart b/packages/rohd_waveform/lib/src/models/waveform_update_event.dart new file mode 100644 index 000000000..81d4eacfd --- /dev/null +++ b/packages/rohd_waveform/lib/src/models/waveform_update_event.dart @@ -0,0 +1,60 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// waveform_update_event.dart +// Event model for incremental waveform updates from live simulations. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'package:rohd_waveform/src/models/waveform_data.dart'; + +/// Reasons for a waveform update event. +enum WaveformUpdateReason { + /// Update triggered by hitting a breakpoint. + breakpoint, + + /// Manual refresh requested by user. + manual, + + /// Periodic update during continuous simulation. + periodic, + + /// Final update when simulation completes. + simulationComplete, + + /// Initial data load. + initial, + + /// Waveform structure (signal dictionary) has become available. + /// + /// Emitted when a debug-pause probe discovers the ROHD WaveformService + /// for the first time. Listeners should re-fetch the module structure + /// so the signal tree appears in the UI. + structureAvailable, +} + +/// A waveform update event containing incremental data. +/// +/// Used to communicate incremental waveform data from live simulations +/// to the waveform viewer UI. +class WaveformUpdateEvent { + /// The new waveform data since the last update. + final List incrementalData; + + /// Reason for this update. + final WaveformUpdateReason reason; + + /// The simulation time up to which data is included. + final int upToTime; + + /// Creates a waveform update event. + WaveformUpdateEvent({ + required this.incrementalData, + required this.reason, + required this.upToTime, + }); + + /// Whether this update contains any new data. + bool get hasData => incrementalData.isNotEmpty; +} diff --git a/packages/rohd_waveform/lib/src/signal_data_service_impl.dart b/packages/rohd_waveform/lib/src/signal_data_service_impl.dart new file mode 100644 index 000000000..89dfe8260 --- /dev/null +++ b/packages/rohd_waveform/lib/src/signal_data_service_impl.dart @@ -0,0 +1,87 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_data_service_impl.dart +// Concrete implementation of SignalDataService using the repository. +// +// This implementation fetches signal waveform data from the cached +// SignalWaveform objects in the SignalWaveformRepository, wrapping them with +// Port (shared model) +// to create WaveData objects. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:rohd_waveform/rohd_waveform.dart'; + +/// Concrete implementation of SignalDataService using +/// SignalWaveformRepository. +/// +/// The repository caches SignalOccurrence metadata and SignalWaveform objects +/// (with waveform data). +/// This implementation: +/// 1. Takes a Port (shared model) as input +/// 2. Uses Port.id to look up the cached SignalWaveform +/// 3. Wraps SignalWaveform.data with the Port to create WaveData +/// +/// This decouples the wave display from the repository by providing a +/// clean service interface that takes shared models as input. +class RepositorySignalDataService implements SignalDataService { + /// Reference to the repository containing cached signal waveform data. + final SignalWaveformRepository _repository; + + /// Creates a new repository-backed signal data service. + /// + /// The repository should already have signal data loaded. + RepositorySignalDataService(this._repository); + + /// Fetch waveform data for a Port using the repository cache. + /// + /// This method: + /// 1. Takes a Port (shared model from ModuleStructure) + /// 2. Uses signal.path() to look up the cached SignalWaveform + /// 3. Wraps SignalWaveform.data with the Port in a WaveData object + /// + /// Returns WaveData with the Port and its cached waveform data. + /// If the waveform is not found in cache, returns WaveData with empty data. + @override + Future getSignalData(SignalOccurrence port) async { + // Look up the waveform in the repository's cache using the address. + final addr = port.address; + final waveform = addr != null ? _repository.getWaveform(addr) : null; + final signal = addr != null ? _repository.getSignal(addr) : null; + + if (waveform == null) { + // Waveform not cached - return empty WaveData + return WaveData( + port: port, // ◄─ Shared model, immutable + data: [], // Empty data + metadata: {'source': 'repository', 'cached': false}, + ); + } + + // Waveform found - wrap its data with the Port (shared model) + return WaveData( + port: port, // ◄─ Shared model, immutable + data: waveform.data, // Waveform data from cached SignalWaveform + metadata: { + 'source': 'repository', + 'cached': true, + 'path': signal?.path(), + }, + ); + } + + /// Get all signals for a module. + @override + List getSignalsForModule(HierarchyOccurrence module) => + module.signals; + + /// Get all ports for a module. + /// + /// This implementation returns the module's ports (signals with direction). + @override + List getPortsForModule(HierarchyOccurrence module) => + module.signals.where((s) => s.isPort).toList(); +} diff --git a/packages/rohd_waveform/lib/src/waveform_api.dart b/packages/rohd_waveform/lib/src/waveform_api.dart new file mode 100644 index 000000000..8daa6ffa7 --- /dev/null +++ b/packages/rohd_waveform/lib/src/waveform_api.dart @@ -0,0 +1,101 @@ +// Copyright (C) 2024-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// waveform_api.dart +// An abstract class that defines the API for waveform data retrieval. +// +// 2024 January 29 +// Author: Desmond Kirkpatrick + +import 'package:rohd_waveform/rohd_waveform.dart'; + +/// An abstract class that defines the API for waveform data retrieval. +/// +/// Module structure (hierarchy, signals) is provided separately through +/// the rohd_hierarchy path. This API handles only waveform *values*. +abstract class SignalWaveformApi { + /// Creates a new instance of [SignalWaveformApi]. + const SignalWaveformApi(); + + /// Whether the underlying waveform source is ready for data access. + /// + /// Implementations that have an asynchronous load phase override this. + /// The default assumes the API is ready immediately. + bool get isLoaded => true; + + /// Retrieves waveform data for specific signals. + /// + /// [signalIds] is a list of signal IDs for which to retrieve data. + /// [startTime] and [endTime] optionally specify a time range for the data. + /// + /// Returns a [Future] that completes with a list of [WaveformData] objects. + Future> getWaveformData({ + required List signalIds, + int? startTime, + int? endTime, + }) async { + // Base implementation: must be overridden by concrete implementations + // Port no longer contains data - implementations must fetch from + // their source. + throw UnimplementedError( + 'getWaveformData must be implemented by subclasses', + ); + } + + /// Streams waveform data incrementally for specific signals. + /// + /// [signalIds] is a list of signal IDs for which to stream data. + /// [startTime] optionally specifies the starting time for the data stream. + /// + /// Returns a [Stream] of [WaveformData] objects that can be used to + /// incrementally update the waveform display. + Stream streamWaveformData({ + required List signalIds, + int? startTime, + }) async* { + // Default implementation: get all data at once and yield + final waveformDataList = await getWaveformData( + signalIds: signalIds, + startTime: startTime, + ); + for (final waveformData in waveformDataList) { + yield waveformData; + } + } + + /// Retrieves the current time of the active ROHD application. + /// + /// This method polls the current simulation time, which is useful for + /// dynamically updating the waveform display end time as a simulation + /// progresses. + /// + /// Returns a [Future] that completes with the current time as an integer, + /// or null if the time cannot be determined. + Future getCurrentTime() async { + // Default implementation: must be overridden by concrete implementations + throw UnimplementedError( + 'getCurrentTime must be implemented by subclasses', + ); + } + + /// Retrieves a snapshot of all signal values at the given [time]. + /// + /// Returns a map of signal ID to a map containing: + /// - `value`: the signal value at that time (String) + /// - `name`: signal display name + /// - `width`: signal bit width + /// - `direction`: signal direction (if port) + /// + /// Returns null if the snapshot could not be retrieved. + Future>?> getSnapshot(int time) async { + throw UnimplementedError('getSnapshot must be implemented by subclasses'); + } + + /// Proactively expand all slim module definitions so the client-side + /// evaluator can compute internal signals immediately. + /// + /// Called when the user enables "internal signals" in the wave viewer. + /// Default implementation is a no-op; overridden by implementations + /// that support client-side synthesis. + Future expandAllSlimModules() async {} +} diff --git a/packages/rohd_waveform/lib/src/waveform_repository.dart b/packages/rohd_waveform/lib/src/waveform_repository.dart new file mode 100644 index 000000000..ba24cf12d --- /dev/null +++ b/packages/rohd_waveform/lib/src/waveform_repository.dart @@ -0,0 +1,722 @@ +// Copyright (C) 2024-2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// waveform_repository.dart +// Domain layer that manages the retrieval of signal waveforms. +// +// 2024 January 29 +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:rohd_waveform/rohd_waveform.dart'; + +export 'signal_data_service_impl.dart'; + +/// A class that manages the retrieval of signal waveforms. +/// +/// It uses an instance of [SignalWaveformApi] to retrieve the data. +/// Maintains a cache of [SignalOccurrence] metadata and a separate cache of +/// [SignalWaveform] objects for waveform data. +class SignalWaveformRepository { + /// Currently selected module, if any. + HierarchyOccurrence? selectedModule; + + /// The [SignalWaveformApi] instance used to retrieve the data. + SignalWaveformApi _signalWaveformApi; + + /// Hierarchy service for tree-walk pathname ↔ address conversions. + /// Set via [hierarchyService] after loading a hierarchy. + HierarchyService? hierarchyService; + + /// Optional future that completes when the underlying API is ready. + /// + /// On web, the Wellen WASM may need to finish loading the waveform bytes + /// before calls to the underlying API succeed. Passing a readiness future + /// allows repository methods to wait for that event. Tests and native + /// usage may leave this null. + Future? _apiReady; + + /// A cache of signal metadata keyed by [OccurrenceAddress]. + final Map _signalCache = {}; + + /// A cache of signal waveform data keyed by [OccurrenceAddress]. + final Map _waveformCache = {}; + + /// Fallback cache for computed sub-field waveforms (e.g. bit-slices of + /// arrays/structs) that don't have a real [OccurrenceAddress] in the + /// hierarchy tree. Keyed by the raw signal ID string. + final Map _subFieldWaveformCache = {}; + + /// Expose the API for file loading operations + SignalWaveformApi get api => _signalWaveformApi; + + /// Creates a new instance of [SignalWaveformRepository]. + /// + /// Requires [signalWaveformApi] as a parameter. + SignalWaveformRepository({ + required SignalWaveformApi signalWaveformApi, + Future? apiReady, + }) : _signalWaveformApi = signalWaveformApi, + _apiReady = apiReady { + // Register the signal lookup function so SignalWaveform can resolve + // metadata. + // Uses tree-walk to convert signalId strings to addresses for cache lookup. + SignalWaveform.signalLookup = _lookupSignalByPath; + } + + /// Look up a [SignalOccurrence] by its pathname string. + /// Used as the [SignalWaveform] backpointer lookup function. + SignalOccurrence? _lookupSignalByPath(String signalId) { + final addr = hierarchyService?.pathnameToAddress(signalId); + return addr != null ? _signalCache[addr] : null; + } + + /// Replace the underlying API at runtime (e.g., switch from mock to Wellen + /// when the user picks a file). Clears cached signals and waveform data. + void setSignalWaveformApi( + SignalWaveformApi signalWaveformApi, { + Future? apiReady, + }) { + _signalWaveformApi = signalWaveformApi; + _apiReady = apiReady; + clearSignalCache(); + clearAllWaveformData(); + } + + Future? _waitForApiLoaded({ + Duration timeout = const Duration(seconds: 10), + }) { + final api = _signalWaveformApi; + if (api.isLoaded) { + return Future.value(); + } + + final completer = Completer(); + final deadline = DateTime.now().add(timeout); + + void poll() { + if (api.isLoaded) { + completer.complete(); + return; + } + + if (DateTime.now().isAfter(deadline)) { + completer.complete(); + return; + } + + Future.delayed(const Duration(milliseconds: 50), poll); + } + + poll(); + return completer.future; + } + + /// Internal helper to wait for API readiness if provided. + Future _ensureReady() async { + if (_apiReady != null) { + final loadedFuture = _waitForApiLoaded(); + if (loadedFuture != null) { + await Future.any([_apiReady!, loadedFuture]); + } else { + await _apiReady; + } + } + } + + /// Proactively expand all slim module definitions for client-side + /// evaluation. Delegates to [SignalWaveformApi.expandAllSlimModules]. + Future expandAllSlimModules() => + _signalWaveformApi.expandAllSlimModules(); + + /// Build the signal cache from the given module hierarchy. + /// + /// Ensures occurrence addresses are assigned via `buildAddresses()` and + /// auto-creates a hierarchy service if one hasn't been set explicitly. + /// Call this after receiving the hierarchy from the external + /// tree-data-source path, via `setExternalHierarchy` in the bloc. + void buildSignalCacheFromHierarchy(List modules) { + // Ensure every node and signal has an address. + for (final m in modules) { + if (m.address == null) { + m.buildAddresses(); + } + } + // Auto-create a HierarchyService if the caller hasn't set one. + if (hierarchyService == null && modules.isNotEmpty) { + hierarchyService = BaseHierarchyAdapter.fromTree(modules.first); + } + _buildSignalCache(modules); + } + + /// Get the current simulation time from the waveform API. + Future getCurrentTime() async { + await _ensureReady(); + return _signalWaveformApi.getCurrentTime(); + } + + /// Retrieves waveform data for specific signals. + /// + /// [signalIds] is a list of signal IDs for which to retrieve data. + /// [startTime] and [endTime] optionally specify a time range for the data. + /// + /// Sub-field IDs (containing `#`) are intercepted and synthesized via + /// bit-slicing the parent signal's waveform data. This enables sub-field + /// expansion to work with any API backend (including Wellen/VCD). + /// + /// Returns a [Future] that completes with a list of [WaveformData] objects. + Future> getWaveformData({ + required List signalIds, + int? startTime, + int? endTime, + }) => + () async { + await _ensureReady(); + + // Separate plain IDs from sub-field IDs. + final plainIds = []; + final subFieldIds = []; + for (final id in signalIds) { + if (id.contains(_subFieldSeparator)) { + subFieldIds.add(id); + } else { + plainIds.add(id); + } + } + + // Fetch plain signals from the API. + final results = []; + if (plainIds.isNotEmpty) { + results.addAll(await _signalWaveformApi.getWaveformData( + signalIds: plainIds, + startTime: startTime, + endTime: endTime, + )); + } + + // Synthesize sub-field bit-slices at the repository level. + if (subFieldIds.isNotEmpty) { + for (final sfId in subFieldIds) { + final synthesized = await _synthesizeBitSlice( + sfId, + startTime: startTime, + endTime: endTime, + ); + if (synthesized != null) { + results.add(synthesized); + } + } + } + + return results; + }(); + + /// Loads waveform data for specific signals and appends it to the cached + /// signals. + /// + /// [signalIds] is a list of signal IDs for which to load data. + /// [startTime] and [endTime] optionally specify a time range for the data. + /// [sortByTime] if true, sorts the data by time after appending. + /// + /// Returns a [Future] that completes with the list of [WaveformData] loaded. + Future> loadAndAppendWaveformData({ + required List signalIds, + int? startTime, + int? endTime, + bool sortByTime = true, + }) async { + final waveformDataList = await getWaveformData( + signalIds: signalIds, + startTime: startTime, + endTime: endTime, + ); + for (final waveformData in waveformDataList) { + // Resolve the waveform service signal ID to a OccurrenceAddress + // via O(depth) tree walk (no maps needed). + final addr = _resolveWaveformAddress(waveformData.signalId); + + // A null/null range means "full fetch". Replace cached waveform to + // avoid stale/duplicated points causing misplaced transitions. + final isFullFetch = startTime == null && endTime == null; + + if (addr == null) { + // Sub-field / computed waveform — no real address in the tree. + // Cache by raw string ID. + final id = waveformData.signalId; + if (isFullFetch || !_subFieldWaveformCache.containsKey(id)) { + _subFieldWaveformCache[id] = + SignalWaveform.fromWaveformData(waveformData); + } else { + _subFieldWaveformCache[id]! + .appendWaveformData(waveformData, sortByTime: sortByTime); + } + continue; + } + + if (isFullFetch || !_waveformCache.containsKey(addr)) { + _waveformCache[addr] = SignalWaveform.fromWaveformData(waveformData); + } else { + _waveformCache[addr]! + .appendWaveformData(waveformData, sortByTime: sortByTime); + } + } + return waveformDataList; + } + + /// Resolve a waveform service signal ID string to a [OccurrenceAddress] + /// using an O(depth) tree walk. Returns null when the hierarchy service + /// is not set or the path doesn't exist in the tree. + OccurrenceAddress? _resolveWaveformAddress(String waveformSignalId) => + hierarchyService?.waveformIdToAddress(waveformSignalId); + + /// Streams waveform data incrementally for specific signals. + /// + /// [signalIds] is a list of signal IDs for which to stream data. + /// [startTime] optionally specifies the starting time for the data stream. + /// [appendToSignals] if true, automatically appends streamed data to + /// cached waveforms. + /// + /// Returns a [Stream] of [WaveformData] objects. + Stream streamWaveformData({ + required List signalIds, + int? startTime, + bool appendToSignals = true, + }) async* { + await for (final waveformData in _signalWaveformApi.streamWaveformData( + signalIds: signalIds, + startTime: startTime, + )) { + if (appendToSignals) { + final addr = _resolveWaveformAddress(waveformData.signalId); + if (addr != null) { + final waveform = _waveformCache[addr]; + if (waveform != null) { + waveform.appendWaveformData(waveformData); + } else { + _waveformCache[addr] = SignalWaveform.fromWaveformData( + waveformData, + ); + } + } else { + // Sub-field / computed waveform — cache by string ID. + final id = waveformData.signalId; + final waveform = _subFieldWaveformCache[id]; + if (waveform != null) { + waveform.appendWaveformData(waveformData); + } else { + _subFieldWaveformCache[id] = + SignalWaveform.fromWaveformData(waveformData); + } + } + } + yield waveformData; + } + } + + /// Appends waveform data to a specific signal. + /// + /// [signalId] is the ID of the signal to append data to. + /// [data] is the list of data points to append. + /// [sortByTime] if true, sorts the data by time after appending. + /// + /// Returns true if data was appended (creates waveform if not exists). + bool appendDataToSignal( + String signalId, + List data, { + bool sortByTime = false, + }) { + final addr = _resolveWaveformAddress(signalId); + if (addr == null) { + // Sub-field / computed waveform — cache by string ID. + var waveform = _subFieldWaveformCache[signalId]; + if (waveform == null) { + waveform = SignalWaveform.empty(signalId); + _subFieldWaveformCache[signalId] = waveform; + } + waveform.appendData(data, sortByTime: sortByTime); + return true; + } + + var waveform = _waveformCache[addr]; + if (waveform == null) { + waveform = SignalWaveform.empty(signalId); + _waveformCache[addr] = waveform; + } + waveform.appendData(data, sortByTime: sortByTime); + return true; + } + + /// Clears waveform data for a specific signal. + /// + /// [address] is the address of the signal whose data should be cleared. + /// + /// Returns true if the waveform was found and data was cleared. + bool clearWaveformData(OccurrenceAddress address) { + final waveform = _waveformCache[address]; + if (waveform != null) { + waveform.clearData(); + return true; + } + return false; + } + + /// Clears all waveform data from all cached signals. + void clearAllWaveformData() { + _waveformCache.clear(); + _subFieldWaveformCache.clear(); + } + + /// Clears the entire signal cache (used when loading a new file). + void clearSignalCache() { + _signalCache.clear(); + _waveformCache.clear(); + _subFieldWaveformCache.clear(); + } + + // ───────────── Address-based cache accessors ───────────────── + + /// Gets a signal by its [OccurrenceAddress]. O(1). + SignalOccurrence? getSignal(OccurrenceAddress address) => + _signalCache[address]; + + /// Gets a signal waveform by [OccurrenceAddress]. O(1). + SignalWaveform? getWaveform(OccurrenceAddress address) => + _waveformCache[address]; + + /// Gets all cached signal addresses. + List get cachedSignalAddresses => + _signalCache.keys.toList(); + + // ───────────── String convenience accessors (tree-walk) ────────── + + /// Gets a signal by pathname string. O(depth) tree walk. + SignalOccurrence? getSignalById(String signalId) => + _lookupSignalByPath(signalId); + + /// Gets a signal waveform by pathname string. O(depth) tree walk. + /// Falls back to the sub-field cache for computed waveforms (bit-slices). + SignalWaveform? getWaveformById(String signalId) { + final addr = hierarchyService?.pathnameToAddress(signalId); + if (addr != null) { + return _waveformCache[addr]; + } + // Fallback: check sub-field cache for computed waveforms. + return _subFieldWaveformCache[signalId]; + } + + /// Gets all cached signal IDs as pathname strings. + List get cachedSignalIds => + _signalCache.values.map((s) => s.path()).toList(); + + /// Gets signals with their waveform data for the selected module. + List getWaveformsBySelectedModule( + HierarchyOccurrence module, + ) { + final waveforms = []; + for (final signal in module.signals) { + final addr = signal.address; + if (addr == null) { + continue; + } + var waveform = _waveformCache[addr]; + if (waveform == null) { + waveform = SignalWaveform.empty(signal.path()); + _waveformCache[addr] = waveform; + } + waveforms.add(waveform); + } + return waveforms; + } + + /// Gets signal metadata for the selected module. + /// + /// Use [getWaveformsBySelectedModule] to get waveform data. + List getSignalsBySelectedModule( + HierarchyOccurrence module) { + final signals = []; + for (final signal in module.signals) { + final addr = signal.address; + if (addr == null) { + continue; + } + if (!_signalCache.containsKey(addr)) { + _signalCache[addr] = signal; + } + signals.add(signal); + } + return signals; + } + + /// Builds the signal cache from the signal hierarchy. + void _buildSignalCache(List modules) { + void collectSignals(List nodes) { + for (final module in nodes) { + for (final signal in module.signals) { + final addr = signal.address; + if (addr == null) { + continue; + } + if (!_signalCache.containsKey(addr)) { + _signalCache[addr] = signal; + } + if (!_waveformCache.containsKey(addr)) { + _waveformCache[addr] = SignalWaveform.empty(signal.path()); + } + } + collectSignals(module.children); + } + } + + collectSignals(modules); + } + + // ───────────────────────────────────────────────────────────────────────── + // Sub-field bit-slice synthesis (API-agnostic) + // ───────────────────────────────────────────────────────────────────────── + + static const String _subFieldSeparator = '#'; + + /// Synthesize waveform data for a sub-field by extracting a bit range + /// from the parent signal's waveform data. + /// + /// Works with any API backend (DevTools, Wellen/VCD) because it fetches the + /// parent waveform via the API and resolves bit positions from the + /// hierarchy metadata. + Future _synthesizeBitSlice( + String signalId, { + int? startTime, + int? endTime, + }) async { + final idx = signalId.indexOf(_subFieldSeparator); + if (idx < 0) { + return null; + } + + final parentPath = signalId.substring(0, idx); + final fieldPath = signalId.substring(idx + 1); + + // Resolve parent signal metadata for logicType and width. + final parentSig = _lookupSignalByPath(parentPath); + if (parentSig == null) { + return null; + } + final parentWidth = parentSig.width; + + // Handle flat bit-slice patterns: b[N] or b[high:low]. + // These don't require logicType — they are pure bitvector access. + final bitSliceMatch = + RegExp(r'^b\[(\d+)(?::(\d+))?\]$').firstMatch(fieldPath); + int? bitSliceLo; + int? bitSliceWidth; + if (bitSliceMatch != null) { + final hi = int.parse(bitSliceMatch.group(1)!); + final lo = bitSliceMatch.group(2) != null + ? int.parse(bitSliceMatch.group(2)!) + : hi; + bitSliceLo = lo < hi ? lo : hi; + bitSliceWidth = (hi - lo).abs() + 1; + } else if (parentSig.logicType == null) { + return null; + } + + int lo; + int fieldWidth; + if (bitSliceLo != null) { + lo = bitSliceLo; + fieldWidth = bitSliceWidth!; + } else { + final resolved = _resolveFieldBits(parentSig.logicType!, fieldPath); + if (resolved == null) { + return null; + } + lo = resolved.startBit; + fieldWidth = resolved.width; + } + + // Fetch parent waveform data from the API. + final parentData = await _signalWaveformApi.getWaveformData( + signalIds: [parentPath], + startTime: startTime, + endTime: endTime, + ); + + if (parentData.isEmpty || parentData.first.data.isEmpty) { + return WaveformData(signalId: signalId, data: const []); + } + + final pData = parentData.first.data; + + // Extract bits at each parent timepoint, deduplicating consecutive values. + final outputData = []; + String? lastValue; + + for (final point in pData) { + final sliced = _extractBits(point.value, parentWidth, lo, fieldWidth); + if (sliced != lastValue) { + outputData.add(Data(time: point.time, value: sliced)); + lastValue = sliced; + } + } + + return WaveformData( + signalId: signalId, + data: outputData, + isComputed: true, + ); + } + + /// Recursively resolve a dot-separated field path within a [logicType] map + /// to its absolute bit position and width within the parent signal. + static ({int startBit, int width})? _resolveFieldBits( + Map logicType, + String fieldPath, + ) { + final dotIdx = fieldPath.indexOf('.'); + final String segment; + final String? remainder; + if (dotIdx >= 0) { + segment = fieldPath.substring(0, dotIdx); + remainder = fieldPath.substring(dotIdx + 1); + } else { + segment = fieldPath; + remainder = null; + } + + // Struct case: look up named field. + final fields = logicType['fields'] as List?; + if (fields != null) { + for (final fieldRaw in fields) { + final field = fieldRaw as Map; + final name = field['name'] as String? ?? ''; + if (name != segment) { + continue; + } + + final bits = field['bits'] as List?; + final width = field['width'] as int? ?? 1; + final startBit = bits != null && bits.isNotEmpty + ? (bits.cast().reduce((a, b) => a < b ? a : b)) + : 0; + + if (remainder == null) { + return (startBit: startBit, width: width); + } + final nestedType = field['type'] as Map?; + if (nestedType == null) { + return null; + } + final inner = _resolveFieldBits(nestedType, remainder); + if (inner == null) { + return null; + } + return (startBit: startBit + inner.startBit, width: inner.width); + } + return null; + } + + // Array case: look up by index [N]. + final arrayDims = logicType['arrayDims'] as List?; + if (arrayDims != null) { + final leafWidth = (logicType['elementWidth'] as int?) ?? 1; + final remainingDims = + arrayDims.length > 1 ? arrayDims.sublist(1).cast() : []; + final perElementWidth = remainingDims.isEmpty + ? leafWidth + : remainingDims.fold(leafWidth, (acc, d) => acc * d); + final elementType = logicType['elementType'] as Map?; + + final match = RegExp(r'^\[(\d+)\]$').firstMatch(segment); + if (match == null) { + return null; + } + final index = int.parse(match.group(1)!); + final startBit = index * perElementWidth; + + if (remainder == null) { + return (startBit: startBit, width: perElementWidth); + } + final subType = elementType ?? + (remainingDims.isNotEmpty + ? { + 'arrayDims': remainingDims, + 'elementWidth': leafWidth, + 'width': perElementWidth, + } + : null); + if (subType == null) { + return null; + } + final inner = _resolveFieldBits(subType, remainder); + if (inner == null) { + return null; + } + return (startBit: startBit + inner.startBit, width: inner.width); + } + + return null; + } + + /// Extract [width] bits starting at [startBit] from a value string. + /// + /// Handles multiple value formats: + /// - ROHD LogicValue format: `16'hFF00`, `8'b10101010` + /// - Raw hex: `ff00`, `0xff00` + /// - Raw binary: `10101010` + /// - x/z states + /// + /// Returns a hex-formatted string for the extracted slice. + static String _extractBits( + String value, + int parentWidth, + int startBit, + int width, + ) { + final lower = value.toLowerCase().trim(); + + // Handle pure x/z values. + if (lower.replaceAll('x', '').replaceAll('z', '').isEmpty && + lower.isNotEmpty) { + return width == 1 ? 'x' : 'x' * ((width + 3) ~/ 4); + } + + BigInt? bi; + + // Try ROHD radix format: 'h or 'b + final rohdMatch = RegExp(r"^(\d+)'([hb])(.+)$").firstMatch(lower); + if (rohdMatch != null) { + final radixChar = rohdMatch.group(2)!; + final digits = rohdMatch.group(3)!; + // Check for x/z in the value portion. + if (digits.contains('x') || digits.contains('z')) { + return width == 1 ? 'x' : 'x' * ((width + 3) ~/ 4); + } + final radix = radixChar == 'h' ? 16 : 2; + bi = BigInt.tryParse(digits, radix: radix); + } else if (lower.startsWith('0x')) { + bi = BigInt.tryParse(value.substring(2), radix: 16); + } else if (RegExp(r'^[01]+$').hasMatch(lower)) { + bi = BigInt.tryParse(value, radix: 2); + } else { + // Default: try hex parse. + bi = BigInt.tryParse(value, radix: 16); + } + + if (bi == null) { + return width == 1 ? 'x' : 'x' * ((width + 3) ~/ 4); + } + + // Extract the bit range. + final mask = (BigInt.one << width) - BigInt.one; + final sliced = (bi >> startBit) & mask; + + // Format output using ROHD radixString style: width'hHEX + if (width == 1) { + return sliced == BigInt.one ? '1' : '0'; + } + final hexDigits = (width + 3) ~/ 4; + final hex = sliced.toRadixString(16).padLeft(hexDigits, '0'); + return "$width'h$hex"; + } +} diff --git a/packages/rohd_waveform/pubspec.yaml b/packages/rohd_waveform/pubspec.yaml new file mode 100644 index 000000000..9ec84a97e --- /dev/null +++ b/packages/rohd_waveform/pubspec.yaml @@ -0,0 +1,24 @@ +name: rohd_waveform +description: "Waveform data models and APIs for wave viewers - ModuleStructure, SignalWaveform, and waveform primitives." +homepage: https://intel.github.io/rohd-website/ +repository: https://github.com/intel/rohd +version: 0.1.0 +issue_tracker: https://github.com/intel/rohd/issues + +publish_to: none + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + collection: ^1.15.0 + equatable: ^2.0.5 + rohd_hierarchy: ^0.1.0 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.17.3 + +dependency_overrides: + rohd_hierarchy: + path: ../rohd_hierarchy diff --git a/packages/rohd_waveform/test/metadata_test.dart b/packages/rohd_waveform/test/metadata_test.dart new file mode 100644 index 000000000..0e9ddeea1 --- /dev/null +++ b/packages/rohd_waveform/test/metadata_test.dart @@ -0,0 +1,74 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// metadata_test.dart +// Unit tests for the MetaData and Data models. +// +// 2026 July 15 +// Author: Desmond Kirkpatrick + +import 'package:rohd_waveform/rohd_waveform.dart'; +import 'package:test/test.dart'; + +void main() { + group('Data', () { + test('round-trips through JSON', () { + final data = Data(time: 42, value: '1010'); + final restored = Data.fromJson(data.toJson()); + expect(restored.time, 42); + expect(restored.value, '1010'); + }); + + test('empty starts at time zero with value 0', () { + final data = Data.empty(); + expect(data.time, 0); + expect(data.value, '0'); + }); + }); + + group('MetaData', () { + test('value equality via Equatable', () { + const a = MetaData(source: 'a.vcd', timescale: '1ns', date: 'today'); + const b = MetaData(source: 'a.vcd', timescale: '1ns', date: 'today'); + const c = MetaData(source: 'b.vcd', timescale: '1ns', date: 'today'); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + + test('empty has blank fields and zero range', () { + final meta = MetaData.empty(); + expect(meta.source, isEmpty); + expect(meta.timescale, isEmpty); + expect(meta.date, isEmpty); + expect(meta.startTime, 0); + expect(meta.endTime, 0); + expect(meta.format, isNull); + }); + + test('round-trips full payload through JSON', () { + const meta = MetaData( + source: 'dump.fst', + timescale: '100ps', + date: '2026-01-01', + startTime: 5, + endTime: 500, + timescaleFactor: 100, + version: 'sim-1.2', + format: WaveFormat.fst, + ); + final restored = MetaData.fromJson(meta.toJson()); + expect(restored, equals(meta)); + expect(restored.format, WaveFormat.fst); + expect(restored.timescaleFactor, 100); + expect(restored.version, 'sim-1.2'); + }); + + test('omits optional fields from JSON when null', () { + const meta = MetaData(source: 's', timescale: 't', date: 'd'); + final json = meta.toJson(); + expect(json.containsKey('timescaleFactor'), isFalse); + expect(json.containsKey('version'), isFalse); + expect(json.containsKey('format'), isFalse); + }); + }); +} diff --git a/packages/rohd_waveform/test/module_structure_test.dart b/packages/rohd_waveform/test/module_structure_test.dart new file mode 100644 index 000000000..f74c5b484 --- /dev/null +++ b/packages/rohd_waveform/test/module_structure_test.dart @@ -0,0 +1,39 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// module_structure_test.dart +// Unit tests for the ModuleStructure model (empty / no-signal cases). +// +// 2026 July 15 +// Author: Desmond Kirkpatrick + +import 'package:rohd_waveform/rohd_waveform.dart'; +import 'package:test/test.dart'; + +void main() { + group('ModuleStructure', () { + test('empty has blank metadata and no modules', () { + final structure = ModuleStructure.empty(); + expect(structure.modules, isEmpty); + expect(structure.metadata, equals(MetaData.empty())); + expect(structure.allSignalIds, isEmpty); + expect(structure.firstModuleWithSignals, isNull); + }); + + test('value equality via Equatable', () { + final a = ModuleStructure.empty(); + final b = ModuleStructure.empty(); + const c = ModuleStructure( + metadata: MetaData(source: 'x', timescale: '1ns', date: 'd'), + modules: [], + ); + expect(a, equals(b)); + expect(a, isNot(equals(c))); + }); + + test('withFirstRealModule returns the structure unchanged when empty', () { + final structure = ModuleStructure.empty(); + expect(structure.withFirstRealModule(), same(structure)); + }); + }); +} diff --git a/packages/rohd_waveform/test/signal_waveform_test.dart b/packages/rohd_waveform/test/signal_waveform_test.dart new file mode 100644 index 000000000..e8f45f8bd --- /dev/null +++ b/packages/rohd_waveform/test/signal_waveform_test.dart @@ -0,0 +1,204 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_waveform_test.dart +// Unit tests for SignalWaveform and WaveformData (no signal lookup required). +// +// 2026 July 15 +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:rohd_waveform/rohd_waveform.dart'; +import 'package:test/test.dart'; + +void main() { + // Ensure a clean static lookup state for these pure-data tests. + setUp(SignalWaveform.clearSignalLookup); + tearDown(SignalWaveform.clearSignalLookup); + + group('SignalWaveform metadata fallbacks (no lookup)', () { + test('name and width fall back to signalId metadata', () { + final wf = SignalWaveform(signalId: 'clk'); + expect(wf.signal, isNull); + expect(wf.name, 'clk'); + expect(wf.width, 1); + expect(wf.hierarchyPath, 'clk'); + expect(wf.id, 'clk'); + expect(wf.isPort, isFalse); + expect(wf.isSubField, isFalse); + }); + + test('derives bit-slice name and width from signalId', () { + final waveform = SignalWaveform(signalId: 'top/value#b[15:8]'); + + expect(waveform.isSubField, isTrue); + expect(waveform.name, 'value[15:8]'); + expect(waveform.width, 8); + }); + + test('derives structural field metadata from its parent signal', () { + final parent = SignalOccurrence( + name: 'value', + width: 16, + logicType: { + 'fields': [ + { + 'name': 'exponent', + 'width': 5, + 'bits': [8, 9, 10, 11, 12] + }, + ], + }, + ); + SignalWaveform.signalLookup = (id) => id == 'top/value' ? parent : null; + final waveform = SignalWaveform(signalId: 'top/value#exponent'); + + expect(waveform.name, 'value_exponent'); + expect(waveform.width, 5); + }); + + test('empty and length reflect data', () { + final wf = SignalWaveform.empty('s'); + expect(wf.isEmpty, isTrue); + expect(wf.isNotEmpty, isFalse); + expect(wf.length, 0); + + wf.appendData([Data(time: 0, value: '0')]); + expect(wf.isEmpty, isFalse); + expect(wf.length, 1); + + wf.clearData(); + expect(wf.isEmpty, isTrue); + }); + }); + + group('SignalWaveform.appendData', () { + test('sortByTime sorts and dedups keeping the latest value', () { + final wf = SignalWaveform(signalId: 's') + ..appendData([ + Data(time: 10, value: 'a'), + Data(time: 0, value: 'x'), + Data(time: 10, value: 'b'), + ], sortByTime: true); + + expect(wf.data.map((d) => d.time).toList(), [0, 10]); + // Last value at duplicate timestamp 10 wins. + expect(wf.getValueByTime(10), 'b'); + expect(wf.getValueByTime(0), 'x'); + }); + }); + + group('SignalWaveform.getValueByTime (binary search)', () { + final wf = SignalWaveform(signalId: 's', data: [ + Data(time: 0, value: '0'), + Data(time: 10, value: '1'), + Data(time: 20, value: '0'), + ]); + + test('returns the value at or before a time', () { + expect(wf.getValueByTime(0), '0'); + expect(wf.getValueByTime(5), '0'); + expect(wf.getValueByTime(10), '1'); + expect(wf.getValueByTime(15), '1'); + expect(wf.getValueByTime(100), '0'); + }); + + test('returns first value before the first sample', () { + expect(wf.getValueByTime(-5), '0'); + }); + + test('empty waveform returns empty string', () { + expect(SignalWaveform.empty('e').getValueByTime(3), ''); + }); + }); + + group('SignalWaveform navigation indices', () { + final wf = SignalWaveform(signalId: 's', data: [ + Data(time: 0, value: '0'), + Data(time: 10, value: '1'), + Data(time: 10, value: '1'), + Data(time: 20, value: '0'), + ]); + + test('getNextDataPointIndex skips duplicates at current time', () { + expect(wf.getNextDataPointIndex(0), 1); + expect(wf.getNextDataPointIndex(10), 3); + expect(wf.getNextDataPointIndex(20), -1); + }); + + test('getPreviousDataPointIndex skips duplicates at current time', () { + expect(wf.getPreviousDataPointIndex(20), 2); + expect(wf.getPreviousDataPointIndex(10), 0); + expect(wf.getPreviousDataPointIndex(0), -1); + }); + }); + + group('SignalWaveform copy/serialization', () { + test('copyFrom produces an independent data list', () { + final original = SignalWaveform( + signalId: 's', + data: [Data(time: 0, value: '0')], + ); + final copy = SignalWaveform.copyFrom(original); + expect(copy.signalId, 's'); + + copy.appendData([Data(time: 5, value: '1')]); + expect(original.length, 1, reason: 'copy must not mutate the original'); + expect(copy.length, 2); + }); + + test('round-trips through JSON', () { + final wf = SignalWaveform(signalId: 'sig', data: [ + Data(time: 0, value: '0'), + Data(time: 4, value: '1'), + ]); + final restored = SignalWaveform.fromJson(wf.toJson()); + expect(restored.signalId, 'sig'); + expect(restored.length, 2); + expect(restored.getValueByTime(4), '1'); + }); + }); + + group('WaveformData', () { + test('empty payload reports no data and null bounds', () { + final wd = WaveformData.empty('s'); + expect(wd.isEmpty, isTrue); + expect(wd.length, 0); + expect(wd.startTime, isNull); + expect(wd.endTime, isNull); + }); + + test('reports start/end times from its samples', () { + final wd = WaveformData(signalId: 's', data: [ + Data(time: 3, value: '0'), + Data(time: 9, value: '1'), + ]); + expect(wd.startTime, 3); + expect(wd.endTime, 9); + expect(wd.isNotEmpty, isTrue); + }); + + test('round-trips through JSON', () { + final wd = WaveformData( + signalId: 's', + data: [Data(time: 1, value: '1')], + isComputed: true, + ); + final restored = WaveformData.fromJson(wd.toJson()); + expect(restored.signalId, 's'); + expect(restored.length, 1); + }); + + test('SignalWaveform.fromWaveformData copies data and flags', () { + final wd = WaveformData( + signalId: 's', + data: [Data(time: 0, value: '0')], + isComputed: true, + ); + final wf = SignalWaveform.fromWaveformData(wd); + expect(wf.signalId, 's'); + expect(wf.length, 1); + expect(wf.isComputed, isTrue); + }); + }); +} diff --git a/packages/rohd_waveform/test/wave_format_test.dart b/packages/rohd_waveform/test/wave_format_test.dart new file mode 100644 index 000000000..d7a3aaa62 --- /dev/null +++ b/packages/rohd_waveform/test/wave_format_test.dart @@ -0,0 +1,48 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// wave_format_test.dart +// Unit tests for the WaveFormat enumeration. +// +// 2026 July 15 +// Author: Desmond Kirkpatrick + +import 'package:rohd_waveform/rohd_waveform.dart'; +import 'package:test/test.dart'; + +void main() { + group('WaveFormat', () { + test('extension defaults to the enum name', () { + for (final format in WaveFormat.values) { + final expected = format == WaveFormat.unknown ? '' : '.${format.name}'; + expect(format.extension, expected); + } + }); + + test('fromPath detects every format extension case-insensitively', () { + for (final format in WaveFormat.values) { + if (format.extension.isEmpty) { + continue; + } + expect( + WaveFormat.fromPath('sim/out${format.extension.toUpperCase()}'), + format, + ); + } + expect(WaveFormat.fromPath('notes.txt'), WaveFormat.unknown); + }); + + test('fromString parses every enum name case-insensitively', () { + for (final format in WaveFormat.values) { + expect(WaveFormat.fromString(format.name.toUpperCase()), format); + } + expect(WaveFormat.fromString('mystery'), WaveFormat.unknown); + }); + + test('round-trips through name and fromString', () { + for (final format in WaveFormat.values) { + expect(WaveFormat.fromString(format.name), format); + } + }); + }); +} diff --git a/packages/rohd_waveform/test/waveform_api_test.dart b/packages/rohd_waveform/test/waveform_api_test.dart new file mode 100644 index 000000000..353ed7f41 --- /dev/null +++ b/packages/rohd_waveform/test/waveform_api_test.dart @@ -0,0 +1,82 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// waveform_api_test.dart +// Unit tests for default SignalWaveformApi behavior. +// +// 2026 July 17 +// Author: Desmond Kirkpatrick + +import 'package:rohd_waveform/rohd_waveform.dart'; +import 'package:test/test.dart'; + +void main() { + group('SignalWaveformApi defaults', () { + test('base implementation reports itself loaded', () { + expect(const _BaseOnlySignalWaveformApi().isLoaded, isTrue); + }); + + test('base methods throw when subclasses do not implement them', () async { + const api = _BaseOnlySignalWaveformApi(); + + await expectLater( + api.getWaveformData(signalIds: ['top/clk']), + throwsA(isA()), + ); + await expectLater( + api.getCurrentTime(), throwsA(isA())); + await expectLater( + api.getSnapshot(10), throwsA(isA())); + }); + + test('default streamWaveformData yields getWaveformData results', () async { + final api = _StreamingSignalWaveformApi([ + WaveformData( + signalId: 'top/clk', + data: [Data(time: 0, value: '0')], + ), + WaveformData( + signalId: 'top/rst', + data: [Data(time: 0, value: '1')], + ), + ]); + + final streamed = await api.streamWaveformData( + signalIds: ['top/clk', 'top/rst'], startTime: 5).toList(); + + expect(streamed.map((w) => w.signalId), ['top/clk', 'top/rst']); + expect(api.requestedSignalIds, ['top/clk', 'top/rst']); + expect(api.requestedStartTime, 5); + }); + + test('default expandAllSlimModules is a no-op', () async { + await expectLater( + const _BaseOnlySignalWaveformApi().expandAllSlimModules(), + completes, + ); + }); + }); +} + +class _BaseOnlySignalWaveformApi extends SignalWaveformApi { + const _BaseOnlySignalWaveformApi(); +} + +class _StreamingSignalWaveformApi extends SignalWaveformApi { + _StreamingSignalWaveformApi(this.response); + + final List response; + List requestedSignalIds = const []; + int? requestedStartTime; + + @override + Future> getWaveformData({ + required List signalIds, + int? startTime, + int? endTime, + }) async { + requestedSignalIds = List.of(signalIds); + requestedStartTime = startTime; + return response; + } +} diff --git a/packages/rohd_waveform/test/waveform_repository_test.dart b/packages/rohd_waveform/test/waveform_repository_test.dart new file mode 100644 index 000000000..fe21f7e67 --- /dev/null +++ b/packages/rohd_waveform/test/waveform_repository_test.dart @@ -0,0 +1,440 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// waveform_repository_test.dart +// Unit tests for repository-backed waveform loading and caching. +// +// 2026 July 17 +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:rohd_waveform/rohd_waveform.dart'; +import 'package:test/test.dart'; + +void main() { + group('SignalWaveformRepository', () { + late SignalOccurrence clk; + late SignalOccurrence bus; + late HierarchyOccurrence top; + late _FakeSignalWaveformApi api; + late SignalWaveformRepository repository; + + setUp(() { + clk = SignalOccurrence( + name: 'clk', + width: 1, + direction: 'input', + portIndex: 0, + ); + bus = SignalOccurrence(name: 'bus', width: 4); + top = HierarchyOccurrence(name: 'top', signals: [clk, bus]); + api = _FakeSignalWaveformApi({ + 'top/clk': [Data(time: 0, value: '0'), Data(time: 5, value: '1')], + 'top.clk': [Data(time: 2, value: '1'), Data(time: 7, value: '0')], + 'top/bus': [ + Data(time: 0, value: "4'b1010"), + Data(time: 5, value: "4'b1011"), + Data(time: 10, value: "4'b0011"), + ], + }); + repository = SignalWaveformRepository(signalWaveformApi: api) + ..buildSignalCacheFromHierarchy([top]); + }); + + test('builds signal cache and loads waveforms by hierarchy path', () async { + expect(repository.cachedSignalIds, containsAll(['top/clk', 'top/bus'])); + expect(repository.getSignal(clk.address!), same(clk)); + expect(repository.getWaveform(clk.address!), isEmpty); + + final loaded = await repository.loadAndAppendWaveformData( + signalIds: ['top/clk'], + ); + + expect(api.requests, hasLength(1)); + expect(api.requests.single.signalIds, ['top/clk']); + expect(api.requests.single.startTime, isNull); + expect(api.requests.single.endTime, isNull); + expect(loaded.single.signalId, 'top/clk'); + expect(repository.getWaveform(clk.address!)!.data, hasLength(2)); + expect(repository.getWaveformById('top/clk')!.getValueByTime(5), '1'); + }); + + test('loads dot-separated waveform IDs into slash-path cache', () async { + final loaded = await repository.loadAndAppendWaveformData( + signalIds: ['top.clk'], + ); + + expect(loaded.single.signalId, 'top.clk'); + expect(repository.getWaveform(clk.address!)!.data.map((d) => d.time), [ + 2, + 7, + ]); + expect(repository.getWaveformById('top/clk')!.getValueByTime(7), '0'); + expect( + repository.getWaveformById('top.clk'), + same(repository.getWaveformById('top/clk')), + ); + }); + + test('forwards requested time windows to the API', () async { + await repository.loadAndAppendWaveformData( + signalIds: ['top/clk', 'top/bus'], + startTime: 5, + endTime: 10, + ); + + expect(api.requests, hasLength(1)); + expect(api.requests.single.signalIds, ['top/clk', 'top/bus']); + expect(api.requests.single.startTime, 5); + expect(api.requests.single.endTime, 10); + }); + + test('delegates current time and slim module expansion to the API', + () async { + expect(await repository.getCurrentTime(), 42); + + await repository.expandAllSlimModules(); + + expect(api.currentTimeCalls, 1); + expect(api.expandAllSlimModulesCalls, 1); + }); + + test('waits for apiReady before reading current time', () async { + final ready = Completer(); + api.isLoadedValue = false; + repository = SignalWaveformRepository( + signalWaveformApi: api, + apiReady: ready.future, + ); + + final currentTime = repository.getCurrentTime(); + await Future.delayed(Duration.zero); + expect(api.currentTimeCalls, 0); + + ready.complete(); + expect(await currentTime, 42); + expect(api.currentTimeCalls, 1); + }); + + test('setSignalWaveformApi clears caches and uses replacement API', + () async { + await repository.loadAndAppendWaveformData(signalIds: ['top/clk']); + expect(repository.getSignal(clk.address!), same(clk)); + expect(repository.getWaveform(clk.address!)!.data, isNotEmpty); + + final replacementApi = _FakeSignalWaveformApi({ + 'top/clk': [Data(time: 20, value: '1')], + }); + repository.setSignalWaveformApi(replacementApi); + + expect(repository.api, same(replacementApi)); + expect(repository.cachedSignalIds, isEmpty); + expect(repository.getSignal(clk.address!), isNull); + expect(repository.getWaveform(clk.address!), isNull); + + repository.buildSignalCacheFromHierarchy([top]); + await repository.loadAndAppendWaveformData(signalIds: ['top/clk']); + + expect(replacementApi.requests, hasLength(1)); + expect(repository.getWaveform(clk.address!)!.data.single.time, 20); + }); + + test('appends and clears waveform cache entries', () { + expect( + repository.appendDataToSignal( + 'top/clk', + [Data(time: 5, value: '1'), Data(time: 0, value: '0')], + sortByTime: true, + ), + isTrue, + ); + expect(repository.getWaveform(clk.address!)!.data.map((d) => d.time), [ + 0, + 5, + ]); + + expect(repository.clearWaveformData(clk.address!), isTrue); + expect(repository.getWaveform(clk.address!)!.data, isEmpty); + expect( + repository.clearWaveformData(const OccurrenceAddress([99])), isFalse); + }); + + test('keeps unresolved waveform IDs in sub-field cache', () { + expect( + repository.appendDataToSignal( + 'top/bus#b[0]', + [Data(time: 0, value: '0')], + ), + isTrue, + ); + + expect( + repository.getWaveformById('top/bus#b[0]')!.data.single.value, '0'); + + repository.clearAllWaveformData(); + expect(repository.getWaveformById('top/bus#b[0]'), isNull); + expect(repository.getSignal(clk.address!), same(clk)); + }); + + test('selected module helpers expose signal metadata and waveforms', () { + repository.clearAllWaveformData(); + + final signals = repository.getSignalsBySelectedModule(top); + final waveforms = repository.getWaveformsBySelectedModule(top); + + expect(signals, [clk, bus]); + expect(waveforms.map((w) => w.signalId), ['top/clk', 'top/bus']); + expect(repository.getWaveform(clk.address!), same(waveforms.first)); + }); + + test('can stream without appending to cached waveforms', () async { + api.streamResponses = [ + WaveformData( + signalId: 'top/clk', + data: [Data(time: 10, value: '0')], + ), + ]; + + final streamed = await repository.streamWaveformData( + signalIds: ['top/clk'], appendToSignals: false).toList(); + + expect(streamed, hasLength(1)); + expect(repository.getWaveform(clk.address!)!.data, isEmpty); + }); + + test('streams unresolved waveform IDs into the computed cache', () async { + api.streamResponses = [ + WaveformData( + signalId: 'top/bus#b[0]', + data: [Data(time: 0, value: '0')], + ), + WaveformData( + signalId: 'top/bus#b[0]', + data: [Data(time: 5, value: '1')], + ), + ]; + + await repository + .streamWaveformData(signalIds: ['top/bus#b[0]']).drain(); + + expect( + repository.getWaveformById('top/bus#b[0]')!.data.map((d) => d.value), + ['0', '1'], + ); + }); + + test('repository signal data service wraps cached waveforms', () async { + final service = RepositorySignalDataService(repository); + + final uncached = await service.getSignalData( + SignalOccurrence(name: 'detached', width: 1), + ); + expect(uncached.data, isEmpty); + expect(uncached.metadata, containsPair('cached', false)); + + await repository.loadAndAppendWaveformData(signalIds: ['top/clk']); + final cached = await service.getSignalData(clk); + + expect(cached.port, same(clk)); + expect(cached.data.map((d) => d.value), ['0', '1']); + expect(cached.metadata, containsPair('cached', true)); + expect(cached.metadata, containsPair('path', 'top/clk')); + }); + + test('streams waveform data and appends it to the cache', () async { + api.streamResponses = [ + WaveformData( + signalId: 'top/clk', + data: [Data(time: 10, value: '0')], + ), + WaveformData( + signalId: 'top/clk', + data: [Data(time: 15, value: '1')], + ), + ]; + + final streamed = await repository + .streamWaveformData(signalIds: ['top/clk'], startTime: 10).toList(); + + expect(streamed, hasLength(2)); + expect(api.streamRequests, hasLength(1)); + expect(api.streamRequests.single.signalIds, ['top/clk']); + expect(api.streamRequests.single.startTime, 10); + expect(repository.getWaveform(clk.address!)!.data.map((d) => d.time), [ + 10, + 15, + ]); + }); + + test('synthesizes bit-slice waveform data from parent signal', () async { + final synthesized = await repository.getWaveformData( + signalIds: ['top/bus#b[1:0]'], + ); + + expect(api.requests, hasLength(1)); + expect(api.requests.single.signalIds, ['top/bus']); + expect(api.requests.single.startTime, isNull); + expect(api.requests.single.endTime, isNull); + expect(synthesized.single.signalId, 'top/bus#b[1:0]'); + expect(synthesized.single.isComputed, isTrue); + expect(synthesized.single.data.map((d) => d.time), [0, 5]); + expect(synthesized.single.data.map((d) => d.value), ["2'h2", "2'h3"]); + }); + + test('appends range-loaded computed waveforms to their cache', () async { + await repository.loadAndAppendWaveformData( + signalIds: ['top/bus#b[0]'], + ); + api.responses['top/bus'] = [Data(time: 10, value: "4'b0000")]; + await repository.loadAndAppendWaveformData( + signalIds: ['top/bus#b[0]'], + startTime: 5, + endTime: 10, + ); + + expect( + repository.getWaveformById('top/bus#b[0]')!.data.map((d) => d.time), + [0, 5, 10], + ); + }); + + test('returns an empty computed waveform when parent data is absent', + () async { + api.responses['top/bus'] = []; + + final synthesized = await repository.getWaveformData( + signalIds: ['top/bus#b[0]'], + ); + + expect(synthesized.single.isComputed, isFalse); + expect(synthesized.single.data, isEmpty); + }); + + test('synthesizes bit slices from hexadecimal, binary, and unknown values', + () async { + api.responses['top/bus'] = [ + Data(time: 0, value: '0x9'), + Data(time: 1, value: '1010'), + Data(time: 2, value: 'zzzz'), + Data(time: 3, value: 'not-a-value'), + ]; + + final synthesized = await repository.getWaveformData( + signalIds: ['top/bus#b[0]'], + ); + + expect(synthesized.single.data.map((d) => d.value), ['1', '0', 'x']); + }); + + test('synthesizes nested struct field waveform data', () async { + bus.logicType = { + 'typeName': 'Packet', + 'fields': [ + { + 'name': 'payload', + 'width': 4, + 'bits': [0, 1, 2, 3], + 'type': { + 'typeName': 'Payload', + 'fields': [ + { + 'name': 'lo', + 'width': 2, + 'bits': [0, 1], + }, + { + 'name': 'hi', + 'width': 2, + 'bits': [2, 3], + }, + ], + }, + }, + ], + }; + + final synthesized = await repository.getWaveformData( + signalIds: ['top/bus#payload.hi'], + ); + + expect(synthesized.single.signalId, 'top/bus#payload.hi'); + expect(synthesized.single.isComputed, isTrue); + expect(synthesized.single.data.map((d) => d.time), [0, 10]); + expect(synthesized.single.data.map((d) => d.value), ["2'h2", "2'h0"]); + }); + + test('synthesizes array element waveform data', () async { + bus.logicType = { + 'width': 4, + 'arrayDims': [2], + 'elementWidth': 2, + }; + + final synthesized = await repository.getWaveformData( + signalIds: ['top/bus#[1]'], + ); + + expect(synthesized.single.signalId, 'top/bus#[1]'); + expect(synthesized.single.isComputed, isTrue); + expect(synthesized.single.data.map((d) => d.time), [0, 10]); + expect(synthesized.single.data.map((d) => d.value), ["2'h2", "2'h0"]); + }); + }); +} + +class _FakeSignalWaveformApi extends SignalWaveformApi { + _FakeSignalWaveformApi(this.responses); + + final Map> responses; + final requests = <({List signalIds, int? startTime, int? endTime})>[]; + final streamRequests = <({List signalIds, int? startTime})>[]; + List streamResponses = const []; + bool isLoadedValue = true; + int currentTime = 42; + int currentTimeCalls = 0; + int expandAllSlimModulesCalls = 0; + + @override + bool get isLoaded => isLoadedValue; + + @override + Future> getWaveformData({ + required List signalIds, + int? startTime, + int? endTime, + }) async { + requests.add(( + signalIds: List.of(signalIds), + startTime: startTime, + endTime: endTime, + )); + return [ + for (final signalId in signalIds) + WaveformData(signalId: signalId, data: responses[signalId] ?? const []), + ]; + } + + @override + Stream streamWaveformData({ + required List signalIds, + int? startTime, + }) async* { + streamRequests.add((signalIds: List.of(signalIds), startTime: startTime)); + for (final response in streamResponses) { + yield response; + } + } + + @override + Future getCurrentTime() async { + currentTimeCalls++; + return currentTime; + } + + @override + Future expandAllSlimModules() async { + expandAllSlimModulesCalls++; + } +} diff --git a/rohd-multipackage.code-workspace b/rohd-multipackage.code-workspace new file mode 100644 index 000000000..3c0ed09fc --- /dev/null +++ b/rohd-multipackage.code-workspace @@ -0,0 +1,30 @@ +// Opens each nested Dart package as a workspace root so the Dart analyzer uses +// its own package context and reports fewer cross-package Problems. Open this +// file in VS Code with File > Open Workspace from File... . +{ + "folders": [ + { + "name": "rohd", + "path": "." + }, + { + "name": "rohd_hierarchy", + "path": "packages/rohd_hierarchy" + }, + { + "name": "rohd_waveform", + "path": "packages/rohd_waveform" + }, + { + "name": "rohd_devtools_extension", + "path": "rohd_devtools_extension" + }, + { + "name": "rohd_devtools_widgets", + "path": "rohd_devtools_extension/packages/rohd_devtools_widgets" + } + ], + "settings": { + "dart.projectSearchDepth": 8 + } +} diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/io_vm_connection_strategy.dart b/rohd_devtools_extension/lib/rohd_devtools/services/io_vm_connection_strategy.dart index c8f2c2d27..05c460f4d 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/services/io_vm_connection_strategy.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/services/io_vm_connection_strategy.dart @@ -28,6 +28,17 @@ class _StdoutLog extends Log { /// VM connection strategy for native platforms (Linux/macOS/Windows). /// Uses vm_service_io's vmServiceConnectUri. class IoVmConnectionStrategy extends VmConnectionStrategy { + /// Creates a native VM connection strategy. + IoVmConnectionStrategy({ + Future Function(String uri, Log log)? connectUri, + Duration retryDelay = const Duration(milliseconds: 500), + }) : _connectUri = + connectUri ?? ((uri, log) => vmServiceConnectUri(uri, log: log)), + _retryDelay = retryDelay; + + final Future Function(String uri, Log log) _connectUri; + final Duration _retryDelay; + @override /// Connects to a VM service on native platforms. @@ -38,9 +49,9 @@ class IoVmConnectionStrategy extends VmConnectionStrategy { throw Exception('Invalid URI format'); } - final vmService = await vmServiceConnectUri( + final vmService = await _connectUri( normalizedUri.toString(), - log: _StdoutLog(), + _StdoutLog(), ).timeout( const Duration(seconds: 10), onTimeout: () => @@ -59,8 +70,6 @@ class IoVmConnectionStrategy extends VmConnectionStrategy { // fall back to the slow polling reconnect path. String? isolateId; const maxRetries = 6; - const retryDelay = Duration(milliseconds: 500); - for (var attempt = 1; attempt <= maxRetries; attempt++) { final vmInfo = attempt == 1 ? vm @@ -71,14 +80,14 @@ class IoVmConnectionStrategy extends VmConnectionStrategy { if (attempt < maxRetries) { Logger('VMService').info( 'No isolates yet (attempt $attempt/$maxRetries) — ' - 'waiting ${retryDelay.inMilliseconds} ms', + 'waiting ${_retryDelay.inMilliseconds} ms', ); - await Future.delayed(retryDelay); + await Future.delayed(_retryDelay); continue; } throw Exception( 'No isolates found in the VM after $maxRetries ' - 'attempts (${retryDelay.inMilliseconds * maxRetries} ms)', + 'attempts (${_retryDelay.inMilliseconds * maxRetries} ms)', ); } @@ -120,7 +129,7 @@ class IoVmConnectionStrategy extends VmConnectionStrategy { 'ROHD isolate not found yet (attempt $attempt/$maxRetries, ' '${isolates.length} isolate(s) seen) — retrying', ); - await Future.delayed(retryDelay); + await Future.delayed(_retryDelay); continue; } diff --git a/rohd_devtools_extension/lib/rohd_devtools/services/vm_service_signal_value_source.dart b/rohd_devtools_extension/lib/rohd_devtools/services/vm_service_signal_value_source.dart index c1be04bfd..7c35ef0a8 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/services/vm_service_signal_value_source.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/services/vm_service_signal_value_source.dart @@ -37,6 +37,9 @@ class VmServiceSignalValueSource implements SignalValueSource { /// VM service used for debug-event subscriptions. final vm.VmService vmService; + final Future Function(String, {required Disposable? isAlive}) + _evalInstance; + final StreamController _updatesController = StreamController.broadcast(); @@ -49,7 +52,11 @@ class VmServiceSignalValueSource implements SignalValueSource { required this.rohdControllerEval, required this.evalDisposable, required this.vmService, - }) { + Future Function( + String expression, { + required Disposable? isAlive, + })? evalInstance, + }) : _evalInstance = evalInstance ?? rohdControllerEval.evalInstance { _debugEventSubscription = vmService.onDebugEvent.listen( _handleDebugEvent, onError: (Object e) { @@ -71,7 +78,7 @@ class VmServiceSignalValueSource implements SignalValueSource { for (final expression in _currentTimeExpressions) { try { - final value = await rohdControllerEval.evalInstance( + final value = await _evalInstance( expression, isAlive: evalDisposable, ); @@ -122,7 +129,7 @@ class VmServiceSignalValueSource implements SignalValueSource { for (final expression in snapshotExpressions) { try { - final value = await rohdControllerEval.evalInstance( + final value = await _evalInstance( expression, isAlive: evalDisposable, ); diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/details_help_button.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/details_help_button.dart index 6fe12d1e7..4d5278be6 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/details_help_button.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/details_help_button.dart @@ -35,6 +35,6 @@ class DetailsHelpButton extends StatelessWidget { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(FlagProperty('isDark', value: isDark)); + properties.add(DiagnosticsProperty('isDark', isDark)); } } diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/devtool_appbar.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/devtool_appbar.dart index 7c8d7e022..53fc54e6a 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/devtool_appbar.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/devtool_appbar.dart @@ -78,6 +78,6 @@ class DevtoolAppBar extends StatelessWidget implements PreferredSizeWidget { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(FlagProperty('hasColorEmoji', value: hasColorEmoji)); + properties.add(DiagnosticsProperty('hasColorEmoji', hasColorEmoji)); } } diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_connection_host.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_connection_host.dart index d6b80eac1..de4b5d010 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_connection_host.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_connection_host.dart @@ -562,16 +562,16 @@ abstract class DevToolsConnectionHostState void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties - ..add(FlagProperty('isConnected', value: isConnected)) - ..add(FlagProperty('isConnecting', value: isConnecting)) - ..add(FlagProperty('isVmDead', value: isVmDead)) - ..add(FlagProperty('isPaused', value: isPaused)) + ..add(DiagnosticsProperty('isConnected', isConnected)) + ..add(DiagnosticsProperty('isConnecting', isConnecting)) + ..add(DiagnosticsProperty('isVmDead', isVmDead)) + ..add(DiagnosticsProperty('isPaused', isPaused)) ..add(DiagnosticsProperty('vmService', vmService)) ..add(StringProperty('lastVmServiceUri', lastVmServiceUri)) ..add(StringProperty('lastIsolateId', lastIsolateId)) ..add(StringProperty('connectedVmName', connectedVmName)) - ..add(FlagProperty('autoReconnect', value: autoReconnect)) - ..add(FlagProperty('isVmConnected', value: isVmConnected)) + ..add(DiagnosticsProperty('autoReconnect', autoReconnect)) + ..add(DiagnosticsProperty('isVmConnected', isVmConnected)) ..add(IntProperty('connectionGeneration', connectionGeneration)) ..add( DiagnosticsProperty( diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_help_button.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_help_button.dart index 4281686f6..2e7ff4c16 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_help_button.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/devtools_help_button.dart @@ -36,6 +36,6 @@ class DevToolsHelpButton extends StatelessWidget { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(FlagProperty('isDark', value: isDark)); + properties.add(DiagnosticsProperty('isDark', isDark)); } } diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_details_navbar.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_details_navbar.dart index d8e472006..183684ce2 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_details_navbar.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/module_tree_details_navbar.dart @@ -100,7 +100,7 @@ class _TabButton extends StatelessWidget { properties ..add(StringProperty('label', label)) ..add(DiagnosticsProperty('icon', icon)) - ..add(FlagProperty('isSelected', value: isSelected)) + ..add(DiagnosticsProperty('isSelected', isSelected)) ..add( ObjectFlagProperty('onTap', onTap, ifNull: 'disabled')); } diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/signal_details_card.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/signal_details_card.dart index c143059dc..c3972ff40 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/signal_details_card.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/signal_details_card.dart @@ -173,9 +173,9 @@ class SignalDetailsCardState extends State { super.debugFillProperties(properties); properties ..add(StringProperty('searchTerm', searchTerm)) - ..add(FlagProperty('inputSelected', value: inputSelected.value)) - ..add(FlagProperty('outputSelected', value: outputSelected.value)) - ..add(FlagProperty('inoutSelected', value: inoutSelected.value)) + ..add(DiagnosticsProperty('inputSelected', inputSelected.value)) + ..add(DiagnosticsProperty('outputSelected', outputSelected.value)) + ..add(DiagnosticsProperty('inoutSelected', inoutSelected.value)) ..add(IntProperty('notifier', notifier.value)); } } diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table.dart index 8c341c87f..140da744b 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/signal_table.dart @@ -60,9 +60,9 @@ class SignalTable extends StatefulWidget { properties ..add(DiagnosticsProperty('selectedModule', selectedModule)) ..add(StringProperty('searchTerm', searchTerm)) - ..add(FlagProperty('inputSelectedVal', value: inputSelectedVal)) - ..add(FlagProperty('outputSelectedVal', value: outputSelectedVal)) - ..add(FlagProperty('inoutSelectedVal', value: inoutSelectedVal)) + ..add(DiagnosticsProperty('inputSelectedVal', inputSelectedVal)) + ..add(DiagnosticsProperty('outputSelectedVal', outputSelectedVal)) + ..add(DiagnosticsProperty('inoutSelectedVal', inoutSelectedVal)) ..add(DiagnosticsProperty('snapshot', snapshot)) ..add(DiagnosticsProperty( 'timeDisplay', timeDisplay)); diff --git a/rohd_devtools_extension/lib/rohd_devtools/ui/vm_connection_form.dart b/rohd_devtools_extension/lib/rohd_devtools/ui/vm_connection_form.dart index 74848f14f..421bf5433 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/ui/vm_connection_form.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/ui/vm_connection_form.dart @@ -63,8 +63,8 @@ class DiscoveredVmService with Diagnosticable { ..add(StringProperty('name', name)) ..add(StringProperty('uri', uri)) ..add(StringProperty('exposedUri', exposedUri)) - ..add(FlagProperty('isAlive', value: isAlive)) - ..add(FlagProperty('autoReconnect', value: autoReconnect)) + ..add(DiagnosticsProperty('isAlive', isAlive)) + ..add(DiagnosticsProperty('autoReconnect', autoReconnect)) ..add(StringProperty('connectionUri', connectionUri)) ..add(StringProperty('displayLabel', displayLabel)); } @@ -185,8 +185,8 @@ class VmConnectionForm extends StatefulWidget { ifNull: 'disabled', ), ) - ..add(FlagProperty('showDemoButton', value: showDemoButton)) - ..add(FlagProperty('hasColorEmoji', value: hasColorEmoji)) + ..add(DiagnosticsProperty('showDemoButton', showDemoButton)) + ..add(DiagnosticsProperty('hasColorEmoji', hasColorEmoji)) ..add( DiagnosticsProperty( 'cleanVmServiceUri', diff --git a/rohd_devtools_extension/lib/rohd_devtools/view/rohd_devtools_page.dart b/rohd_devtools_extension/lib/rohd_devtools/view/rohd_devtools_page.dart index e6c352864..c999cb61b 100644 --- a/rohd_devtools_extension/lib/rohd_devtools/view/rohd_devtools_page.dart +++ b/rohd_devtools_extension/lib/rohd_devtools/view/rohd_devtools_page.dart @@ -7,6 +7,7 @@ // 2025 January 28 // Author: Roberto Torres +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:rohd_devtools_extension/rohd_devtools/const/app_theme.dart'; @@ -15,8 +16,22 @@ import 'package:rohd_devtools_extension/rohd_devtools/ui/ui.dart'; /// Main page for the embedded ROHD DevTools experience. class RohdDevToolsPage extends StatelessWidget { + /// Whether this page should wire [RohdServiceCubit] to the DevTools + /// extension service manager. + final bool manageServiceManager; + /// Creates the DevTools page. - const RohdDevToolsPage({super.key}); + const RohdDevToolsPage({super.key, this.manageServiceManager = true}); + + @override + + /// Adds the page configuration to Flutter's diagnostics tree. + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('manageServiceManager', manageServiceManager), + ); + } @override @@ -24,7 +39,9 @@ class RohdDevToolsPage extends StatelessWidget { Widget build(BuildContext context) => MultiBlocProvider( providers: [ BlocProvider(create: (context) => DevToolsThemeCubit()), - BlocProvider(create: (context) => RohdServiceCubit()), + BlocProvider( + create: (context) => RohdServiceCubit( + manageServiceManager: manageServiceManager)), BlocProvider(create: (context) => TreeSearchTermCubit()), BlocProvider(create: (context) => SelectedModuleCubit()), BlocProvider(create: (context) => SignalSearchTermCubit()), diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/CHANGELOG.md b/rohd_devtools_extension/packages/rohd_devtools_widgets/CHANGELOG.md new file mode 100644 index 000000000..bd0841426 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +- Initial release of shared ROHD DevTools widgets. diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/README.md b/rohd_devtools_extension/packages/rohd_devtools_widgets/README.md index 90d3e3681..e40b328bb 100644 --- a/rohd_devtools_extension/packages/rohd_devtools_widgets/README.md +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/README.md @@ -2,7 +2,29 @@ Shared Flutter widgets and utilities for ROHD DevTools debugger views. -This package contains reusable UI pieces used across ROHD debugger tools such as schematic and waveform viewers. It is intended for common controls and presentation helpers that should stay consistent across DevTools packages, including shared menus, help system components, export helpers, and other supporting widgets. +This package contains reusable UI pieces used across ROHD debugger tools such as +schematic and waveform viewers. It is intended for common controls, +presentation helpers, and extension-facing models that should stay consistent +across DevTools packages. + +## What It Provides + +- `MarkdownHelpButton` for structured in-app help content. +- `AppBarOverlay` for auto-hiding toolbar layouts in dense viewer surfaces. +- `ExportPngButton`, `captureBoundaryToPng`, `showExportToast`, and platform + PNG save helpers for screenshot/export flows. +- `CrossProbeService`, `LocalCrossProbeChannel`, `LocalCrossProbeService`, + `NullCrossProbeService`, and `CrossProbeButton` for sharing signal selections + between viewers. +- Source-navigation menu helpers, including `buildGotoSourceMenuItems`, + `sourceFormatIconStrip`, and related `RohdSourceFormat` formatting utilities. +- Bit-expansion menu and dialog helpers for multi-bit signals: + `buildBitExpansionMenuItems`, `resolveBitExpansionMenuValue`, + `BitExpandRangeAction`, `BitDefineFieldsAction`, and `BitFieldDef`. +- Logic-type formatting utilities, including `expandLogicType`, + `formatFieldValue`, and `formatTypeTooltip`. +- ROHD extension client/status abstractions: `RohdExtensionClient`, + `NullExtensionClient`, `RohdModuleInfo`, and `RohdFormatInfo`. ## Usage @@ -11,3 +33,10 @@ Add this package as a path dependency from a ROHD DevTools package and import th ```dart import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; ``` + +The package exports a single barrel library. Consumers should import +`rohd_devtools_widgets.dart` rather than reaching into `lib/src`. + +---------------- +Copyright (C) 2026 Intel Corporation +SPDX-License-Identifier: BSD-3-Clause diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_field_utils.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_field_utils.dart index 339057750..efb61eb93 100644 --- a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_field_utils.dart +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/bit_field_utils.dart @@ -77,13 +77,16 @@ abstract final class BitFieldUtils { for (final rawLine in lines) { final line = rawLine.trim(); if (line.isEmpty) continue; + final tokens = _splitAsciiWhitespace(line); // Try: name high:low - final namedRange = RegExp(r'^(\w+)\s+(\d+):(\d+)$').firstMatch(line); + final namedRange = tokens.length == 2 && _isWord(tokens[0]) + ? _parseRangeToken(tokens[1]) + : null; if (namedRange != null) { - final name = namedRange.group(1)!; - final a = int.parse(namedRange.group(2)!).clamp(0, maxBit); - final b = int.parse(namedRange.group(3)!).clamp(0, maxBit); + final name = tokens[0]; + final a = namedRange.$1.clamp(0, maxBit); + final b = namedRange.$2.clamp(0, maxBit); final high = a >= b ? a : b; final low = a >= b ? b : a; fields.add(BitFieldDef(name: name, high: high, low: low)); @@ -91,19 +94,21 @@ abstract final class BitFieldUtils { } // Try: name bit (single bit) - final namedSingle = RegExp(r'^(\w+)\s+(\d+)$').firstMatch(line); - if (namedSingle != null) { - final name = namedSingle.group(1)!; - final bit = int.parse(namedSingle.group(2)!).clamp(0, maxBit); + final namedSingle = tokens.length == 2 && + _isWord(tokens[0]) && + _isUnsignedDecimal(tokens[1]); + if (namedSingle) { + final name = tokens[0]; + final bit = int.parse(tokens[1]).clamp(0, maxBit); fields.add(BitFieldDef(name: name, high: bit, low: bit)); continue; } // Try: high:low (unnamed) - final anonRange = RegExp(r'^(\d+):(\d+)$').firstMatch(line); + final anonRange = tokens.length == 1 ? _parseRangeToken(tokens[0]) : null; if (anonRange != null) { - final a = int.parse(anonRange.group(1)!).clamp(0, maxBit); - final b = int.parse(anonRange.group(2)!).clamp(0, maxBit); + final a = anonRange.$1.clamp(0, maxBit); + final b = anonRange.$2.clamp(0, maxBit); final high = a >= b ? a : b; final low = a >= b ? b : a; fields.add(BitFieldDef(name: '[$high:$low]', high: high, low: low)); @@ -111,15 +116,78 @@ abstract final class BitFieldUtils { } // Try: single number (unnamed single bit) - final anonSingle = RegExp(r'^(\d+)$').firstMatch(line); - if (anonSingle != null) { - final bit = int.parse(anonSingle.group(1)!).clamp(0, maxBit); + if (tokens.length == 1 && _isUnsignedDecimal(tokens[0])) { + final bit = int.parse(tokens[0]).clamp(0, maxBit); fields.add(BitFieldDef(name: '[$bit]', high: bit, low: bit)); continue; } } return fields; } + + static List _splitAsciiWhitespace(String value) { + final tokens = []; + var tokenStart = -1; + for (var i = 0; i < value.length; i++) { + if (_isAsciiWhitespace(value.codeUnitAt(i))) { + if (tokenStart >= 0) { + tokens.add(value.substring(tokenStart, i)); + tokenStart = -1; + } + } else if (tokenStart < 0) { + tokenStart = i; + } + } + if (tokenStart >= 0) { + tokens.add(value.substring(tokenStart)); + } + return tokens; + } + + static (int, int)? _parseRangeToken(String value) { + final separator = value.indexOf(':'); + if (separator <= 0 || separator != value.lastIndexOf(':')) { + return null; + } + final highText = value.substring(0, separator); + final lowText = value.substring(separator + 1); + if (!_isUnsignedDecimal(highText) || !_isUnsignedDecimal(lowText)) { + return null; + } + return (int.parse(highText), int.parse(lowText)); + } + + static bool _isWord(String value) { + if (value.isEmpty) return false; + for (var i = 0; i < value.length; i++) { + final char = value.codeUnitAt(i); + final isUppercase = char >= 65 && char <= 90; + final isLowercase = char >= 97 && char <= 122; + final isDigit = char >= 48 && char <= 57; + final isUnderscore = char == 95; + if (!isUppercase && !isLowercase && !isDigit && !isUnderscore) { + return false; + } + } + return true; + } + + static bool _isUnsignedDecimal(String value) { + if (value.isEmpty) return false; + for (var i = 0; i < value.length; i++) { + final char = value.codeUnitAt(i); + if (char < 48 || char > 57) return false; + } + return true; + } + + static bool _isAsciiWhitespace(int char) => + char == 9 || + char == 10 || + char == 11 || + char == 12 || + char == 13 || + char == 32; } /// Show a dialog to select a bit range for a signal. diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/capture_boundary.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/capture_boundary.dart index 172f79d75..9c3e05ab8 100644 --- a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/capture_boundary.dart +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/capture_boundary.dart @@ -39,33 +39,31 @@ Future captureBoundaryToPng( String filePrefix = 'export', double pixelRatio = 2.0, Future Function(Uint8List pngBytes, String fileName)? saveFn, + Future Function( + RenderRepaintBoundary boundary, + double pixelRatio, + )? encodeFn, }) async { - final boundary = - boundaryKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; - if (boundary == null) { + final renderObject = boundaryKey.currentContext?.findRenderObject(); + if (renderObject is! RenderRepaintBoundary) { debugPrint('[ExportPng] No RepaintBoundary found'); return false; } + final boundary = renderObject; - final image = await boundary.toImage(pixelRatio: pixelRatio); - final byteData = await image.toByteData(format: ui.ImageByteFormat.png); - image.dispose(); + final encode = encodeFn ?? _encodeBoundaryToPng; + final pngBytes = await encode(boundary, pixelRatio); - if (byteData == null) { + if (pngBytes == null) { debugPrint('[ExportPng] Failed to encode PNG'); return false; } - final pngBytes = byteData.buffer.asUint8List(); final fileName = '${filePrefix}_${DateTime.now().millisecondsSinceEpoch}.png'; try { - final String? savedPath; - if (saveFn != null) { - savedPath = await saveFn(pngBytes, fileName); - } else { - savedPath = await export_png.savePngBytes(pngBytes, fileName); - } + final save = saveFn ?? export_png.savePngBytes; + final savedPath = await save(pngBytes, fileName); final msg = savedPath != null ? 'Saved: $savedPath' : 'Downloaded $fileName'; debugPrint('[ExportPng] $msg'); @@ -81,3 +79,13 @@ Future captureBoundaryToPng( return false; } } + +Future _encodeBoundaryToPng( + RenderRepaintBoundary boundary, + double pixelRatio, +) async { + final image = await boundary.toImage(pixelRatio: pixelRatio); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + return byteData?.buffer.asUint8List(); +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/markdown_help_button.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/markdown_help_button.dart index 713e6b0f1..ddc373afd 100644 --- a/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/markdown_help_button.dart +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/lib/src/markdown_help_button.dart @@ -21,7 +21,6 @@ // Author: Desmond Kirkpatrick import 'package:flutter/material.dart'; -import 'package:flutter/services.dart' show rootBundle; /// A help button that loads its content from a markdown asset file. /// @@ -117,8 +116,8 @@ class _MarkdownHelpButtonState extends State { _HelpContent? _content; @override - void initState() { - super.initState(); + void didChangeDependencies() { + super.didChangeDependencies(); _loadContent(); } @@ -133,23 +132,24 @@ class _MarkdownHelpButtonState extends State { Future _loadContent() async { try { + final assetBundle = DefaultAssetBundle.of(context); String raw; if (widget.package != null) { // Try the package-qualified path first (works when embedded as a // dependency in a host app), then fall back to the bare asset path // (standalone mode). This order avoids a spurious 404 on the web // when the bare path doesn't exist. - // Use catch-all because rootBundle.loadString throws FlutterError + // Use catch-all because AssetBundle.loadString throws FlutterError // (an Error, not Exception) when the asset is missing. try { - raw = await rootBundle + raw = await assetBundle .loadString('packages/${widget.package}/${widget.assetPath}'); // ignore: avoid_catches_without_on_clauses } catch (_) { - raw = await rootBundle.loadString(widget.assetPath); + raw = await assetBundle.loadString(widget.assetPath); } } else { - raw = await rootBundle.loadString(widget.assetPath); + raw = await assetBundle.loadString(widget.assetPath); } // Apply substitutions before parsing. final subs = widget.substitutions; @@ -364,11 +364,7 @@ class _HelpContent { final detailsIdx = raw.indexOf(detailsMarker); // Extract title from the first # heading. - String title = 'Help'; - final titleMatch = RegExp(r'^#\s+(.+)$', multiLine: true).firstMatch(raw); - if (titleMatch != null) { - title = titleMatch.group(1)!.trim(); - } + final title = _extractTitle(raw) ?? 'Help'; // Extract tooltip text. String tooltip = ''; @@ -412,7 +408,7 @@ class _HelpContent { } // Table separator row (|---|---|) — skip - if (RegExp(r'^\|[\s\-:|]+\|$').hasMatch(trimmed)) { + if (_isTableSeparatorRow(trimmed)) { continue; } @@ -420,7 +416,7 @@ class _HelpContent { if (trimmed.startsWith('|') && trimmed.endsWith('|') && i + 1 < lines.length && - RegExp(r'^\|[\s\-:|]+\|$').hasMatch(lines[i + 1].trim())) { + _isTableSeparatorRow(lines[i + 1].trim())) { continue; } @@ -459,6 +455,39 @@ class _HelpContent { return blocks; } + static String? _extractTitle(String raw) { + for (final line in raw.split('\n')) { + if (line.length > 2 && + line.codeUnitAt(0) == 35 && + _isAsciiWhitespace(line.codeUnitAt(1))) { + return line.substring(2).trim(); + } + } + return null; + } + + static bool _isTableSeparatorRow(String line) { + if (line.length < 3 || !line.startsWith('|') || !line.endsWith('|')) { + return false; + } + for (var i = 1; i < line.length - 1; i++) { + final char = line.codeUnitAt(i); + final isSeparatorChar = char == 45 || char == 58 || char == 124; + if (!isSeparatorChar && !_isAsciiWhitespace(char)) { + return false; + } + } + return true; + } + + static bool _isAsciiWhitespace(int char) => + char == 9 || + char == 10 || + char == 11 || + char == 12 || + char == 13 || + char == 32; + /// Strip backtick inline code markers: `text` → text. static String _stripInlineCode(String s) => s.replaceAll('`', ''); } diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/test/app_bar_overlay_test.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/app_bar_overlay_test.dart new file mode 100644 index 000000000..080f41c11 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/app_bar_overlay_test.dart @@ -0,0 +1,68 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// app_bar_overlay_test.dart +// Tests for auto-hiding app bar overlay behavior. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'dart:ui' show PointerDeviceKind; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; + +void main() { + PreferredSizeWidget testAppBar() => const PreferredSize( + preferredSize: Size.fromHeight(48), + child: Material(child: Text('Toolbar')), + ); + + testWidgets('lays out app bar above body when auto-hide is disabled', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: AppBarOverlay( + appBar: testAppBar(), + body: const Text('Body'), + ), + ), + ); + + expect(find.text('Toolbar'), findsOneWidget); + expect(find.text('Body'), findsOneWidget); + expect(find.byType(Column), findsOneWidget); + expect(find.byType(Stack), findsNothing); + }); + + testWidgets('slides overlay app bar in when pointer enters trigger zone', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: AppBarOverlay( + appBar: testAppBar(), + body: const Text('Body'), + autoHide: true, + triggerHeight: 16, + animationDuration: const Duration(milliseconds: 1), + ), + ), + ); + + final overlaySlide = find.descendant( + of: find.byType(AppBarOverlay), + matching: find.byType(SlideTransition), + ); + var slide = tester.widget(overlaySlide); + expect(slide.position.value, const Offset(0, -1)); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await gesture.addPointer(location: const Offset(10, 10)); + await tester.pumpAndSettle(); + + slide = tester.widget(overlaySlide); + expect(slide.position.value, Offset.zero); + }); +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/test/bit_expansion_menu_test.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/bit_expansion_menu_test.dart new file mode 100644 index 000000000..921ebe684 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/bit_expansion_menu_test.dart @@ -0,0 +1,133 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// bit_expansion_menu_test.dart +// Tests for bit expansion popup-menu items and action resolution. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; + +void main() { + testWidgets('buildBitExpansionMenuItems creates divider and actions', + (tester) async { + final items = buildBitExpansionMenuItems( + width: 16, + includeDivider: true, + fontSize: 11, + itemHeight: 28, + ); + + expect(items, hasLength(3)); + expect(items[0], isA()); + expect((items[1] as PopupMenuItem).value, 'expand_bits'); + expect((items[2] as PopupMenuItem).value, 'define_fields'); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: [ + (items[1] as PopupMenuItem).child!, + (items[2] as PopupMenuItem).child!, + ], + ), + ), + ), + ); + + expect(find.text('Expand Bits [16]'), findsOneWidget); + expect(find.text('Define Bit Fields [16]...'), findsOneWidget); + }); + + testWidgets('resolveBitExpansionMenuValue expands small widths immediately', + (tester) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + splashFactory: NoSplash.splashFactory, + ), + home: Builder( + builder: (ctx) { + context = ctx; + return const SizedBox.shrink(); + }, + ), + ), + ); + + final action = await resolveBitExpansionMenuValue( + context, + value: BitExpansionMenuValues.expandBits, + signalName: 'data', + width: BitFieldUtils.expandThreshold, + ); + + expect(action, isA()); + final range = action! as BitExpandRangeAction; + expect(range.bitStart, 0); + expect(range.bitEnd, BitFieldUtils.expandThreshold - 1); + expect( + await resolveBitExpansionMenuValue( + context, + value: 'other', + signalName: 'data', + width: 4, + ), + isNull, + ); + }); + + testWidgets('resolveBitExpansionMenuValue uses dialogs for large ranges', + (tester) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + splashFactory: NoSplash.splashFactory, + ), + home: Builder( + builder: (ctx) { + context = ctx; + return const SizedBox.shrink(); + }, + ), + ), + ); + + final rangeFuture = resolveBitExpansionMenuValue( + context, + value: BitExpansionMenuValues.expandBits, + signalName: 'bus', + width: BitFieldUtils.expandThreshold + 1, + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '6:3'); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + final range = await rangeFuture as BitExpandRangeAction; + expect(range.bitStart, 3); + expect(range.bitEnd, 6); + + final fieldsFuture = resolveBitExpansionMenuValue( + context, + value: BitExpansionMenuValues.defineFields, + signalName: 'bus', + width: 16, + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'upper 15:8\nlower 7:0'); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + final fields = await fieldsFuture as BitDefineFieldsAction; + expect(fields.fields.map((field) => field.name), ['upper', 'lower']); + }); +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/test/bit_field_utils_test.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/bit_field_utils_test.dart new file mode 100644 index 000000000..3999dfe58 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/bit_field_utils_test.dart @@ -0,0 +1,175 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// bit_field_utils_test.dart +// Tests for shared bit-field parsing and formatting utilities. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; + +void main() { + group('formatBitRange', () { + test('formats single-bit and multi-bit ranges', () { + expect(BitFieldUtils.formatBitRange(0, 1), '[0]'); + expect(BitFieldUtils.formatBitRange(4, 4), '[7:4]'); + }); + }); + + group('parseBitRange', () { + test('parses and clamps ranges and single bits', () { + expect(BitFieldUtils.parseBitRange('7:4', 31), (7, 4)); + expect(BitFieldUtils.parseBitRange('4:7', 31), (7, 4)); + expect(BitFieldUtils.parseBitRange('99:30', 31), (31, 30)); + expect(BitFieldUtils.parseBitRange('5', 31), (5, 5)); + expect(BitFieldUtils.parseBitRange('99', 31), (31, 31)); + }); + + test('rejects malformed ranges', () { + expect(BitFieldUtils.parseBitRange('7:4:1', 31), isNull); + expect(BitFieldUtils.parseBitRange('high:low', 31), isNull); + expect(BitFieldUtils.parseBitRange('', 31), isNull); + }); + }); + + group('parseBitFieldDefs', () { + test('parses named and unnamed bit fields', () { + final fields = BitFieldUtils.parseBitFieldDefs( + ''' +exponent 31:21 +mantissa 20:0 +sign 31 +7:4 +0 +ignored-name 3 +''', + 31, + ); + + expect(fields, hasLength(5)); + expect(fields[0].name, 'exponent'); + expect(fields[0].high, 31); + expect(fields[0].low, 21); + expect(fields[1].name, 'mantissa'); + expect(fields[1].high, 20); + expect(fields[1].low, 0); + expect(fields[2].name, 'sign'); + expect(fields[2].high, 31); + expect(fields[2].low, 31); + expect(fields[3].name, '[7:4]'); + expect(fields[3].high, 7); + expect(fields[3].low, 4); + expect(fields[4].name, '[0]'); + expect(fields[4].high, 0); + expect(fields[4].low, 0); + }); + + test('normalizes reversed and out-of-range indexes', () { + final fields = BitFieldUtils.parseBitFieldDefs( + ''' +low_high 1:8 +too_high 40:32 +''', + 31, + ); + + expect(fields, hasLength(2)); + expect(fields[0].high, 8); + expect(fields[0].low, 1); + expect(fields[1].high, 31); + expect(fields[1].low, 31); + }); + }); + + testWidgets('showBitRangeDialog returns parsed input and cancel returns null', + (tester) async { + late BuildContext dialogContext; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + splashFactory: NoSplash.splashFactory, + ), + home: Builder( + builder: (context) { + dialogContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ); + + final future = showBitRangeDialog( + dialogContext, + signalName: 'data', + width: 16, + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '2:5'); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + expect(await future, (5, 2)); + + final cancelled = showBitRangeDialog( + dialogContext, + signalName: 'data', + width: 16, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(await cancelled, isNull); + }); + + testWidgets('showDefineBitFieldsDialog parses edited definitions', + (tester) async { + late BuildContext dialogContext; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + splashFactory: NoSplash.splashFactory, + ), + home: Builder( + builder: (context) { + dialogContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ); + + final future = showDefineBitFieldsDialog( + dialogContext, + signalName: 'floatBits', + width: 32, + existingDefs: const [ + BitFieldDef(name: 'sign', high: 31, low: 31), + ], + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('floatBits'), findsOneWidget); + expect(find.textContaining('sign 31'), findsOneWidget); + + await tester.enterText( + find.byType(TextField), + 'sign 31\nexponent 30:23\nmantissa 22:0', + ); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + final fields = await future; + expect(fields, hasLength(3)); + expect(fields![0].name, 'sign'); + expect(fields[1].name, 'exponent'); + expect(fields[1].high, 30); + expect(fields[1].low, 23); + expect(fields[2].name, 'mantissa'); + }); +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/test/capture_boundary_test.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/capture_boundary_test.dart new file mode 100644 index 000000000..d1574c192 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/capture_boundary_test.dart @@ -0,0 +1,241 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// capture_boundary_test.dart +// Tests for RepaintBoundary PNG capture and toast feedback. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show RenderRepaintBoundary; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; + +void main() { + testWidgets('returns false when no repaint boundary is found', + (tester) async { + final key = GlobalKey(); + late BuildContext captureContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + captureContext = context; + return SizedBox(key: key, width: 10, height: 10); + }, + ), + ), + ); + + expect( + await captureBoundaryToPng(captureContext, boundaryKey: key), + isFalse, + ); + }); + + testWidgets('returns false when the key has no mounted context', + (tester) async { + final key = GlobalKey(); + late BuildContext captureContext; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + captureContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect( + await captureBoundaryToPng(captureContext, boundaryKey: key), + isFalse, + ); + }); + + testWidgets('saves injected PNG bytes and shows saved path toast', + (tester) async { + final key = GlobalKey(); + late BuildContext captureContext; + Uint8List? savedBytes; + String? fileName; + double? requestedPixelRatio; + + await _pumpRepaintBoundary( + tester, + key: key, + onContext: (context) => captureContext = context, + ); + + final succeeded = await captureBoundaryToPng( + captureContext, + boundaryKey: key, + filePrefix: 'wave', + pixelRatio: 3, + encodeFn: (boundary, pixelRatio) async { + expect(boundary, isA()); + requestedPixelRatio = pixelRatio; + return Uint8List.fromList([1, 2, 3]); + }, + saveFn: (pngBytes, suggestedName) async { + savedBytes = pngBytes; + fileName = suggestedName; + return '/tmp/$suggestedName'; + }, + ); + await tester.pump(); + + expect(succeeded, isTrue); + expect(requestedPixelRatio, 3); + expect(savedBytes, [1, 2, 3]); + expect(fileName, startsWith('wave_')); + expect(fileName, endsWith('.png')); + expect( + find.byWidgetPredicate( + (widget) => + widget is Text && + widget.data?.startsWith('Saved: /tmp/wave_') == true, + ), + findsOneWidget, + ); + await _letExportToastExpire(tester); + }); + + testWidgets('shows downloaded toast when save function returns no path', + (tester) async { + final key = GlobalKey(); + late BuildContext captureContext; + + await _pumpRepaintBoundary( + tester, + key: key, + onContext: (context) => captureContext = context, + ); + + final succeeded = await captureBoundaryToPng( + captureContext, + boundaryKey: key, + filePrefix: 'capture', + encodeFn: (boundary, pixelRatio) async => Uint8List.fromList([4, 5, 6]), + saveFn: (pngBytes, suggestedName) async => null, + ); + await tester.pump(); + + expect(succeeded, isTrue); + expect( + find.byWidgetPredicate( + (widget) => + widget is Text && + widget.data?.startsWith('Downloaded capture_') == true, + ), + findsOneWidget, + ); + await _letExportToastExpire(tester); + }); + + testWidgets('skips saved toast when context unmounts during save', + (tester) async { + final key = GlobalKey(); + late BuildContext captureContext; + + await _pumpRepaintBoundary( + tester, + key: key, + onContext: (context) => captureContext = context, + ); + + final succeeded = await captureBoundaryToPng( + captureContext, + boundaryKey: key, + filePrefix: 'wave', + encodeFn: (boundary, pixelRatio) async => Uint8List.fromList([1, 2, 3]), + saveFn: (pngBytes, suggestedName) async { + await tester.pumpWidget(const SizedBox.shrink()); + return '/tmp/$suggestedName'; + }, + ); + await tester.pump(); + + expect(succeeded, isTrue); + expect(find.textContaining('Saved:'), findsNothing); + }); + + testWidgets('returns false and shows failure toast when save throws', + (tester) async { + final key = GlobalKey(); + late BuildContext captureContext; + + await _pumpRepaintBoundary( + tester, + key: key, + onContext: (context) => captureContext = context, + ); + + final succeeded = await captureBoundaryToPng( + captureContext, + boundaryKey: key, + encodeFn: (boundary, pixelRatio) async => Uint8List.fromList([7, 8, 9]), + saveFn: (pngBytes, suggestedName) async => throw StateError('disk full'), + ); + await tester.pump(); + + expect(succeeded, isFalse); + expect(find.textContaining('Export failed: Bad state: disk full'), + findsOneWidget); + await _letExportToastExpire(tester); + }); + + testWidgets('returns false when injected encoder returns null', + (tester) async { + final key = GlobalKey(); + late BuildContext captureContext; + + await _pumpRepaintBoundary( + tester, + key: key, + onContext: (context) => captureContext = context, + ); + + expect( + await captureBoundaryToPng( + captureContext, + boundaryKey: key, + encodeFn: (boundary, pixelRatio) async => null, + ), + isFalse, + ); + }); +} + +Future _pumpRepaintBoundary( + WidgetTester tester, { + required GlobalKey key, + required ValueChanged onContext, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + onContext(context); + return Center( + child: RepaintBoundary( + key: key, + child: const SizedBox(width: 8, height: 8), + ), + ); + }, + ), + ), + ); +} + +Future _letExportToastExpire(WidgetTester tester) async { + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/test/cross_probe_button_test.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/cross_probe_button_test.dart new file mode 100644 index 000000000..22df3efc5 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/cross_probe_button_test.dart @@ -0,0 +1,46 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// cross_probe_button_test.dart +// Tests for the cross-probing toolbar toggle button. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; + +void main() { + testWidgets('toggles cross-probing service active state', (tester) async { + final channel = LocalCrossProbeChannel(); + final service = LocalCrossProbeService(channel, source: 'waveform'); + addTearDown(service.dispose); + addTearDown(channel.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, splashFactory: NoSplash.splashFactory), + home: Scaffold( + body: CrossProbeButton(service: service), + ), + ), + ); + + expect(service.isActive.value, isTrue); + expect( + tester.widget(find.byType(Tooltip)).message, + 'Cross-probing active — tap to disable', + ); + + await tester.tap(find.byType(IconButton)); + await tester.pump(); + + expect(service.isActive.value, isFalse); + expect( + tester.widget(find.byType(Tooltip)).message, + 'Cross-probing disabled — tap to enable', + ); + }); +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/test/cross_probe_menu_test.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/cross_probe_menu_test.dart new file mode 100644 index 000000000..4da66a7af --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/cross_probe_menu_test.dart @@ -0,0 +1,205 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// cross_probe_menu_test.dart +// Tests for cross-probe source navigation menu helpers. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; + +void main() { + test('encodes, decodes, and labels source menu values', () { + expect(gotoSourceMenuValue(RohdSourceFormat.rohd), 'goto_source:rohd'); + expect( + gotoSourceFormatFromValue('goto_source:sv'), + RohdSourceFormat.sv, + ); + expect(gotoSourceFormatFromValue(null), isNull); + expect(gotoSourceFormatFromValue('other'), isNull); + expect(gotoSourceFormatFromValue('goto_source:missing'), isNull); + + expect(gotoSourceShortName(RohdSourceFormat.rohd), 'ROHD'); + expect(gotoSourceShortName(RohdSourceFormat.sv), 'SV'); + expect(gotoSourceShortName(RohdSourceFormat.sc), 'SystemC'); + expect(gotoSourceShortName(RohdSourceFormat.fst), 'Waveform'); + expect( + gotoSourceMenuLabel(RohdSourceFormat.rohd), + 'Go to ROHD Source', + ); + expect( + gotoSourceMenuLabel(RohdSourceFormat.sv, count: 3), + 'Go to SV Source (3)', + ); + }); + + test('resolves default and exact navigable formats', () { + expect(resolveNavigableFormats(null), kDefaultNavigableFormats); + expect( + resolveNavigableFormats(const RohdModuleInfo(extensionAvailable: false)), + kDefaultNavigableFormats, + ); + expect( + resolveNavigableFormats( + const RohdModuleInfo(extensionAvailable: true, error: 'not ready'), + ), + kDefaultNavigableFormats, + ); + expect( + resolveNavigableFormats( + const RohdModuleInfo( + extensionAvailable: true, + formats: { + RohdSourceFormat.rohd: RohdFormatInfo( + available: true, + fileFound: false, + ), + RohdSourceFormat.sv: RohdFormatInfo( + available: false, + fileFound: true, + ), + }, + ), + ), + isEmpty, + ); + + final info = RohdModuleInfo( + extensionAvailable: true, + formats: { + RohdSourceFormat.rohd: const RohdFormatInfo( + available: true, + fileFound: true, + ), + RohdSourceFormat.sc: const RohdFormatInfo( + available: true, + fileFound: true, + ), + RohdSourceFormat.fst: const RohdFormatInfo( + available: true, + fileFound: true, + ), + }, + ); + + expect( + resolveNavigableFormats(info), + [RohdSourceFormat.rohd, RohdSourceFormat.sc], + ); + }); + + testWidgets('builds popup menu items with encoded values and custom icons', + (tester) async { + final items = buildGotoSourceMenuItems( + formats: [RohdSourceFormat.rohd, RohdSourceFormat.sv], + count: 2, + showIcons: true, + iconBuilder: (format, {double size = 18}) => Icon( + Icons.code, + key: ValueKey(format), + size: size, + ), + ); + + expect(items, hasLength(2)); + expect(items[0].value, 'goto_source:rohd'); + expect(items[1].value, 'goto_source:sv'); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: [for (final item in items) item.child!], + ), + ), + ), + ); + + expect(find.text('Go to ROHD Source (2)'), findsOneWidget); + expect(find.text('Go to SV Source (2)'), findsOneWidget); + expect(find.byKey(const ValueKey(RohdSourceFormat.rohd)), findsOneWidget); + expect(find.byKey(const ValueKey(RohdSourceFormat.sv)), findsOneWidget); + }); + + testWidgets('builds rows, disabled menu items, and no-icon source items', + (tester) async { + final disabled = buildRohdPopupMenuItem( + value: 'disabled', + icon: const Icon(Icons.block), + label: 'Disabled item', + enabled: false, + height: 40, + ); + final noIconItems = buildGotoSourceMenuItems( + formats: [RohdSourceFormat.sc], + showIcons: false, + textStyle: const TextStyle(fontSize: 11), + ); + + expect(disabled.enabled, isFalse); + expect(disabled.height, 40); + expect(noIconItems.single.value, 'goto_source:sc'); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: [ + disabled.child!, + noIconItems.single.child!, + sourcePopupMenuRow( + icon: const Icon(Icons.timeline), + label: 'A very long source navigation item label', + iconSlotWidth: 30, + gap: 4, + ), + ], + ), + ), + ), + ); + + expect(find.text('Disabled item'), findsOneWidget); + expect(find.text('Go to SystemC Source'), findsOneWidget); + expect(find.byType(SizedBox), findsWidgets); + expect(find.byIcon(Icons.timeline), findsOneWidget); + }); + + testWidgets('source format icons cover strips, aliases, and waveform icon', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: [ + sourceFormatMenuIcon(RohdSourceFormat.fst, size: 21), + sourceFormatIcon(RohdSourceFormat.fst, size: 22), + sourceFormatIconStrip( + formats: const [ + RohdSourceFormat.rohd, + RohdSourceFormat.sv, + RohdSourceFormat.sc, + ], + size: 13, + gap: 5, + iconBuilder: (format, {double size = 16}) => Icon( + Icons.code, + key: ValueKey('strip-${format.name}'), + size: size, + ), + ), + ], + ), + ), + ), + ); + + expect(find.byIcon(Icons.timeline), findsNWidgets(2)); + expect(find.byKey(const ValueKey('strip-rohd')), findsOneWidget); + expect(find.byKey(const ValueKey('strip-sv')), findsOneWidget); + expect(find.byKey(const ValueKey('strip-sc')), findsOneWidget); + }); +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/test/cross_probe_service_test.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/cross_probe_service_test.dart new file mode 100644 index 000000000..3f91273e8 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/cross_probe_service_test.dart @@ -0,0 +1,76 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// cross_probe_service_test.dart +// Tests for local and null cross-probing services. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; + +void main() { + group('LocalCrossProbeService', () { + test('broadcasts selections to other sources only', () { + final channel = LocalCrossProbeChannel(); + final waveform = LocalCrossProbeService(channel, source: 'waveform'); + final schematic = LocalCrossProbeService(channel, source: 'schematic'); + + addTearDown(waveform.dispose); + addTearDown(schematic.dispose); + addTearDown(channel.dispose); + + waveform.send(['top.clk', 'top.reset'], source: 'waveform'); + + expect(waveform.incomingSignals.value, isNull); + expect(schematic.incomingSignals.value, ['top.clk', 'top.reset']); + expect(channel.lastSource, 'waveform'); + expect(channel.lastPaths, ['top.clk', 'top.reset']); + expect( + () => channel.lastPaths!.add('top.extra'), + throwsUnsupportedError, + ); + }); + + test('does not broadcast while inactive or for empty selections', () { + final channel = LocalCrossProbeChannel(); + final waveform = LocalCrossProbeService(channel, source: 'waveform'); + final schematic = LocalCrossProbeService(channel, source: 'schematic'); + + addTearDown(waveform.dispose); + addTearDown(schematic.dispose); + addTearDown(channel.dispose); + + waveform.isActive.value = false; + waveform.send(['top.data'], source: 'waveform'); + expect(channel.lastPaths, isNull); + expect(schematic.incomingSignals.value, isNull); + + waveform.isActive.value = true; + waveform.send(const [], source: 'waveform'); + expect(channel.lastPaths, isNull); + expect(schematic.incomingSignals.value, isNull); + + schematic.isActive.value = false; + waveform.send(['top.data'], source: 'waveform'); + expect(channel.lastPaths, ['top.data']); + expect(schematic.incomingSignals.value, isNull); + }); + }); + + group('NullCrossProbeService', () { + test('starts inactive and ignores sends', () { + final service = NullCrossProbeService(); + addTearDown(service.dispose); + + expect(service.isActive.value, isFalse); + expect(service.incomingSignals.value, isNull); + + service.send(['top.clk'], source: 'waveform'); + + expect(service.isActive.value, isFalse); + expect(service.incomingSignals.value, isNull); + }); + }); +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/test/export_button_test.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/export_button_test.dart new file mode 100644 index 000000000..bdabebc52 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/export_button_test.dart @@ -0,0 +1,44 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// export_button_test.dart +// Tests for the PNG export toolbar button. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; + +void main() { + testWidgets('renders camera icon, tooltip, and invokes callback', + (tester) async { + var taps = 0; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + splashFactory: NoSplash.splashFactory, + ), + home: Scaffold( + body: Center( + child: ExportPngButton( + tooltip: 'Save waveform PNG', + onPressed: () => taps++, + ), + ), + ), + ), + ); + + expect(find.byIcon(Icons.camera_alt_outlined), findsOneWidget); + expect(tester.widget(find.byType(Tooltip)).message, + 'Save waveform PNG'); + + await tester.tap(find.byType(ExportPngButton)); + await tester.pump(); + + expect(taps, 1); + }); +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/test/export_toast_test.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/export_toast_test.dart new file mode 100644 index 000000000..b2fa49cf9 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/export_toast_test.dart @@ -0,0 +1,43 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// export_toast_test.dart +// Tests for export toast overlay behavior. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; + +void main() { + testWidgets('showExportToast inserts and removes an overlay entry', + (tester) async { + late BuildContext toastContext; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + toastContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ); + + showExportToast( + toastContext, + 'Saved: waveform.png', + duration: const Duration(milliseconds: 10), + ); + await tester.pump(); + + expect(find.text('Saved: waveform.png'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 10)); + await tester.pump(); + + expect(find.text('Saved: waveform.png'), findsNothing); + }); +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/test/logic_type_utils_test.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/logic_type_utils_test.dart index 4bcc64326..86c9b6e68 100644 --- a/rohd_devtools_extension/packages/rohd_devtools_widgets/test/logic_type_utils_test.dart +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/logic_type_utils_test.dart @@ -24,6 +24,17 @@ void main() { }); }); + group('formatFieldValue', () { + test('formats short binary, long hex, and unknown values', () { + expect(formatFieldValue(null, 4), isEmpty); + expect(formatFieldValue('', 4), isEmpty); + expect(formatFieldValue('1010', 4), "4'b1010"); + expect(formatFieldValue('00011010', 8), "8'h1a"); + expect(formatFieldValue('10x0', 4), "4'hx"); + expect(formatFieldValue('10z0', 4), "4'hz"); + }); + }); + test('expandLogicType slices contiguous array elements with LogicValue', () { final nodes = expandLogicType( { @@ -37,4 +48,113 @@ void main() { expect(nodes[0].value, '1100'); expect(nodes[1].value, '1010'); }); + + test('expandLogicType normalizes absolute struct bits and nested fields', () { + final nodes = expandLogicType( + { + 'typeName': 'Packet', + 'fields': [ + { + 'name': 'payload', + 'width': 4, + 'bits': [100, 101, 102, 103], + 'type': { + 'fields': [ + { + 'name': 'low', + 'width': 2, + 'bits': [0, 1], + }, + { + 'name': 'high', + 'width': 2, + 'bits': [2, 3], + }, + ], + }, + }, + { + 'name': 'valid', + 'width': 1, + 'bits': [104], + }, + ], + }, + parentBinaryValue: '11010', + ); + + expect(nodes, hasLength(2)); + expect(nodes[0].name, 'payload'); + expect(nodes[0].startBit, 0); + expect(nodes[0].value, '1010'); + expect(nodes[0].children, hasLength(2)); + expect(nodes[0].children[0].value, '10'); + expect(nodes[0].children[1].value, '10'); + expect(nodes[1].name, 'valid'); + expect(nodes[1].startBit, 4); + expect(nodes[1].value, '1'); + }); + + test('expandLogicType expands multidimensional arrays', () { + final nodes = expandLogicType( + { + 'arrayDims': [2, 2], + 'elementWidth': 2, + }, + parentBinaryValue: '11100100', + ); + + expect(nodes, hasLength(2)); + expect(nodes[0].name, '[0]'); + expect(nodes[0].width, 4); + expect(nodes[0].value, '0100'); + expect(nodes[0].children.map((child) => child.value), ['00', '01']); + expect(nodes[1].value, '1110'); + expect(nodes[1].children.map((child) => child.value), ['10', '11']); + }); + + test('formatTypeTooltip includes signal name, type, values, and depth limit', + () { + final tooltip = formatTypeTooltip( + { + 'typeName': 'Packet', + 'fields': [ + { + 'name': 'payload', + 'width': 4, + 'bits': [0, 1, 2, 3], + 'type': { + 'fields': [ + { + 'name': 'nibble', + 'width': 4, + 'bits': [0, 1, 2, 3], + 'type': { + 'fields': [ + { + 'name': 'bit0', + 'width': 1, + 'bits': [0], + }, + ], + }, + }, + ], + }, + }, + ], + }, + parentBinaryValue: '1010', + signalName: 'packet', + maxDepth: 2, + ); + + expect( + tooltip, + '''packet (Packet) + payload: 4'b1010 + nibble: 4'b1010 + ...''', + ); + }); } diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/test/markdown_help_button_test.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/markdown_help_button_test.dart new file mode 100644 index 000000000..c1731db48 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/markdown_help_button_test.dart @@ -0,0 +1,125 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// markdown_help_button_test.dart +// Tests for markdown-backed help button loading and dialog rendering. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; + +void main() { + const assetPath = 'assets/help/test_help.md'; + const packagePath = 'packages/rohd_devtools_widgets/$assetPath'; + const markdown = ''' +# Wave Help {{VERSION}} + + + +Quick help for {{THING}} + + + +## Navigation + +| Key | Description | +| --- | --- | +| `F` | Fit to canvas | + +First line +second line +'''; + + Future pumpUntilTooltipMessage( + WidgetTester tester, + String expected, + ) async { + for (var i = 0; i < 20; i++) { + await tester.pump(const Duration(milliseconds: 10)); + final tooltip = tester.widget(find.byType(Tooltip)); + if (tooltip.message == expected) { + return; + } + } + expect(tester.widget(find.byType(Tooltip)).message, expected); + } + + testWidgets('loads markdown, applies substitutions, and renders dialog', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: DefaultAssetBundle( + bundle: _MapAssetBundle({packagePath: markdown}), + child: const MarkdownHelpButton( + assetPath: assetPath, + package: 'rohd_devtools_widgets', + isDark: false, + label: 'Help', + substitutions: {'VERSION': '1.2.3', 'THING': 'waveforms'}, + ), + ), + ), + ); + await pumpUntilTooltipMessage(tester, 'Quick help for waveforms'); + + expect( + tester.widget(find.byType(Tooltip)).message, + 'Quick help for waveforms', + ); + + await tester.tap(find.text('Help')); + await tester.pumpAndSettle(); + + expect(find.text('Wave Help 1.2.3'), findsOneWidget); + expect(find.text('Navigation'), findsOneWidget); + expect(find.text('F'), findsOneWidget); + expect(find.text('Fit to canvas'), findsOneWidget); + expect(find.text('First line second line'), findsOneWidget); + }); + + testWidgets('falls back to bare asset path when package asset is unavailable', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: DefaultAssetBundle( + bundle: _MapAssetBundle({assetPath: markdown}), + child: const MarkdownHelpButton( + assetPath: assetPath, + package: 'rohd_devtools_widgets', + isDark: true, + labelIcon: Icon(Icons.help_outline), + substitutions: {'VERSION': '2.0.0', 'THING': 'signals'}, + ), + ), + ), + ); + await pumpUntilTooltipMessage(tester, 'Quick help for signals'); + + expect(find.byIcon(Icons.help_outline), findsOneWidget); + expect( + tester.widget(find.byType(Tooltip)).message, + 'Quick help for signals', + ); + }); +} + +class _MapAssetBundle extends CachingAssetBundle { + final Map assets; + + _MapAssetBundle(this.assets); + + @override + Future load(String key) async { + final asset = assets[key]; + if (asset == null) { + throw FlutterError('Unable to load asset: "$key".'); + } + return ByteData.sublistView(Uint8List.fromList(utf8.encode(asset))); + } +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/test/rohd_extension_status_test.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/rohd_extension_status_test.dart new file mode 100644 index 000000000..d340c0c45 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/rohd_extension_status_test.dart @@ -0,0 +1,127 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// rohd_extension_status_test.dart +// Tests for ROHD extension status models and null client. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; + +void main() { + group('RohdModuleInfo', () { + test('round-trips JSON and exposes usable format helpers', () { + final info = RohdModuleInfo.fromJson({ + 'extensionAvailable': true, + 'module': 'Counter', + 'dtdHealthy': true, + 'dtdRegistrationConflict': true, + 'dtdStatusMessage': 'using ROHD bridge', + 'fstLoading': true, + 'formats': { + 'rohd': { + 'available': true, + 'fileFound': true, + 'path': '/tmp/counter.dart', + }, + 'sv': { + 'available': true, + 'fileFound': false, + 'path': '/tmp/counter.sv', + }, + 'fst': { + 'available': true, + 'fileFound': true, + 'path': '/tmp/counter.fst', + }, + 'unknown': {'available': true, 'fileFound': true}, + }, + }); + + expect(info.extensionAvailable, isTrue); + expect(info.module, 'Counter'); + expect(info.dtdHealthy, isTrue); + expect(info.dtdRegistrationConflict, isTrue); + expect(info.dtdStatusMessage, 'using ROHD bridge'); + expect(info.fstLoading, isTrue); + expect(info.hasRohd, isTrue); + expect(info.hasSv, isFalse); + expect(info.hasSc, isFalse); + expect(info.hasFst, isTrue); + expect(info.hasAnySource, isTrue); + expect(info.availableFormatNames, ['rohd', 'fst']); + expect(info.navigableSourceFormats, [RohdSourceFormat.rohd]); + + expect(info.toJson(), { + 'extensionAvailable': true, + 'module': 'Counter', + 'formats': { + 'rohd': { + 'available': true, + 'fileFound': true, + 'path': '/tmp/counter.dart', + }, + 'sv': { + 'available': true, + 'fileFound': false, + 'path': '/tmp/counter.sv', + }, + 'fst': { + 'available': true, + 'fileFound': true, + 'path': '/tmp/counter.fst', + }, + }, + 'dtdHealthy': true, + 'dtdRegistrationConflict': true, + 'dtdStatusMessage': 'using ROHD bridge', + 'fstLoading': true, + }); + }); + + test('uses display labels and unavailable sentinel defaults', () { + expect(RohdModuleInfo.formatLabel(RohdSourceFormat.rohd), 'ROHD (Dart)'); + expect(RohdModuleInfo.formatLabel(RohdSourceFormat.sv), 'SystemVerilog'); + expect(RohdModuleInfo.formatLabel(RohdSourceFormat.sc), 'SystemC'); + expect( + RohdModuleInfo.formatLabel(RohdSourceFormat.fst), 'Waveform (FST)'); + + const unavailable = RohdModuleInfo.unavailable; + expect(unavailable.extensionAvailable, isFalse); + expect(unavailable.availableFormatNames, isEmpty); + expect(unavailable.navigableSourceFormats, isEmpty); + expect(unavailable.toJson(), { + 'extensionAvailable': false, + 'formats': {}, + 'fstLoading': false, + }); + }); + }); + + group('NullExtensionClient', () { + test('reports unavailable and returns empty lookup results', () async { + final client = NullExtensionClient(); + addTearDown(client.dispose); + + expect(client.isAvailable.value, isFalse); + expect(client.currentModuleInfo.value, isNull); + expect(await client.ping(), isFalse); + expect(await client.queryModule('Counter'), RohdModuleInfo.unavailable); + expect( + await client.lookupSignalFrames( + signals: [ + {'module': 'Counter', 'name': 'clk'}, + ], + format: 'rohd', + ), + isEmpty, + ); + + client.openSourceLocation(file: '/tmp/counter.dart', line: 12); + expect(client.isAvailable.value, isFalse); + expect(client.currentModuleInfo.value, isNull); + }); + }); +} diff --git a/rohd_devtools_extension/packages/rohd_devtools_widgets/test/save_png_native_test.dart b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/save_png_native_test.dart new file mode 100644 index 000000000..bb23512a7 --- /dev/null +++ b/rohd_devtools_extension/packages/rohd_devtools_widgets/test/save_png_native_test.dart @@ -0,0 +1,36 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// save_png_native_test.dart +// Tests for native PNG byte saving. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_widgets/src/save_png_native.dart' as native; + +void main() { + test('writes PNG bytes into the current directory', () async { + final previousDirectory = Directory.current; + final tempDirectory = await Directory.systemTemp.createTemp( + 'rohd_devtools_widgets_save_png_', + ); + addTearDown(() async { + Directory.current = previousDirectory; + await tempDirectory.delete(recursive: true); + }); + + Directory.current = tempDirectory; + final savedPath = await native.savePngBytes( + Uint8List.fromList([1, 2, 3, 4]), + 'capture.png', + ); + + expect(savedPath, '${tempDirectory.path}/capture.png'); + expect(await File(savedPath!).readAsBytes(), [1, 2, 3, 4]); + }); +} diff --git a/rohd_devtools_extension/test/const/app_theme_test.dart b/rohd_devtools_extension/test/const/app_theme_test.dart new file mode 100644 index 000000000..b7510c70b --- /dev/null +++ b/rohd_devtools_extension/test/const/app_theme_test.dart @@ -0,0 +1,57 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// app_theme_test.dart +// Tests for ROHD DevTools theme configurations. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/const/app_theme.dart'; + +void main() { + group('buildDarkTheme', () { + test('preserves the DevTools dark visual contract', () { + final theme = buildDarkTheme(); + + expect(theme.brightness, Brightness.dark); + expect(theme.scaffoldBackgroundColor, DarkThemeColors.scaffoldBackground); + expect(theme.cardColor, DarkThemeColors.cardBackground); + expect(theme.dividerColor, DarkThemeColors.divider); + expect( + theme.appBarTheme.backgroundColor, DarkThemeColors.appBarBackground); + expect(theme.appBarTheme.foregroundColor, DarkThemeColors.text); + expect(theme.appBarTheme.elevation, 0); + expect(theme.appBarTheme.shadowColor, Colors.transparent); + expect(theme.hoverColor, Colors.transparent); + expect(theme.splashColor, Colors.transparent); + expect(theme.splashFactory, NoSplash.splashFactory); + expect( + theme.textTheme.bodyMedium!.fontFamilyFallback, ['Noto Color Emoji']); + }); + }); + + group('buildLightTheme', () { + test('preserves the DevTools light visual contract', () { + final theme = buildLightTheme(); + + expect(theme.brightness, Brightness.light); + expect( + theme.scaffoldBackgroundColor, LightThemeColors.scaffoldBackground); + expect(theme.cardColor, LightThemeColors.cardBackground); + expect(theme.dividerColor, LightThemeColors.divider); + expect( + theme.appBarTheme.backgroundColor, LightThemeColors.appBarBackground); + expect(theme.appBarTheme.foregroundColor, LightThemeColors.text); + expect(theme.appBarTheme.elevation, 0); + expect(theme.appBarTheme.shadowColor, Colors.transparent); + expect(theme.hoverColor, Colors.transparent); + expect(theme.splashColor, Colors.transparent); + expect(theme.splashFactory, NoSplash.splashFactory); + expect( + theme.textTheme.bodyMedium!.fontFamilyFallback, ['Noto Color Emoji']); + }); + }); +} diff --git a/rohd_devtools_extension/test/cubit/basic_cubits_test.dart b/rohd_devtools_extension/test/cubit/basic_cubits_test.dart new file mode 100644 index 000000000..d8a7d5d3c --- /dev/null +++ b/rohd_devtools_extension/test/cubit/basic_cubits_test.dart @@ -0,0 +1,94 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// basic_cubits_test.dart +// Tests for basic ROHD DevTools cubit state transitions. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/details_tab_cubit.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/selected_module_cubit.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/signal_search_term_cubit.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/theme_cubit.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/tree_search_term_cubit.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/models/tree_model.dart'; + +void main() { + group('search term cubits', () { + test('store the latest signal and tree search terms', () async { + final signalCubit = SignalSearchTermCubit(); + final treeCubit = TreeSearchTermCubit(); + addTearDown(signalCubit.close); + addTearDown(treeCubit.close); + + expect(signalCubit.state, isNull); + expect(treeCubit.state, isNull); + + signalCubit.setTerm('count'); + treeCubit.setTerm('counter/top'); + + expect(signalCubit.state, 'count'); + expect(treeCubit.state, 'counter/top'); + }); + }); + + test('DetailsTabCubit selects each available details view', () async { + final cubit = DetailsTabCubit(); + addTearDown(cubit.close); + + expect(cubit.state, DetailsTab.details); + + cubit.selectTab(DetailsTab.waveform); + expect(cubit.state, DetailsTab.waveform); + + cubit.selectTab(DetailsTab.schematic); + expect(cubit.state, DetailsTab.schematic); + }); + + test('SelectedModuleCubit exposes the selected module', () async { + final cubit = SelectedModuleCubit(); + addTearDown(cubit.close); + final module = TreeModel( + name: 'counter', + inputs: const [], + outputs: const [], + subModules: const [], + ); + + expect(cubit.state, isA()); + + cubit.setModule(module); + + expect(cubit.state, isA()); + expect((cubit.state as SelectedModuleLoaded).module, same(module)); + }); + + group('DevToolsThemeCubit', () { + test('starts dark and toggles between the supported modes', () async { + final cubit = DevToolsThemeCubit(); + addTearDown(cubit.close); + + expect(cubit.state, DevToolsThemeMode.dark); + expect(cubit.isDark, isTrue); + + cubit.toggleTheme(); + expect(cubit.state, DevToolsThemeMode.light); + expect(cubit.isDark, isFalse); + + cubit.toggleTheme(); + expect(cubit.state, DevToolsThemeMode.dark); + }); + + test('sets an explicit theme mode', () async { + final cubit = DevToolsThemeCubit(); + addTearDown(cubit.close); + + cubit.setTheme(DevToolsThemeMode.light); + + expect(cubit.state, DevToolsThemeMode.light); + expect(cubit.isDark, isFalse); + }); + }); +} diff --git a/rohd_devtools_extension/test/cubit/rohd_service_cubit_test.dart b/rohd_devtools_extension/test/cubit/rohd_service_cubit_test.dart new file mode 100644 index 000000000..7db9aea9e --- /dev/null +++ b/rohd_devtools_extension/test/cubit/rohd_service_cubit_test.dart @@ -0,0 +1,137 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// rohd_service_cubit_test.dart +// Tests for ROHD service cubit behavior without a VM service. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/rohd_service_cubit.dart'; +import 'package:vm_service/vm_service.dart' as vm; + +class _MockVmService extends Mock implements vm.VmService {} + +void main() { + test('configures a standalone service and finds the ROHD isolate', () async { + final vmService = _MockVmService(); + const rohdIsolateId = 'isolates/rohd'; + final rohdIsolate = vm.IsolateRef( + id: rohdIsolateId, + name: 'rohd application', + ); + final vmInfo = vm.VM( + version: '3.6.0', + isolates: [ + vm.IsolateRef(id: 'isolates/runner', name: 'test runner'), + rohdIsolate, + ], + ); + final inspectorLibrary = vm.LibraryRef( + id: 'libraries/rohd-inspector', + uri: 'package:rohd/src/diagnostics/inspector_service.dart', + ); + final dartIoLibrary = vm.LibraryRef( + id: 'libraries/dart-io', + uri: 'dart:io', + ); + const hierarchyPayload = ''' + {"name":"top","inputs":{},"outputs":{},"subModules":[]} + '''; + + when(() => vmService.wsUri).thenReturn('ws://host:8181/app=/ws'); + when(vmService.getVM).thenAnswer((_) async => vmInfo); + when(() => vmService.onIsolateEvent) + .thenAnswer((_) => const Stream.empty()); + when(() => vmService.onDebugEvent) + .thenAnswer((_) => const Stream.empty()); + when(() => vmService.onExtensionEvent) + .thenAnswer((_) => const Stream.empty()); + when(() => vmService.onEvent(any())) + .thenAnswer((_) => const Stream.empty()); + when(() => vmService.streamListen(any())) + .thenAnswer((_) async => vm.Success()); + when(() => vmService.getIsolate('isolates/runner')).thenAnswer( + (_) async => vm.Isolate( + id: 'isolates/runner', + name: 'test runner', + libraries: [dartIoLibrary], + ), + ); + when(() => vmService.getIsolate(rohdIsolateId)).thenAnswer( + (_) async => vm.Isolate( + id: rohdIsolateId, + name: 'rohd application', + libraries: [dartIoLibrary, inspectorLibrary], + ), + ); + when( + () => vmService.evaluate( + rohdIsolateId, + inspectorLibrary.id!, + any(), + scope: any(named: 'scope'), + disableBreakpoints: any(named: 'disableBreakpoints'), + ), + ).thenAnswer( + (_) async => vm.Instance( + id: 'objects/hierarchy', + kind: vm.InstanceKind.kString, + valueAsString: hierarchyPayload, + ), + ); + when( + () => vmService.getObject( + rohdIsolateId, + 'objects/hierarchy', + offset: any(named: 'offset'), + count: any(named: 'count'), + ), + ).thenAnswer( + (_) async => vm.Instance( + id: 'objects/hierarchy', + kind: vm.InstanceKind.kString, + valueAsString: hierarchyPayload, + ), + ); + + final cubit = RohdServiceCubit(manageServiceManager: false); + addTearDown(cubit.close); + + await cubit.configureStandaloneVmService(vmService, rohdIsolateId); + await cubit.evalModuleTree(); + + expect(cubit.rohdIsolateId, rohdIsolateId); + expect(cubit.treeService, isNotNull); + expect(cubit.state, isA()); + expect((cubit.state as RohdServiceLoaded).treeModel?.name, 'top'); + verify(vmService.getVM).called(greaterThanOrEqualTo(2)); + verify(() => vmService.getIsolate(rohdIsolateId)) + .called(greaterThanOrEqualTo(1)); + }); + + test('returns a loaded null tree when no VM service is configured', () async { + final cubit = RohdServiceCubit(manageServiceManager: false); + addTearDown(cubit.close); + final emittedStates = cubit.stream.take(4).toList(); + + await cubit.evalModuleTree(); + await cubit.refreshModuleTree(); + + expect( + await emittedStates, + [ + isA(), + const RohdServiceLoaded(null), + isA(), + const RohdServiceLoaded(null), + ], + ); + expect(cubit.state, const RohdServiceLoaded(null)); + expect(cubit.treeService, isNull); + expect(cubit.signalValueSource, isNull); + expect(cubit.rohdIsolateId, isNull); + }); +} diff --git a/rohd_devtools_extension/test/cubit/snapshot_cubit_test.dart b/rohd_devtools_extension/test/cubit/snapshot_cubit_test.dart new file mode 100644 index 000000000..58f7d79d4 --- /dev/null +++ b/rohd_devtools_extension/test/cubit/snapshot_cubit_test.dart @@ -0,0 +1,171 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// snapshot_cubit_test.dart +// Tests for signal snapshot cubit behavior. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/snapshot_cubit.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/signal_value_source.dart'; + +class _FakeSignalValueSource implements SignalValueSource { + _FakeSignalValueSource(this.snapshot); + + final SignalSnapshotData? snapshot; + + @override + Stream? get updates => null; + + @override + Future getCurrentTime() async => null; + + @override + Future getSnapshot(int time) async => snapshot; +} + +class _StreamingSignalValueSource extends _FakeSignalValueSource { + _StreamingSignalValueSource(super.snapshot); + + final updatesController = StreamController(); + + @override + Stream get updates => updatesController.stream; + + Future dispose() => updatesController.close(); +} + +class _ThrowingSignalValueSource implements SignalValueSource { + @override + Stream? get updates => null; + + @override + Future getCurrentTime() async => null; + + @override + Future getSnapshot(int time) => + Future.error(StateError('source unavailable')); +} + +void main() { + group('SnapshotCubit', () { + test('maps source data into snapshots and supports both lookup methods', + () async { + final cubit = SnapshotCubit(); + addTearDown(cubit.close); + final source = _FakeSignalValueSource({ + 'top.counter': { + 'name': 'counter', + 'value': "8'h2a", + 'width': 8, + 'direction': 'output', + }, + 'top.internal': {}, + }); + + final takingSnapshot = cubit.takeSnapshot(source, 42); + expect(cubit.state, const SnapshotLoading(42)); + await takingSnapshot; + + final state = cubit.state as SnapshotLoaded; + expect(state.time, 42); + expect( + state.getSignal('top.counter'), + const SignalSnapshot( + signalId: 'top.counter', + name: 'counter', + value: "8'h2a", + width: 8, + direction: 'output', + ), + ); + expect(state.getSignalByName('counter'), + same(state.getSignal('top.counter'))); + expect( + state.getSignal('top.internal'), + const SignalSnapshot( + signalId: 'top.internal', + name: 'top.internal', + value: '?', + width: 1, + ), + ); + expect(state.getSignalByName('missing'), isNull); + }); + + test('reports an error when no snapshot data is available', () async { + final cubit = SnapshotCubit(); + addTearDown(cubit.close); + + await cubit.takeSnapshot(_FakeSignalValueSource(null), 42); + + expect(cubit.state, const SnapshotError('No snapshot data returned')); + }); + + test('reports a source failure as an error state', () async { + final cubit = SnapshotCubit(); + addTearDown(cubit.close); + + await cubit.takeSnapshot(_ThrowingSignalValueSource(), 42); + + expect( + cubit.state, + isA().having( + (state) => state.message, + 'message', + contains('source unavailable'), + ), + ); + }); + + test('takes a snapshot for each video update containing data', () async { + final cubit = SnapshotCubit(); + addTearDown(cubit.close); + final source = _StreamingSignalValueSource({ + 'top.counter': {'value': "8'h2a", 'width': 8}, + }); + addTearDown(source.dispose); + + cubit + ..setMode(SignalTrackingMode.video) + ..startVideoTracking(source); + source.updatesController + ..add( + const SignalValueUpdateEvent( + upToTime: 0, + hasData: true, + reason: 'initial', + ), + ) + ..add( + const SignalValueUpdateEvent( + upToTime: 17, + hasData: true, + reason: 'breakpoint', + ), + ); + + await Future.delayed(const Duration(milliseconds: 10)); + + final state = cubit.state as SnapshotLoaded; + expect(state.time, 17); + expect(state.getSignal('top.counter')!.width, 8); + }); + + test('clear returns to camera mode and its initial state', () async { + final cubit = SnapshotCubit(); + addTearDown(cubit.close); + + cubit + ..setMode(SignalTrackingMode.video) + ..clear(); + + expect(cubit.mode, SignalTrackingMode.camera); + expect(cubit.state, const SnapshotInitial()); + }); + }); +} diff --git a/rohd_devtools_extension/test/models/dtd_vm_service_info_test.dart b/rohd_devtools_extension/test/models/dtd_vm_service_info_test.dart new file mode 100644 index 000000000..232519f96 --- /dev/null +++ b/rohd_devtools_extension/test/models/dtd_vm_service_info_test.dart @@ -0,0 +1,50 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// dtd_vm_service_info_test.dart +// Tests for DTD VM service display and connection information. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/models/dtd_vm_service_info.dart'; + +void main() { + group('DtdVmServiceInfo', () { + test('uses the exposed URI for connection and display when available', () { + final service = DtdVmServiceInfo.fromFields( + uri: 'ws://localhost:8181', + exposedUri: 'ws://forwarded-host:8181', + name: 'counter demo', + autoReconnect: true, + ); + + expect(service.uri, 'ws://localhost:8181'); + expect(service.exposedUri, 'ws://forwarded-host:8181'); + expect(service.connectionUri, 'ws://forwarded-host:8181'); + expect(service.name, 'counter demo'); + expect(service.isAlive, isTrue); + expect(service.autoReconnect, isTrue); + expect(service.displayLabel, 'counter demo — ws://forwarded-host:8181'); + }); + + test( + 'uses the service URI and default name when optional fields are absent', + () { + final service = DtdVmServiceInfo.fromFields(uri: 'ws://localhost:8181'); + + expect(service.connectionUri, 'ws://localhost:8181'); + expect(service.displayLabel, 'VM Service — ws://localhost:8181'); + expect(service.autoReconnect, isFalse); + }); + + test('truncates long connection URIs in display labels only', () { + final uri = 'ws://${'a' * 60}'; + final service = DtdVmServiceInfo.fromFields(uri: uri, name: 'long VM'); + + expect(service.connectionUri, uri); + expect(service.displayLabel, 'long VM — ${uri.substring(0, 50)}…'); + }); + }); +} diff --git a/rohd_devtools_extension/test/models/signal_tree_model_test.dart b/rohd_devtools_extension/test/models/signal_tree_model_test.dart new file mode 100644 index 000000000..bf67cd2fb --- /dev/null +++ b/rohd_devtools_extension/test/models/signal_tree_model_test.dart @@ -0,0 +1,83 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_tree_model_test.dart +// Tests for signal and module-tree data models. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/models/signal_model.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/models/tree_model.dart'; + +void main() { + group('SignalModel', () { + test('round trips through a map', () { + final signal = SignalModel( + name: 'accumulator', + direction: 'Output', + value: "8'h2a", + width: 8, + ); + + final restored = SignalModel.fromMap(signal.toMap()); + + expect(restored.name, signal.name); + expect(restored.direction, signal.direction); + expect(restored.value, signal.value); + expect(restored.width, signal.width); + }); + }); + + group('TreeModel.fromJson', () { + test('parses signals by direction and recursively parses submodules', () { + final tree = TreeModel.fromJson({ + 'name': 'top', + 'inputs': { + 'clock': {'value': "1'h0", 'width': 1}, + }, + 'outputs': { + 'result': {'value': "8'h2a", 'width': 8}, + }, + 'inouts': { + 'bus': {'value': "4'hf", 'width': 4}, + }, + 'subModules': [ + { + 'name': 'child', + 'inputs': {}, + 'outputs': {}, + 'subModules': >[], + }, + ], + }); + + expect(tree.name, 'top'); + expect( + tree.inputs.single.toMap(), + {'name': 'clock', 'direction': 'Input', 'value': "1'h0", 'width': 1}, + ); + expect( + tree.outputs.single.toMap(), + {'name': 'result', 'direction': 'Output', 'value': "8'h2a", 'width': 8}, + ); + expect( + tree.inouts.single.toMap(), + {'name': 'bus', 'direction': 'Inout', 'value': "4'hf", 'width': 4}, + ); + expect(tree.subModules.single.name, 'child'); + }); + + test('uses no inout signals when the JSON omits them', () { + final tree = TreeModel.fromJson({ + 'name': 'leaf', + 'inputs': {}, + 'outputs': {}, + 'subModules': >[], + }); + + expect(tree.inouts, isEmpty); + }); + }); +} diff --git a/rohd_devtools_extension/test/modules/tree_structure/model_tree_card_test.dart b/rohd_devtools_extension/test/modules/tree_structure/model_tree_card_test.dart index eb99a9f89..85aa22124 100644 --- a/rohd_devtools_extension/test/modules/tree_structure/model_tree_card_test.dart +++ b/rohd_devtools_extension/test/modules/tree_structure/model_tree_card_test.dart @@ -6,7 +6,6 @@ // // 2024 January 9 // Author: Yao Jing Quek -library; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; diff --git a/rohd_devtools_extension/test/modules/tree_structure/tree_structure_page_test.dart b/rohd_devtools_extension/test/modules/tree_structure/tree_structure_page_test.dart index b406d534f..df8936c46 100644 --- a/rohd_devtools_extension/test/modules/tree_structure/tree_structure_page_test.dart +++ b/rohd_devtools_extension/test/modules/tree_structure/tree_structure_page_test.dart @@ -7,8 +7,6 @@ // 2024 January 9 // Author: Yao Jing Quek -library; - import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/rohd_devtools_extension/test/services/connection_state_machine_test.dart b/rohd_devtools_extension/test/services/connection_state_machine_test.dart new file mode 100644 index 000000000..15653fca2 --- /dev/null +++ b/rohd_devtools_extension/test/services/connection_state_machine_test.dart @@ -0,0 +1,202 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// connection_state_machine_test.dart +// Tests for VM connection lifecycle state transitions. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/connection_state_machine.dart'; +import 'package:vm_service/vm_service.dart'; + +class _MockVmService extends Mock implements VmService {} + +void main() { + group('DataLoadState', () { + test('copies independently and resets every flag', () { + final state = DataLoadState( + hierarchyLoaded: true, + schematicLoaded: true, + waveformDataLoaded: true, + hierarchyAttempted: true, + ); + + final copy = state.copy(); + state.reset(); + + expect(state.isEmpty, isTrue); + expect(state.hierarchyAttempted, isFalse); + expect(copy.isFullyLoaded, isTrue); + expect(copy.schematicLoaded, isTrue); + expect(copy.waveformDataLoaded, isTrue); + expect(copy.hierarchyAttempted, isTrue); + }); + }); + + group('VmIdentity', () { + test('identifies processes by isolate ID rather than service URI', () { + const first = VmIdentity(uri: 'ws://localhost:8181', isolateId: 'iso-1'); + const sameProcess = VmIdentity( + uri: 'ws://localhost:9191', + isolateId: 'iso-1', + ); + const otherProcess = VmIdentity( + uri: 'ws://localhost:8181', + isolateId: 'iso-2', + ); + + expect(first.isSameProcess(sameProcess), isTrue); + expect(first.isSameProcess(otherProcess), isFalse); + }); + }); + + group('ConnectionStateMachine', () { + test('moves from a connection request back to disconnected on failure', () { + final machine = ConnectionStateMachine(); + addTearDown(machine.dispose); + + machine.handleEvent(const ConnectRequested('ws://localhost:8181')); + expect(machine.phase, ConnectionPhase.connecting); + + machine.handleEvent(const ConnectionFailed('connection refused')); + expect(machine.phase, ConnectionPhase.disconnected); + expect(machine.dataState.isEmpty, isTrue); + }); + + test('records hierarchy load results', () { + final machine = ConnectionStateMachine(); + addTearDown(machine.dispose); + + machine.handleEvent(const HierarchyLoadResult(success: false)); + expect(machine.dataState.hierarchyAttempted, isTrue); + expect(machine.dataState.hierarchyLoaded, isFalse); + expect(machine.dataState.isFullyLoaded, isFalse); + + machine.handleEvent(const HierarchyLoadResult(success: true)); + expect(machine.dataState.isFullyLoaded, isTrue); + }); + + test('preserves data when resuming the same VM process', () { + final machine = ConnectionStateMachine(); + addTearDown(machine.dispose); + final service = _MockVmService(); + const identity = + VmIdentity(uri: 'ws://localhost:8181', isolateId: 'iso-1'); + + machine + ..handleEvent(ConnectionEstablished(service, identity)) + ..handleEvent(const HierarchyLoadResult(success: true)) + ..markSchematicLoaded() + ..markWaveformDataLoaded() + ..handleEvent(const PauseRequested()); + + expect(machine.phase, ConnectionPhase.paused); + expect(machine.dataState.isFullyLoaded, isTrue); + expect(machine.dataState.schematicLoaded, isTrue); + expect(machine.dataState.waveformDataLoaded, isTrue); + + machine + ..handleEvent(const ResumeRequested()) + ..handleEvent(ConnectionEstablished(service, identity)); + + expect(machine.phase, ConnectionPhase.connected); + expect(machine.shouldSkipHierarchyReload(identity), isTrue); + expect(machine.dataState.isFullyLoaded, isTrue); + expect(machine.dataState.schematicLoaded, isTrue); + expect(machine.dataState.waveformDataLoaded, isTrue); + }); + + test('clears cached data when the user disconnects', () { + final machine = ConnectionStateMachine(); + addTearDown(machine.dispose); + final service = _MockVmService(); + const identity = + VmIdentity(uri: 'ws://localhost:8181', isolateId: 'iso-1'); + + machine + ..handleEvent(ConnectionEstablished(service, identity)) + ..handleEvent(const HierarchyLoadResult(success: true)) + ..markSchematicLoaded() + ..markWaveformDataLoaded() + ..handleEvent(const DisconnectRequested()); + + expect(machine.phase, ConnectionPhase.disconnected); + expect(machine.currentIdentity, isNull); + expect(machine.lastIdentity, identity); + expect(machine.dataState.isEmpty, isTrue); + expect(machine.shouldSkipHierarchyReload(identity), isFalse); + }); + + test('preserves cached data across VM death and recovery', () { + final machine = ConnectionStateMachine(); + addTearDown(machine.dispose); + final service = _MockVmService(); + const identity = + VmIdentity(uri: 'ws://localhost:8181', isolateId: 'iso-1'); + + machine + ..handleEvent(ConnectionEstablished(service, identity)) + ..handleEvent(const HierarchyLoadResult(success: true)) + ..markSchematicLoaded() + ..handleEvent(const VmDied()); + + expect(machine.phase, ConnectionPhase.vmDead); + expect(machine.lastIdentity, identity); + expect(machine.dataState.isFullyLoaded, isTrue); + expect(machine.dataState.schematicLoaded, isTrue); + + machine.handleEvent(const VmRecovered()); + + expect(machine.phase, ConnectionPhase.connected); + expect(machine.dataState.isFullyLoaded, isTrue); + }); + + test('treats DTD unregistration as VM death', () { + final machine = ConnectionStateMachine(); + addTearDown(machine.dispose); + + machine.handleEvent(const DtdVmUnregistered()); + + expect(machine.phase, ConnectionPhase.vmDead); + }); + + test('debounces hierarchy loads requested by debug pauses', () async { + final machine = ConnectionStateMachine(); + addTearDown(machine.dispose); + var loads = 0; + machine + ..onLoadHierarchy = () async { + loads++; + } + ..handleEvent( + ConnectionEstablished( + _MockVmService(), + const VmIdentity(uri: 'ws://localhost:8181', isolateId: 'iso-1'), + ), + ) + ..handleEvent(const DebugPauseReceived('PauseBreakpoint')) + ..handleEvent(const DebugPauseReceived('PauseException')); + + await Future.delayed(const Duration(milliseconds: 250)); + + expect(loads, 1); + }); + + test('enters demo mode with its hierarchy and schematic data available', + () { + final machine = ConnectionStateMachine(); + addTearDown(machine.dispose); + + machine.handleEvent(const DemoModeEntered()); + + expect(machine.phase, ConnectionPhase.connected); + expect(machine.currentIdentity, isNull); + expect(machine.dataState.isFullyLoaded, isTrue); + expect(machine.dataState.schematicLoaded, isTrue); + expect(machine.canLoadData, isFalse); + }); + }); +} diff --git a/rohd_devtools_extension/test/services/platform_vm_connection_strategy_test.dart b/rohd_devtools_extension/test/services/platform_vm_connection_strategy_test.dart new file mode 100644 index 000000000..fe023cb5e --- /dev/null +++ b/rohd_devtools_extension/test/services/platform_vm_connection_strategy_test.dart @@ -0,0 +1,103 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// platform_vm_connection_strategy_test.dart +// Tests for platform VM connection strategy dispatch and native validation. +// +// 2026 July +// Author: Desmond Kirkpatrick + +@TestOn('vm') +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/io_vm_connection_strategy.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/platform_vm_connection_strategy.dart'; +import 'package:vm_service/vm_service.dart'; + +class _MockVmService extends Mock implements VmService {} + +void main() { + test('platform strategy resolves to the native IO implementation on VM', () { + expect(createPlatformVmConnectionStrategy(), isA()); + expect(platformVmConnectionStrategy(), isA()); + }); + + test('IO strategy rejects invalid URI syntax before connecting', () async { + final strategy = IoVmConnectionStrategy(); + + await expectLater( + strategy.connect('http://[invalid'), + throwsA(isA()), + ); + }); + + test('IO strategy selects the isolate containing the ROHD inspector service', + () async { + final vmService = _MockVmService(); + when(vmService.getVM).thenAnswer( + (_) async => VM( + isolates: [ + IsolateRef(id: 'isolate-helper'), + IsolateRef(id: 'isolate-rohd'), + ], + ), + ); + when(() => vmService.getIsolate('isolate-helper')).thenAnswer( + (_) async => Isolate( + id: 'isolate-helper', + libraries: [ + LibraryRef(id: 'lib-helper', uri: 'package:other/main.dart') + ], + ), + ); + when(() => vmService.getIsolate('isolate-rohd')).thenAnswer( + (_) async => Isolate( + id: 'isolate-rohd', + libraries: [ + LibraryRef( + id: 'lib-rohd', + uri: 'package:rohd/inspector_service.dart', + ), + ], + ), + ); + + final strategy = IoVmConnectionStrategy( + connectUri: (uri, log) async { + expect(uri, 'ws://host:8181/app=/ws'); + return vmService; + }, + retryDelay: Duration.zero, + ); + + final result = await strategy.connect('http://host:8181/app=/'); + + expect(result.vmService, vmService); + expect(result.isolateId, 'isolate-rohd'); + }); + + test('IO strategy falls back to the first isolate when ROHD is not found', + () async { + final vmService = _MockVmService(); + when(vmService.getVM).thenAnswer( + (_) async => VM(isolates: [IsolateRef(id: 'isolate-main')]), + ); + when(() => vmService.getIsolate('isolate-main')).thenAnswer( + (_) async => Isolate( + id: 'isolate-main', + libraries: [LibraryRef(id: 'lib-main', uri: 'package:app/main.dart')], + ), + ); + + final strategy = IoVmConnectionStrategy( + connectUri: (uri, log) async => vmService, + retryDelay: Duration.zero, + ); + + final result = await strategy.connect('ws://host:8181/app=/ws'); + + expect(result.isolateId, 'isolate-main'); + }); +} diff --git a/rohd_devtools_extension/test/services/service_manager_bridge_io_test.dart b/rohd_devtools_extension/test/services/service_manager_bridge_io_test.dart new file mode 100644 index 000000000..e8879129d --- /dev/null +++ b/rohd_devtools_extension/test/services/service_manager_bridge_io_test.dart @@ -0,0 +1,20 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// service_manager_bridge_io_test.dart +// Tests for the native ServiceManager bridge. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:devtools_app_shared/service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/service_manager_bridge_io.dart'; +import 'package:vm_service/vm_service.dart' as vm; + +void main() { + test('exposes an app-local VM service manager', () { + expect(serviceManager, isA>()); + expect(serviceManager.connectedState.value.connected, isFalse); + }); +} diff --git a/rohd_devtools_extension/test/services/signal_service_test.dart b/rohd_devtools_extension/test/services/signal_service_test.dart new file mode 100644 index 000000000..25ebb8687 --- /dev/null +++ b/rohd_devtools_extension/test/services/signal_service_test.dart @@ -0,0 +1,51 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_service_test.dart +// Tests for signal filtering service behavior. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/models/signal_model.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/signal_service.dart'; + +void main() { + final signals = [ + SignalModel( + name: 'ClockEnable', + direction: 'Input', + value: "1'h1", + width: 1, + ), + SignalModel( + name: 'counterValue', + direction: 'Output', + value: "8'h2a", + width: 8, + ), + SignalModel( + name: 'reset', + direction: 'Input', + value: "1'h0", + width: 1, + ), + ]; + + group('SignalService.filterSignals', () { + test('matches names case insensitively and retains source order', () { + final filtered = SignalService.filterSignals(signals, 'C'); + + expect(filtered, [signals[0], signals[1]]); + }); + + test('returns all signals for an empty search term', () { + expect(SignalService.filterSignals(signals, ''), signals); + }); + + test('returns no signals when no name matches', () { + expect(SignalService.filterSignals(signals, 'missing'), isEmpty); + }); + }); +} diff --git a/rohd_devtools_extension/test/services/vm_service_signal_value_source_test.dart b/rohd_devtools_extension/test/services/vm_service_signal_value_source_test.dart new file mode 100644 index 000000000..0f9c08039 --- /dev/null +++ b/rohd_devtools_extension/test/services/vm_service_signal_value_source_test.dart @@ -0,0 +1,108 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// vm_service_signal_value_source_test.dart +// Tests for VM service signal value source behavior. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:devtools_app_shared/service.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/vm_service_signal_value_source.dart'; +import 'package:vm_service/vm_service.dart' as vm; + +class _MockVmService extends Mock implements vm.VmService {} + +void main() { + late _MockVmService vmService; + late StreamController debugEvents; + late VmServiceSignalValueSource source; + + setUp(() { + vmService = _MockVmService(); + debugEvents = StreamController.broadcast(); + when(() => vmService.onDebugEvent).thenAnswer((_) => debugEvents.stream); + source = VmServiceSignalValueSource( + rohdControllerEval: EvalOnDartLibrary( + 'test', + vmService, + serviceManager: ServiceManager(), + ), + evalDisposable: Disposable(), + vmService: vmService, + ); + }); + + tearDown(() async { + await source.dispose(); + await debugEvents.close(); + }); + + test('reads and remembers a positive current time from the extension', + () async { + when( + () => vmService.callServiceExtension( + 'ext.rohd.currentTime', + args: any(named: 'args'), + ), + ).thenAnswer((_) async => vm.Response()..json = {'currentTime': 42}); + + expect(await source.getCurrentTime(), 42); + }); + + test('maps compact extension snapshots into signal data', () async { + when( + () => vmService.callServiceExtension( + 'ext.rohd.snapshotCompact', + args: any(named: 'args'), + ), + ).thenAnswer( + (_) async => vm.Response() + ..json = { + 'signals': { + 'top.counter': { + 'name': 'counter', + 'value': "8'h2a", + 'width': 8, + }, + }, + }, + ); + + expect( + await source.getSnapshot(17), + { + 'top.counter': { + 'name': 'counter', + 'value': "8'h2a", + 'width': 8, + }, + }, + ); + verify( + () => vmService.callServiceExtension( + 'ext.rohd.snapshotCompact', + args: {'time': '17'}, + ), + ).called(1); + }); + + test('emits updates for pause events and ignores unrelated debug events', + () async { + final update = source.updates.first; + + debugEvents + ..add(vm.Event(kind: 'Resume')) + ..add(vm.Event(kind: vm.EventKind.kPauseBreakpoint)); + + final event = await update; + expect(event.upToTime, 1); + expect(event.hasData, isTrue); + expect(event.reason, vm.EventKind.kPauseBreakpoint); + }); +} diff --git a/rohd_devtools_extension/test/ui/devtool_appbar_test.dart b/rohd_devtools_extension/test/ui/devtool_appbar_test.dart new file mode 100644 index 000000000..e73fe2f61 --- /dev/null +++ b/rohd_devtools_extension/test/ui/devtool_appbar_test.dart @@ -0,0 +1,58 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// devtool_appbar_test.dart +// Tests for ROHD DevTools app bar and help controls. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/theme_cubit.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/devtool_appbar.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/devtools_help_button.dart'; +import 'package:rohd_devtools_widgets/rohd_devtools_widgets.dart'; + +void main() { + testWidgets('renders persistent app bar controls and toggles the theme cubit', + (tester) async { + final cubit = DevToolsThemeCubit(); + addTearDown(cubit.close); + + await tester.pumpWidget( + BlocProvider.value( + value: cubit, + child: const MaterialApp( + home: Scaffold(appBar: DevtoolAppBar(hasColorEmoji: false)), + ), + ), + ); + + expect(find.text('ROHD DevTool (Beta)'), findsOneWidget); + expect(find.text('Licenses'), findsOneWidget); + expect(find.byType(DevToolsHelpButton), findsOneWidget); + expect(find.byTooltip('Switch to light theme'), findsOneWidget); + expect(const DevtoolAppBar().preferredSize.height, kToolbarHeight); + + await tester.tap(find.byTooltip('Switch to light theme')); + await tester.pump(); + + expect(cubit.state, DevToolsThemeMode.light); + expect(find.byTooltip('Switch to dark theme'), findsOneWidget); + }); + + testWidgets('passes DevTools help configuration to the markdown help control', + (tester) async { + await tester.pumpWidget( + const MaterialApp(home: DevToolsHelpButton(isDark: true)), + ); + + final button = tester.widget( + find.byType(MarkdownHelpButton), + ); + expect(button.assetPath, 'assets/help/devtools_help.md'); + expect(button.isDark, isTrue); + }); +} diff --git a/rohd_devtools_extension/test/ui/devtools_connection_host_lifecycle_test.dart b/rohd_devtools_extension/test/ui/devtools_connection_host_lifecycle_test.dart new file mode 100644 index 000000000..066efd90b --- /dev/null +++ b/rohd_devtools_extension/test/ui/devtools_connection_host_lifecycle_test.dart @@ -0,0 +1,309 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// devtools_connection_host_lifecycle_test.dart +// Tests for the base DevTools connection host lifecycle. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/models/dtd_vm_service_info.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/services/connection_state_machine.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/devtools_connection_host.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/vm_connection_form.dart'; +import 'package:vm_service/vm_service.dart'; + +class _MockVmService extends Mock implements VmService {} + +class _TestConnectionStrategy extends VmConnectionStrategy { + _TestConnectionStrategy(this.results); + + final List results; + final connectedUris = []; + + @override + Future connect(String uri) async { + connectedUris.add(uri); + return results.removeAt(0); + } +} + +class _TestConnectionHost extends StatefulWidget { + const _TestConnectionHost({ + required this.strategy, + this.discoverVmServices, + }); + + final VmConnectionStrategy? strategy; + final Future> Function(String uri)? + discoverVmServices; + + @override + State<_TestConnectionHost> createState() => _TestConnectionHostState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add( + DiagnosticsProperty('strategy', strategy), + ) + ..add( + ObjectFlagProperty> Function(String)?>( + 'discoverVmServices', + discoverVmServices, + ifNull: 'default', + ), + ); + } +} + +class _TestConnectionHostState + extends DevToolsConnectionHostState<_TestConnectionHost> { + final lifecycleCalls = []; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + IterableProperty('lifecycleCalls', lifecycleCalls), + ); + } + + @override + VmConnectionStrategy? get connectionStrategy => widget.strategy; + + @override + Future> discoverVmServices(String dtdUri) async => + widget.discoverVmServices?.call(dtdUri) ?? + super.discoverVmServices(dtdUri); + + @override + Future startDtdListener() async {} + + List get _rememberedServicesForTest => + rememberedServices ?? const []; + + set _rememberedServicesForTest(List services) { + rememberedServices = services; + } + + @override + Widget build(BuildContext context) => Text( + isConnected ? 'connected' : 'disconnected', + ); + + @override + Future onVmConnected(VmConnectionResult result, String uri) async { + lifecycleCalls.add('connected:$uri'); + } + + @override + Future onBeforeVmConnected( + VmConnectionResult result, + String uri, { + required VmConnectionTransition transition, + }) async { + lifecycleCalls.add('before:${transition.kind.name}'); + } + + @override + Future tearDownOldConnection({ + required VmConnectionTransition transition, + }) async { + lifecycleCalls.add('tearDown:${transition.kind.name}'); + } + + @override + void onVmDisconnected() { + lifecycleCalls.add('disconnected'); + } + + @override + Future onVmPaused() async { + lifecycleCalls.add('paused'); + } + + @override + Future onVmResumed() async { + lifecycleCalls.add('resumed'); + } + + @override + Future onLightweightReconnectSuccess( + VmConnectionResult result, + String uri, + ) async { + lifecycleCalls.add('lightweight:$uri'); + } +} + +void main() { + _MockVmService newVmService() { + final vmService = _MockVmService(); + when(() => vmService.onDebugEvent) + .thenAnswer((_) => const Stream.empty()); + when(vmService.dispose).thenAnswer((_) async {}); + return vmService; + } + + Future<_TestConnectionHostState> pumpHost( + WidgetTester tester, { + required VmConnectionStrategy? strategy, + Future> Function(String uri)? discoverVmServices, + }) async { + await tester.pumpWidget( + MaterialApp( + home: _TestConnectionHost( + strategy: strategy, + discoverVmServices: discoverVmServices, + ), + ), + ); + return tester.state<_TestConnectionHostState>( + find.byType(_TestConnectionHost), + ); + } + + testWidgets('connects, pauses, resumes, and disconnects through hooks', + (tester) async { + final vmService = newVmService(); + final strategy = _TestConnectionStrategy( + [VmConnectionResult(vmService: vmService, isolateId: 'isolate-1')], + ); + final state = await pumpHost(tester, strategy: strategy); + + await state.connectToVmService('ws://host:8181/app=/ws'); + await tester.pump(); + + expect(strategy.connectedUris, ['ws://host:8181/app=/ws']); + expect(state.isConnected, isTrue); + expect(state.isVmConnected, isTrue); + expect(state.lastIsolateId, 'isolate-1'); + expect(state.connectionStateMachine.phase, ConnectionPhase.connected); + expect( + state.lifecycleCalls, + [ + 'tearDown:freshAttach', + 'before:freshAttach', + 'connected:ws://host:8181/app=/ws' + ], + ); + + await state.pauseVm(); + expect(state.isPaused, isTrue); + expect(state.connectionStateMachine.phase, ConnectionPhase.paused); + + await state.resumeVm(); + expect(state.isPaused, isFalse); + expect(state.connectionStateMachine.phase, ConnectionPhase.connecting); + + await state.disconnect(); + await tester.pump(); + + expect(state.isConnected, isFalse); + expect(state.isVmConnected, isFalse); + expect(state.lastVmServiceUri, isNull); + expect(state.connectionStateMachine.phase, ConnectionPhase.disconnected); + expect( + state.lifecycleCalls, + [ + 'tearDown:freshAttach', + 'before:freshAttach', + 'connected:ws://host:8181/app=/ws', + 'paused', + 'resumed', + 'tearDown:disconnect', + 'disconnected', + ], + ); + }); + + testWidgets('reports a missing connection strategy without connecting', + (tester) async { + final state = await pumpHost(tester, strategy: null); + + await state.attemptConnection(); + await tester.pump(); + + expect( + state.connectionError, 'VM connection not available on this platform'); + expect(state.isConnected, isFalse); + }); + + testWidgets('connects through DTD discovery and retains reconnect details', + (tester) async { + final strategy = _TestConnectionStrategy([ + VmConnectionResult(vmService: newVmService(), isolateId: 'isolate-1'), + ]); + final state = await pumpHost( + tester, + strategy: strategy, + discoverVmServices: (dtdUri) async { + expect(dtdUri, 'ws://host:8181/dtd='); + return [ + DiscoveredVmService( + name: 'counter', + uri: 'ws://internal:8181/app=/ws', + exposedUri: 'ws://forwarded:8181/app=/ws', + ), + ]; + }, + ); + state.vmServiceUriController.clear(); + state.dtdUriController.text = ' ws://host:8181/dtd= '; + state._rememberedServicesForTest = [ + DtdVmServiceInfo.fromFields( + name: 'counter', + uri: 'ws://internal:8181/app=/ws', + exposedUri: 'ws://forwarded:8181/app=/ws', + autoReconnect: true, + ), + ]; + expect(state._rememberedServicesForTest.single.autoReconnect, isTrue); + + await state.attemptConnection(); + await tester.pump(); + + expect(strategy.connectedUris, ['ws://forwarded:8181/app=/ws']); + expect(state.vmServiceUriController.text, isEmpty); + expect(state.dtdUriController.text, 'ws://host:8181/dtd='); + expect(state.connectedVmName, 'counter'); + expect(state.autoReconnect, isTrue); + expect(state.isConnected, isTrue); + }); + + testWidgets('uses lightweight reconnect only when the isolate matches', + (tester) async { + final firstVmService = newVmService(); + final matchingVmService = newVmService(); + final mismatchingVmService = newVmService(); + final strategy = _TestConnectionStrategy([ + VmConnectionResult(vmService: firstVmService, isolateId: 'isolate-1'), + VmConnectionResult( + vmService: matchingVmService, + isolateId: 'isolate-1', + ), + VmConnectionResult( + vmService: mismatchingVmService, + isolateId: 'isolate-2', + ), + ]); + final state = await pumpHost(tester, strategy: strategy); + await state.connectToVmService('ws://host:8181/app=/ws'); + + expect(await state.lightweightReconnect('ws://host:8181/app=/ws'), isTrue); + expect(state.vmService, matchingVmService); + expect(state.lifecycleCalls.last, 'lightweight:ws://host:8181/app=/ws'); + + expect(await state.lightweightReconnect('ws://host:8181/app=/ws'), isFalse); + verify(mismatchingVmService.dispose).called(1); + expect(state.vmService, matchingVmService); + }); +} diff --git a/rohd_devtools_extension/test/ui/devtools_connection_host_test.dart b/rohd_devtools_extension/test/ui/devtools_connection_host_test.dart new file mode 100644 index 000000000..df4bfed51 --- /dev/null +++ b/rohd_devtools_extension/test/ui/devtools_connection_host_test.dart @@ -0,0 +1,189 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// devtools_connection_host_test.dart +// Tests for DevTools connection URI and DTD resolution behavior. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/devtools_connection_host.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/vm_connection_form.dart'; + +class _UriNormalizingStrategy extends VmConnectionStrategy { + @override + Future connect(String uri) async => + throw UnimplementedError(); +} + +void main() { + group('DevToolsConnectionHostState URI cleaning', () { + test('extracts a VM service URI from surrounding console output', () { + expect( + DevToolsConnectionHostState.cleanVmServiceUri( + 'Observatory listening at ws://127.0.0.1:8181/abc=/ws trailing text', + ), + 'ws://127.0.0.1:8181/abc=/ws', + ); + }); + + test('keeps a VM URI without the standard suffix from its websocket start', + () { + expect( + DevToolsConnectionHostState.cleanVmServiceUri( + 'open wss://host:8181/custom', + ), + 'wss://host:8181/custom', + ); + }); + + test('extracts DTD URI without stopping at the VM websocket suffix', () { + expect( + DevToolsConnectionHostState.cleanDtdUri( + 'ws://host:8181/vm=/ws then ws://host:8181/dtd=', + ), + 'ws://host:8181/vm=/ws then ws://host:8181/dtd=', + ); + }); + }); + + group('resolveVmConnectionAttempt', () { + test('uses a valid manually entered VM URI without discovery', () async { + var discoverCalled = false; + + final resolution = await resolveVmConnectionAttempt( + rawVmServiceUri: ' ws://host:8181/app=/ws ', + rawDtdUri: '', + discoverVmServices: (uri) async { + discoverCalled = true; + return const []; + }, + ); + + expect(resolution.vmServiceUri, 'ws://host:8181/app=/ws'); + expect(resolution.error, isNull); + expect(discoverCalled, isFalse); + }); + + test('discovers and selects the exposed URI of the first VM', () async { + final resolution = await resolveVmConnectionAttempt( + rawVmServiceUri: '', + rawDtdUri: ' ws://host:8181/dtd= ', + discoverVmServices: (uri) async { + expect(uri, 'ws://host:8181/dtd='); + return [ + DiscoveredVmService( + name: 'counter', + uri: 'ws://internal:8181/app=/ws', + exposedUri: 'ws://forwarded:8181/app=/ws', + ), + ]; + }, + ); + + expect(resolution.vmServiceUri, 'ws://forwarded:8181/app=/ws'); + expect(resolution.error, isNull); + }); + + test('returns a validation error when neither URI is usable', () async { + final resolution = await resolveVmConnectionAttempt( + rawVmServiceUri: 'ws://host:8181/xxxx=/ws', + rawDtdUri: 'not a websocket URI', + discoverVmServices: (uri) async => const [], + ); + + expect(resolution.vmServiceUri, isNull); + expect(resolution.error, 'Please enter a VM Service URI or DTD URI'); + }); + + test('returns a discovery error when DTD has no VM services', () async { + final resolution = await resolveVmConnectionAttempt( + rawVmServiceUri: '', + rawDtdUri: 'ws://host:8181/dtd=', + discoverVmServices: (uri) async => const [], + ); + + expect(resolution.vmServiceUri, isNull); + expect( + resolution.error, + 'No VM services found via DTD. Is your ROHD app running?', + ); + }); + }); + + group('dtdEventMatchesTrackedVm', () { + test('matches direct and exposed VM service URIs', () { + expect( + dtdEventMatchesTrackedVm( + trackedVmUri: 'ws://forwarded:8181/app=/ws', + eventUri: 'ws://internal:8181/app=/ws', + eventExposedUri: 'ws://forwarded:8181/app=/ws', + ), + isTrue, + ); + expect( + dtdEventMatchesTrackedVm( + trackedVmUri: 'ws://one:8181/app=/ws', + eventUri: 'ws://two:8181/app=/ws', + eventExposedUri: null, + ), + isFalse, + ); + }); + }); + + group('VmConnectionStrategy.normalizeUri', () { + final strategy = _UriNormalizingStrategy(); + + test('converts HTTP URIs to websocket URIs and appends the websocket path', + () { + expect( + strategy.normalizeUri('http://host:8181/debug/'), + Uri.parse('ws://host:8181/debug/ws'), + ); + expect( + strategy.normalizeUri('https://host:8181'), + Uri.parse('wss://host:8181/ws'), + ); + }); + + test('retains an existing websocket path and rejects invalid URIs', () { + expect( + strategy.normalizeUri('ws://host:8181/app=/ws'), + Uri.parse('ws://host:8181/app=/ws'), + ); + expect(strategy.normalizeUri('http://[invalid'), isNull); + }); + }); + + group('VmConnectionTransition', () { + test('classifies state preservation for each connection kind', () { + const fresh = VmConnectionTransition.fresh(); + const restart = VmConnectionTransition.sameVmRestart(); + const disconnect = VmConnectionTransition.disconnect(); + + expect(fresh.preservesAppState, isFalse); + expect(fresh.isSameLogicalVm, isFalse); + expect(restart.preservesAppState, isTrue); + expect(restart.isSameLogicalVm, isTrue); + expect(disconnect.preservesAppState, isFalse); + expect(disconnect.kind, VmConnectionTransitionKind.disconnect); + }); + + test('retains captured prior identity when values are not replaced', () { + final transition = + (const VmConnectionTransition.sameVmRestart()).withPrevious( + previousUri: 'ws://host:8181/app=/ws', + previousIsolateId: 'isolate-1', + previousVmName: 'counter', + ); + + final copied = transition.withPrevious(previousVmName: 'counter-next'); + + expect(copied.previousUri, 'ws://host:8181/app=/ws'); + expect(copied.previousIsolateId, 'isolate-1'); + expect(copied.previousVmName, 'counter-next'); + }); + }); +} diff --git a/rohd_devtools_extension/test/ui/diagnostic_properties_test.dart b/rohd_devtools_extension/test/ui/diagnostic_properties_test.dart new file mode 100644 index 000000000..d6a6bdc2e --- /dev/null +++ b/rohd_devtools_extension/test/ui/diagnostic_properties_test.dart @@ -0,0 +1,69 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// diagnostic_properties_test.dart +// Tests diagnostic properties exposed by public DevTools UI objects. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/snapshot_cubit.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/models/tree_model.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/ui.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/view/tree_structure_page.dart'; + +void main() { + test('public diagnostic objects expose at least one property', () { + final vmServiceUriController = TextEditingController(); + final dtdUriController = TextEditingController(); + addTearDown(vmServiceUriController.dispose); + addTearDown(dtdUriController.dispose); + final module = TreeModel( + name: 'top', + inputs: const [], + outputs: const [], + subModules: const [], + ); + final diagnosticObjects = [ + const DetailsHelpButton(isDark: true), + const DevtoolAppBar(), + const DevToolsHelpButton(isDark: true), + ModuleTreeCard(futureModuleTree: module), + const ModuleTreeDetailsNavbar(), + const PlatformIcon(Icons.waves, 'wave'), + const SchematicIcon(), + SignalDetailsCard( + module: module, + snapshot: const SnapshotLoaded(time: 0, signals: {}), + ), + SignalTable( + selectedModule: module, + searchTerm: '', + inputSelectedVal: true, + outputSelectedVal: true, + inoutSelectedVal: true, + ), + SignalTableTextField(labelText: 'Signals', onChanged: (_) {}), + VmConnectionForm( + vmServiceUriController: vmServiceUriController, + dtdUriController: dtdUriController, + onConnect: () {}, + cleanVmServiceUri: (value) => value, + cleanDtdUri: (value) => value, + ), + DiscoveredVmService(uri: 'ws://host:8181/app=/ws'), + TreeStructurePage(screenSize: Size.zero), + ]; + + for (final object in diagnosticObjects) { + expect( + object.toDiagnosticsNode().getProperties(), + isNotEmpty, + reason: '${object.runtimeType} has no diagnostic properties', + ); + } + }); +} diff --git a/rohd_devtools_extension/test/ui/dtd_discovery_test.dart b/rohd_devtools_extension/test/ui/dtd_discovery_test.dart new file mode 100644 index 000000000..3d4846926 --- /dev/null +++ b/rohd_devtools_extension/test/ui/dtd_discovery_test.dart @@ -0,0 +1,117 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// dtd_discovery_test.dart +// Tests for DTD-backed VM service discovery. +// +// 2026 July +// Author: Desmond Kirkpatrick + +@TestOn('vm') +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/devtools_connection_host.dart'; + +class _DtdDiscoveryStub { + _DtdDiscoveryStub._(this._server); + + final HttpServer _server; + final requestedMethods = []; + final connections = []; + + static Future<_DtdDiscoveryStub> start() async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final stub = _DtdDiscoveryStub._(server); + unawaited(stub._listen()); + return stub; + } + + Uri get uri => Uri( + scheme: 'ws', + host: InternetAddress.loopbackIPv4.address, + port: _server.port, + ); + + Future _listen() async { + await for (final request in _server) { + if (!WebSocketTransformer.isUpgradeRequest(request)) { + request.response.statusCode = HttpStatus.badRequest; + await request.response.close(); + continue; + } + + final socket = await WebSocketTransformer.upgrade(request); + connections.add(socket); + socket.listen((message) { + final request = jsonDecode(message as String) as Map; + final method = request['method'] as String; + requestedMethods.add(method); + socket.add( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': request['id'], + 'result': _resultFor(method), + }), + ); + }); + } + } + + Map _resultFor(String method) => switch (method) { + 'getRegisteredServices' => { + 'type': 'RegisteredServicesResponse', + 'dtdServices': ['ConnectedApp'], + 'clientServices': >[ + { + 'name': 'rohd', + 'methods': >[], + }, + ], + }, + 'ConnectedApp.getVmServices' => { + 'type': 'VmServicesResponse', + 'vmServices': [ + { + 'name': 'waveform demo', + 'uri': 'ws://internal:8181/app=/ws', + 'exposedUri': 'ws://forwarded:8181/app=/ws', + }, + ], + }, + _ => throw StateError('Unexpected DTD request: $method'), + }; + + Future close() async { + await Future.wait(connections.map((connection) => connection.close())); + await _server.close(force: true); + } +} + +void main() { + test('discovers VM services through the DTD JSON-RPC protocol', () async { + final dtd = await _DtdDiscoveryStub.start(); + addTearDown(dtd.close); + Set? registeredServices; + + final services = await discoverVmServicesViaDtd( + dtd.uri.toString(), + onRegisteredServices: (services) => registeredServices = services, + ); + + expect(dtd.requestedMethods, [ + 'getRegisteredServices', + 'ConnectedApp.getVmServices', + ]); + expect(registeredServices, {'rohd'}); + expect(services, hasLength(1)); + expect(services.single.name, 'waveform demo'); + expect(services.single.uri, 'ws://internal:8181/app=/ws'); + expect(services.single.exposedUri, 'ws://forwarded:8181/app=/ws'); + expect(services.single.connectionUri, 'ws://forwarded:8181/app=/ws'); + }); +} diff --git a/rohd_devtools_extension/test/ui/module_tree_details_navbar_test.dart b/rohd_devtools_extension/test/ui/module_tree_details_navbar_test.dart new file mode 100644 index 000000000..41c38b4d4 --- /dev/null +++ b/rohd_devtools_extension/test/ui/module_tree_details_navbar_test.dart @@ -0,0 +1,50 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// module_tree_details_navbar_test.dart +// Tests for module detail navigation interactions. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/details_tab_cubit.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/module_tree_details_navbar.dart'; + +void main() { + testWidgets('selects each details view from its matching navigation tab', + (tester) async { + final cubit = DetailsTabCubit(); + addTearDown(cubit.close); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider.value( + value: cubit, + child: const Scaffold( + body: ModuleTreeDetailsNavbar(hasColorEmoji: false), + ), + ), + ), + ); + + expect(cubit.state, DetailsTab.details); + expect(find.text('Details'), findsOneWidget); + expect(find.text('Waveform'), findsOneWidget); + expect(find.text('Schematic'), findsOneWidget); + + await tester.tap(find.text('Waveform')); + await tester.pump(); + expect(cubit.state, DetailsTab.waveform); + + await tester.tap(find.text('Schematic')); + await tester.pump(); + expect(cubit.state, DetailsTab.schematic); + + await tester.tap(find.text('Details')); + await tester.pump(); + expect(cubit.state, DetailsTab.details); + }); +} diff --git a/rohd_devtools_extension/test/ui/presentation_widgets_test.dart b/rohd_devtools_extension/test/ui/presentation_widgets_test.dart new file mode 100644 index 000000000..6ed702aa3 --- /dev/null +++ b/rohd_devtools_extension/test/ui/presentation_widgets_test.dart @@ -0,0 +1,87 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// presentation_widgets_test.dart +// Tests for reusable DevTools presentation widgets. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/platform_icon.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/simulation_time_display.dart'; + +void main() { + group('SimulationTimeDisplay', () { + test('formats a time without a unit by default', () { + expect(SimulationTimeDisplay.none.format(42), '42'); + }); + + test('formats a time with a trimmed configured unit', () { + expect(const SimulationTimeDisplay(unit: ' ns ').format(42), '42ns'); + }); + + test('treats a blank configured unit as absent', () { + expect(const SimulationTimeDisplay(unit: ' ').format(42), '42'); + }); + }); + + group('PlatformIcon', () { + testWidgets('renders emoji text when color emoji is available', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: PlatformIcon( + Icons.waves, + 'wave', + size: 24, + color: Colors.teal, + ), + ), + ); + + final text = tester.widget(find.text('wave')); + expect(find.byType(Icon), findsNothing); + expect(text.style!.fontSize, 24); + expect(text.style!.color, Colors.teal); + }); + + testWidgets('renders the Material icon when emoji is unavailable', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: PlatformIcon( + Icons.waves, + 'wave', + size: 20, + color: Colors.teal, + hasColorEmoji: false, + ), + ), + ); + + final icon = tester.widget(find.byType(Icon)); + expect(find.text('wave'), findsNothing); + expect(icon.icon, Icons.waves); + expect(icon.size, 20); + expect(icon.color, Colors.teal); + }); + + test('helper retains the requested rendering configuration', () { + final icon = platformIcon( + Icons.waves, + 'wave', + size: 18, + color: Colors.teal, + hasColorEmoji: false, + ) as PlatformIcon; + + expect(icon.nativeIcon, Icons.waves); + expect(icon.emoji, 'wave'); + expect(icon.size, 18); + expect(icon.color, Colors.teal); + expect(icon.hasColorEmoji, isFalse); + }); + }); +} diff --git a/rohd_devtools_extension/test/ui/signal_details_card_test.dart b/rohd_devtools_extension/test/ui/signal_details_card_test.dart new file mode 100644 index 000000000..ed9753a17 --- /dev/null +++ b/rohd_devtools_extension/test/ui/signal_details_card_test.dart @@ -0,0 +1,144 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_details_card_test.dart +// Tests for signal-details card workflows. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/snapshot_cubit.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/models/signal_model.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/models/tree_model.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/signal_details_card.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/simulation_time_display.dart'; + +final _module = TreeModel( + name: 'top', + inputs: [ + SignalModel( + name: 'input_enable', + direction: 'Input', + value: '0', + width: 1, + ), + ], + outputs: [ + SignalModel( + name: 'output_value', + direction: 'Output', + value: '00', + width: 8, + ), + ], + inouts: [ + SignalModel( + name: 'shared_bus', + direction: 'Inout', + value: 'zz', + width: 8, + ), + ], + subModules: const [], +); + +void main() { + Future pumpCard( + WidgetTester tester, { + TreeModel? module, + bool includeModule = true, + SnapshotLoaded? snapshot, + SimulationTimeDisplay timeDisplay = SimulationTimeDisplay.none, + }) => + tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 800, + height: 600, + child: SignalDetailsCard( + module: includeModule ? module ?? _module : null, + snapshot: snapshot, + timeDisplay: timeDisplay, + ), + ), + ), + ), + ); + + testWidgets('shows an empty state until a module is selected', + (tester) async { + await pumpCard(tester, includeModule: false); + + expect(find.text('No module selected'), findsOneWidget); + expect(find.byType(TextField), findsNothing); + }); + + testWidgets('filters signals by search text and restores them when cleared', + (tester) async { + await pumpCard(tester); + + expect(find.text('input_enable'), findsOneWidget); + expect(find.text('output_value'), findsOneWidget); + expect(find.text('shared_bus'), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'output'); + await tester.pump(); + + expect(find.text('input_enable'), findsNothing); + expect(find.text('output_value'), findsOneWidget); + expect(find.text('shared_bus'), findsNothing); + + await tester.tap(find.byIcon(Icons.clear)); + await tester.pump(); + + expect(find.text('input_enable'), findsOneWidget); + expect(find.text('output_value'), findsOneWidget); + expect(find.text('shared_bus'), findsOneWidget); + }); + + testWidgets('hides directions selected in the filter dialog', (tester) async { + await pumpCard(tester); + + await tester.tap( + find.ancestor( + of: find.byIcon(Icons.filter_list), + matching: find.byType(IconButton), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byType(Checkbox).first); + await tester.pump(); + await tester.tap(find.byType(Checkbox).at(2)); + await tester.pump(); + + expect(find.text('input_enable'), findsNothing); + expect(find.text('output_value'), findsOneWidget); + expect(find.text('shared_bus'), findsNothing); + }); + + testWidgets('uses snapshot values and formats the snapshot time', + (tester) async { + await pumpCard( + tester, + snapshot: const SnapshotLoaded( + time: 42, + signals: { + 'output_value': SignalSnapshot( + signalId: 'output_value', + name: 'output_value', + value: 'ff', + width: 8, + ), + }, + ), + timeDisplay: const SimulationTimeDisplay(unit: 'ns'), + ); + + expect(find.text('Value (@ 42ns)'), findsOneWidget); + expect(find.text('ff'), findsOneWidget); + expect(find.text('0'), findsOneWidget); + }); +} diff --git a/rohd_devtools_extension/test/ui/signal_table_test.dart b/rohd_devtools_extension/test/ui/signal_table_test.dart new file mode 100644 index 000000000..830db6c37 --- /dev/null +++ b/rohd_devtools_extension/test/ui/signal_table_test.dart @@ -0,0 +1,114 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_table_test.dart +// Tests for signal table filtering and value display. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/cubit/snapshot_cubit.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/models/signal_model.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/models/tree_model.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/signal_table.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/simulation_time_display.dart'; + +final _module = TreeModel( + name: 'counter', + inputs: [ + SignalModel( + name: 'clock', + direction: 'Input', + value: "1'h0", + width: 1, + ), + ], + outputs: [ + SignalModel( + name: 'count', + direction: 'Output', + value: "8'h00", + width: 8, + ), + ], + inouts: [ + SignalModel( + name: 'bus', + direction: 'Inout', + value: "4'hf", + width: 4, + ), + ], + subModules: const [], +); + +Widget _buildTable({ + String? searchTerm, + bool inputSelected = true, + bool outputSelected = true, + bool inoutSelected = true, + SnapshotLoaded? snapshot, + SimulationTimeDisplay timeDisplay = SimulationTimeDisplay.none, +}) => + MaterialApp( + home: Scaffold( + body: SignalTable( + selectedModule: _module, + searchTerm: searchTerm, + inputSelectedVal: inputSelected, + outputSelectedVal: outputSelected, + inoutSelectedVal: inoutSelected, + snapshot: snapshot, + timeDisplay: timeDisplay, + ), + ), + ); + +void main() { + testWidgets('renders every signal and overlays available snapshot values', + (tester) async { + const snapshot = SnapshotLoaded( + time: 17, + signals: { + 'counter.count': SignalSnapshot( + signalId: 'counter.count', + name: 'count', + value: "8'h2a", + width: 8, + ), + }, + ); + + await tester.pumpWidget( + _buildTable( + snapshot: snapshot, + timeDisplay: const SimulationTimeDisplay(unit: 'ns'), + ), + ); + + expect(find.text('Value (@ 17ns)'), findsOneWidget); + expect(find.text('clock'), findsOneWidget); + expect(find.text('count'), findsOneWidget); + expect(find.text('bus'), findsOneWidget); + expect(find.text("8'h2a"), findsOneWidget); + expect(find.text("8'h00"), findsNothing); + }); + + testWidgets('applies search and direction filters before rendering rows', + (tester) async { + await tester.pumpWidget( + _buildTable( + searchTerm: 'bus', + inputSelected: false, + outputSelected: false, + ), + ); + + expect(find.text('bus'), findsOneWidget); + expect(find.text('clock'), findsNothing); + expect(find.text('count'), findsNothing); + expect(find.text('Inout'), findsOneWidget); + }); +} diff --git a/rohd_devtools_extension/test/ui/signal_table_text_field_test.dart b/rohd_devtools_extension/test/ui/signal_table_text_field_test.dart new file mode 100644 index 000000000..21848dbba --- /dev/null +++ b/rohd_devtools_extension/test/ui/signal_table_text_field_test.dart @@ -0,0 +1,50 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_table_text_field_test.dart +// Tests for signal table filter text field behavior. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/signal_table_text_field.dart'; + +void main() { + testWidgets('forwards typed filters and clears them on request', + (tester) async { + final terms = []; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Row( + children: [ + SignalTableTextField( + labelText: 'Signals', + onChanged: terms.add, + ), + ], + ), + ), + ), + ); + + expect(find.text('Signals (regex supported)'), findsOneWidget); + expect(find.byIcon(Icons.clear), findsNothing); + + await tester.enterText(find.byType(TextField), 'counter.*'); + await tester.pump(); + + expect(terms, ['counter.*']); + expect(find.byIcon(Icons.clear), findsOneWidget); + + await tester.tap(find.byIcon(Icons.clear)); + await tester.pump(); + + expect( + tester.widget(find.byType(TextField)).controller!.text, ''); + expect(terms.last, ''); + }); +} diff --git a/rohd_devtools_extension/test/ui/vm_connection_form_test.dart b/rohd_devtools_extension/test/ui/vm_connection_form_test.dart new file mode 100644 index 000000000..7d8cc2464 --- /dev/null +++ b/rohd_devtools_extension/test/ui/vm_connection_form_test.dart @@ -0,0 +1,188 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// vm_connection_form_test.dart +// Tests for VM connection form user workflows. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/vm_connection_form.dart'; + +void main() { + Future pumpForm( + WidgetTester tester, { + required TextEditingController vmUriController, + required TextEditingController dtdUriController, + required VoidCallback onConnect, + DiscoverVmServicesCallback? discoverVmServices, + List? initialServices, + ValueChanged>? onServicesDiscovered, + bool showDemoButton = false, + VoidCallback? onDemoMode, + }) => + tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: VmConnectionForm( + vmServiceUriController: vmUriController, + dtdUriController: dtdUriController, + onConnect: onConnect, + cleanVmServiceUri: (uri) => uri.trim(), + cleanDtdUri: (uri) => uri.trim(), + discoverVmServices: discoverVmServices, + initialDiscoveredServices: initialServices, + onServicesDiscovered: onServicesDiscovered, + showDemoButton: showDemoButton, + onDemoMode: onDemoMode, + ), + ), + ), + ); + + testWidgets('cleans the manual URI before requesting a connection', + (tester) async { + final vmUriController = + TextEditingController(text: ' ws://host:8181/app=/ws '); + final dtdUriController = TextEditingController(); + addTearDown(vmUriController.dispose); + addTearDown(dtdUriController.dispose); + var connections = 0; + + await pumpForm( + tester, + vmUriController: vmUriController, + dtdUriController: dtdUriController, + onConnect: () => connections++, + ); + + await tester.tap(find.text('Connect')); + + expect(vmUriController.text, 'ws://host:8181/app=/ws'); + expect(connections, 1); + }); + + testWidgets('discovers and auto-selects a single VM service', (tester) async { + final vmUriController = TextEditingController(); + final dtdUriController = TextEditingController(); + addTearDown(vmUriController.dispose); + addTearDown(dtdUriController.dispose); + final discovered = >[]; + final service = DiscoveredVmService( + name: 'counter', + uri: 'ws://internal:8181/app=/ws', + exposedUri: 'ws://forwarded:8181/app=/ws', + ); + + await pumpForm( + tester, + vmUriController: vmUriController, + dtdUriController: dtdUriController, + onConnect: () {}, + discoverVmServices: (dtdUri) async { + expect(dtdUri, 'ws://host:8181/dtd='); + return [service]; + }, + onServicesDiscovered: discovered.add, + ); + + await tester.enterText( + find.byType(TextField).first, + ' ws://host:8181/dtd= ', + ); + await tester.tap(find.text('Discover')); + await tester.pumpAndSettle(); + + expect(find.text('1 VM service(s) found:'), findsOneWidget); + expect(find.text('counter'), findsOneWidget); + expect(vmUriController.text, 'ws://forwarded:8181/app=/ws'); + expect(service.autoReconnect, isTrue); + expect(discovered.single, [service]); + }); + + testWidgets('connects to live services but only fills ended service URIs', + (tester) async { + final vmUriController = TextEditingController(); + final dtdUriController = TextEditingController(); + addTearDown(vmUriController.dispose); + addTearDown(dtdUriController.dispose); + var connections = 0; + final liveService = DiscoveredVmService( + name: 'live VM', + uri: 'ws://live:8181/app=/ws', + ); + final endedService = DiscoveredVmService( + name: 'ended VM', + uri: 'ws://ended:8181/app=/ws', + isAlive: false, + ); + + await pumpForm( + tester, + vmUriController: vmUriController, + dtdUriController: dtdUriController, + onConnect: () => connections++, + initialServices: [liveService, endedService], + ); + + await tester.tap(find.text('live VM')); + expect(vmUriController.text, liveService.connectionUri); + expect(connections, 1); + + await tester.tap(find.text('ended VM')); + expect(vmUriController.text, endedService.connectionUri); + expect(connections, 1); + expect(find.textContaining('(ended)'), findsOneWidget); + }); + + testWidgets('explains when DTD discovery finds no VM services', + (tester) async { + final vmUriController = TextEditingController(); + final dtdUriController = TextEditingController(); + addTearDown(vmUriController.dispose); + addTearDown(dtdUriController.dispose); + + await pumpForm( + tester, + vmUriController: vmUriController, + dtdUriController: dtdUriController, + onConnect: () {}, + discoverVmServices: (dtdUri) async => const [], + ); + + await tester.enterText( + find.byType(TextField).first, + 'ws://host:8181/dtd=', + ); + await tester.tap(find.text('Discover')); + await tester.pumpAndSettle(); + + expect( + find.text('No VM services found. Is your app running?'), + findsOneWidget, + ); + }); + + testWidgets('invokes the optional demo mode command', (tester) async { + final vmUriController = TextEditingController(); + final dtdUriController = TextEditingController(); + addTearDown(vmUriController.dispose); + addTearDown(dtdUriController.dispose); + var demoRequests = 0; + + await pumpForm( + tester, + vmUriController: vmUriController, + dtdUriController: dtdUriController, + onConnect: () {}, + showDemoButton: true, + onDemoMode: () => demoRequests++, + ); + + await tester.tap(find.text('Continue without Connection (Demo examples)')); + + expect(demoRequests, 1); + }); +} diff --git a/rohd_devtools_extension/test/view/rohd_devtools_page_test.dart b/rohd_devtools_extension/test/view/rohd_devtools_page_test.dart new file mode 100644 index 000000000..3b3d2c94d --- /dev/null +++ b/rohd_devtools_extension/test/view/rohd_devtools_page_test.dart @@ -0,0 +1,39 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// rohd_devtools_page_test.dart +// Tests for the top-level ROHD DevTools page composition. +// +// 2026 July +// Author: Desmond Kirkpatrick + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/rohd_devtools.dart'; +import 'package:rohd_devtools_extension/rohd_devtools/ui/ui.dart'; + +void main() { + testWidgets('builds the extension module and toggles its theme', + (tester) async { + tester.view + ..physicalSize = const Size(1200, 800) + ..devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + const MaterialApp(home: RohdDevToolsPage(manageServiceManager: false))); + await tester.pump(); + + expect(find.byType(RohdExtensionModule), findsOneWidget); + expect(find.byType(DevtoolAppBar), findsOneWidget); + expect(find.byType(TreeStructurePage), findsOneWidget); + expect(find.text('ROHD DevTool (Beta)'), findsOneWidget); + expect(find.byTooltip('Switch to light theme'), findsOneWidget); + + await tester.tap(find.byTooltip('Switch to light theme')); + await tester.pump(); + + expect(find.byTooltip('Switch to dark theme'), findsOneWidget); + }); +} diff --git a/rohd_devtools_extension/tool/test_devtools_install.dart b/rohd_devtools_extension/tool/test_devtools_install.dart index 68e170b74..01e6a1c04 100644 --- a/rohd_devtools_extension/tool/test_devtools_install.dart +++ b/rohd_devtools_extension/tool/test_devtools_install.dart @@ -4,8 +4,10 @@ // test_devtools_install.dart // Smoke-test that Flutter DevTools can discover the installed ROHD DevTools // extension from a package root or extension/devtools install directory. +// +// 2026 July +// Author: Desmond Kirkpatrick -import 'dart:convert'; import 'dart:io'; import 'package:devtools_shared/devtools_extensions_io.dart'; @@ -35,13 +37,11 @@ Future main(List args) async { ); final extensionAssetsPath = p.join(extensionDir.path, 'build'); - final rohdExtensions = manager.devtoolsExtensions.where((extension) { - return extension.name == 'rohd' && - p.equals( - extension.extensionAssetsPath, - extensionAssetsPath, - ); - }).toList(); + final rohdExtensions = manager.devtoolsExtensions + .where((extension) => + extension.name == 'rohd' && + p.equals(extension.extensionAssetsPath, extensionAssetsPath)) + .toList(); if (rohdExtensions.length != 1) { _fail( @@ -77,16 +77,11 @@ Future main(List args) async { } Future<_ResolvedTarget> _resolveTarget(String target) async { - final githubTree = _githubTreeUrl.firstMatch(target); + final githubTree = _parseGithubTreeTarget(target); if (githubTree != null) { return _downloadGithubTree(githubTree); } - final intelRohdTree = _intelRohdTreeUrl.firstMatch(target); - if (intelRohdTree != null) { - return _downloadGithubTree(intelRohdTree, isIntelRohdShorthand: true); - } - if (target.startsWith('http://') || target.startsWith('https://')) { _fail( 'Unsupported URL. Expected a GitHub tree URL like ' @@ -99,42 +94,64 @@ Future<_ResolvedTarget> _resolveTarget(String target) async { ); } -final _githubTreeUrl = RegExp( - r'^https://github\.com/([^/]+)/([^/]+)/tree/([^/]+)(?:/(.*))?$', -); +_GithubTreeTarget? _parseGithubTreeTarget(String target) { + final uri = Uri.tryParse(target); + if (uri == null || uri.scheme != 'https' || uri.host != 'github.com') { + return null; + } + + final segments = uri.pathSegments; + if (segments.length >= 4 && segments[2] == 'tree') { + return _GithubTreeTarget( + owner: segments[0], + repo: segments[1], + branch: segments[3], + treePath: segments.length > 4 ? p.joinAll(segments.skip(4)) : null, + ); + } + + if (segments.length >= 3 && segments[0] == 'rohd' && segments[1] == 'tree') { + return _GithubTreeTarget( + owner: 'intel', + repo: 'rohd', + branch: segments[2], + treePath: segments.length > 3 ? p.joinAll(segments.skip(3)) : null, + ); + } -final _intelRohdTreeUrl = RegExp( - r'^https://github\.com/(rohd)/tree/([^/]+)(?:/(.*))?$', -); + return null; +} Future<_ResolvedTarget> _downloadGithubTree( - RegExpMatch match, { - bool isIntelRohdShorthand = false, -}) async { - final owner = isIntelRohdShorthand ? 'intel' : match.group(1)!; - final repo = isIntelRohdShorthand ? 'rohd' : match.group(2)!; - final branch = isIntelRohdShorthand ? match.group(2)! : match.group(3)!; - final treePath = isIntelRohdShorthand ? match.group(3) : match.group(4); + _GithubTreeTarget target, +) async { final tempDir = await Directory.systemTemp.createTemp( 'rohd_devtools_install_tree_', ); - final archivePath = p.join(tempDir.path, '$repo-$branch.zip'); + final archivePath = p.join( + tempDir.path, + '${target.repo}-${target.branch}.zip', + ); + final archiveUrl = 'https://github.com/${target.owner}/${target.repo}/' + 'archive/refs/heads/${target.branch}.zip'; await _runChecked( 'curl', [ '-fsSL', - 'https://github.com/$owner/$repo/archive/refs/heads/$branch.zip', + archiveUrl, '-o', archivePath, ], ); await _runChecked('unzip', ['-q', archivePath, '-d', tempDir.path]); - final extractedRoot = Directory(p.join(tempDir.path, '$repo-$branch')); - final resolvedPath = treePath == null || treePath.isEmpty + final extractedRoot = Directory( + p.join(tempDir.path, '${target.repo}-${target.branch}'), + ); + final resolvedPath = target.treePath == null || target.treePath!.isEmpty ? extractedRoot.path - : p.join(extractedRoot.path, treePath); + : p.join(extractedRoot.path, target.treePath); return _ResolvedTarget( Directory(resolvedPath), @@ -152,6 +169,20 @@ Future _runChecked(String executable, List arguments) async { } } +final class _GithubTreeTarget { + final String owner; + final String repo; + final String branch; + final String? treePath; + + const _GithubTreeTarget({ + required this.owner, + required this.repo, + required this.branch, + required this.treePath, + }); +} + final class _ResolvedTarget { final Directory directory; final Directory? cleanupDir; @@ -182,23 +213,6 @@ Future _writeProjectRootPointingTo(Directory packageRoot) async { final projectRoot = await Directory.systemTemp.createTemp( 'rohd_devtools_extension_discovery_', ); - final dartToolDir = Directory(p.join(projectRoot.path, '.dart_tool')); - dartToolDir.createSync(recursive: true); - final packageConfigFile = - File(p.join(dartToolDir.path, 'package_config.json')); - packageConfigFile.writeAsStringSync( - jsonEncode({ - 'configVersion': 2, - 'packages': [ - { - 'name': 'rohd', - 'rootUri': packageRoot.uri.toString(), - 'packageUri': 'lib/', - 'languageVersion': '3.6', - } - ], - }), - ); return projectRoot; } diff --git a/rohd_extension/dart/lib/dtd_service.dart b/rohd_extension/dart/lib/dtd_service.dart index 47a540b72..917017986 100644 --- a/rohd_extension/dart/lib/dtd_service.dart +++ b/rohd_extension/dart/lib/dtd_service.dart @@ -16,10 +16,9 @@ import 'dart:async'; import 'dart:convert'; import 'package:json_rpc_2/json_rpc_2.dart'; +import 'package:rohd_source_navigator/source_navigator.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; -import 'source_navigator.dart'; - /// Callback invoked when the DTD service receives a goToSource request. /// /// The TS shell provides this callback to bridge DTD requests into diff --git a/rohd_extension/dart/lib/flc_data.dart b/rohd_extension/dart/lib/flc_data.dart index 010196836..9860e15c7 100644 --- a/rohd_extension/dart/lib/flc_data.dart +++ b/rohd_extension/dart/lib/flc_data.dart @@ -25,6 +25,8 @@ class FlcFrame { /// Frame type: `'rohd'` for ROHD Dart source, `'sv'` for SystemVerilog. final String type; + /// Creates an [FlcFrame] with the given source [file], [line], [column], + /// and optional [type] tag (defaults to `'rohd'`). const FlcFrame({ required this.file, required this.line, @@ -53,6 +55,9 @@ class FlcEntry { /// became `sum_0`). Null if the name was not renamed. final String? origName; + /// Creates an [FlcEntry] with one or more ROHD source [frames], optional + /// [outputFrames] for generated-language positions, and an optional + /// [origName] alias. const FlcEntry({ required this.frames, this.outputFrames = const [], @@ -118,7 +123,9 @@ class FlcData { for (final modEntry in modules.entries) { final moduleName = modEntry.key; final modMap = modEntry.value as Map?; - if (modMap == null) continue; + if (modMap == null) { + continue; + } final svFile = modMap['svFile'] as String?; // Output files map. @@ -141,7 +148,9 @@ class FlcData { } } final tree = modMap['tree'] as List?; - if (tree == null) continue; + if (tree == null) { + continue; + } final modSignals = {}; final modInstances = {}; @@ -150,7 +159,9 @@ class FlcData { /// Frames are accumulated outermost-first; we reverse at the leaf /// to match the innermost-first convention of FlcEntry.frames. void walkNode(List node, List path) { - if (node.isEmpty) return; + if (node.isEmpty) { + return; + } final frame = node[0] as String; final currentPath = [...path, frame]; @@ -170,9 +181,13 @@ class FlcData { final rohdFrames = []; for (final f in currentPath.reversed) { final parts = f.split(':'); - if (parts.length < 2) continue; + if (parts.length < 2) { + continue; + } final fi = int.tryParse(parts[0]); - if (fi == null || fi >= files.length) continue; + if (fi == null || fi >= files.length) { + continue; + } final line = int.tryParse(parts[1]) ?? 1; final col = parts.length > 2 ? (int.tryParse(parts[2]) ?? 1) : 1; rohdFrames.add( @@ -184,7 +199,9 @@ class FlcData { final outFrames = []; for (final pos in parsed.outputPositions) { final file = outputFiles[pos.type]; - if (file == null) continue; + if (file == null) { + continue; + } outFrames.add( FlcFrame( file: file, @@ -218,8 +235,12 @@ class FlcData { } } - if (modSignals.isNotEmpty) signals[moduleName] = modSignals; - if (modInstances.isNotEmpty) instances[moduleName] = modInstances; + if (modSignals.isNotEmpty) { + signals[moduleName] = modSignals; + } + if (modInstances.isNotEmpty) { + instances[moduleName] = modInstances; + } } return FlcData._(files: files, signals: signals, instances: instances); @@ -259,13 +280,17 @@ class FlcData { rest = rest.substring(0, atIdx); for (final group in posStr.split(';')) { - if (group.isEmpty) continue; + if (group.isEmpty) { + continue; + } // A group is `[lang:]entry(,entry)*` where each entry is `[F:]L:C`. final entries = group.split(','); String? groupLang; for (var i = 0; i < entries.length; i++) { var part = entries[i]; - if (part.isEmpty) continue; + if (part.isEmpty) { + continue; + } // Only the first entry of a group may carry a language tag. if (i == 0) { final segments = part.split(':'); @@ -304,19 +329,23 @@ class FlcData { /// Look up FLC frames for a signal in a given module. /// - /// Returns null if no trace data exists for this signal. - /// Falls back to matching by [origName] if the canonical name isn't found. + /// Returns null if no trace data exists for this signal. Falls back to + /// matching by [FlcEntry.origName] if the canonical name isn't found. List? lookupSignal(String moduleName, String signalName) => lookupSignalEntry(moduleName, signalName)?.frames; /// Look up the full [FlcEntry] for a signal (includes SV frame if present). FlcEntry? lookupSignalEntry(String moduleName, String signalName) { final modSignals = _signals[moduleName]; - if (modSignals == null) return null; + if (modSignals == null) { + return null; + } // Direct match. final direct = modSignals[signalName]; - if (direct != null) return direct; + if (direct != null) { + return direct; + } // Fallback: search by origName. for (final entry in modSignals.values) { @@ -331,13 +360,18 @@ class FlcData { List? lookupInstance(String moduleName, String instanceName) => lookupInstanceEntry(moduleName, instanceName)?.frames; - /// Look up the full [FlcEntry] for an instance (includes SV frame if present). + /// Look up the full [FlcEntry] for an instance (includes SV frame if + /// present). FlcEntry? lookupInstanceEntry(String moduleName, String instanceName) { final modInstances = _instances[moduleName]; - if (modInstances == null) return null; + if (modInstances == null) { + return null; + } final direct = modInstances[instanceName]; - if (direct != null) return direct; + if (direct != null) { + return direct; + } // Fallback: search by origName. for (final entry in modInstances.values) { diff --git a/rohd_extension/dart/lib/rohd_source_navigator.dart b/rohd_extension/dart/lib/rohd_source_navigator.dart index ea77ef85a..a0ca7b9c4 100644 --- a/rohd_extension/dart/lib/rohd_source_navigator.dart +++ b/rohd_extension/dart/lib/rohd_source_navigator.dart @@ -4,8 +4,6 @@ // rohd_source_navigator.dart // Library exports for the ROHD source navigator Dart package. -library; - export 'dtd_service.dart'; export 'flc_data.dart'; export 'source_navigator.dart'; diff --git a/rohd_extension/dart/lib/source_navigator.dart b/rohd_extension/dart/lib/source_navigator.dart index f6d96c972..5e9e1b846 100644 --- a/rohd_extension/dart/lib/source_navigator.dart +++ b/rohd_extension/dart/lib/source_navigator.dart @@ -29,6 +29,8 @@ class SourceFrame { /// Frame type: `'sv'` for SystemVerilog, `'rohd'` for ROHD Dart source. final String type; + /// Creates a [SourceFrame] with the given [file], [line], [col], optional + /// [desc], and optional [type] tag (defaults to `'rohd'`). const SourceFrame({ required this.file, required this.line, @@ -105,14 +107,18 @@ class FrameCycler { /// Advance to the next frame (wrapping). SourceFrame? next() { - if (_frames.isEmpty) return null; + if (_frames.isEmpty) { + return null; + } _index = (_index + 1) % _frames.length; return _frames[_index]; } /// Go back to the previous frame (wrapping). SourceFrame? prev() { - if (_frames.isEmpty) return null; + if (_frames.isEmpty) { + return null; + } _index = (_index - 1 + _frames.length) % _frames.length; return _frames[_index]; } @@ -127,7 +133,9 @@ class FrameCycler { /// /// Format: `"TYPE 1/3: file.dart:42 desc"` String get statusText { - if (_frames.isEmpty) return ''; + if (_frames.isEmpty) { + return ''; + } final f = _frames[_index]; final desc = f.desc != null ? ' ${f.desc}' : ''; return '${f.typeTag} ${_index + 1}/${_frames.length}: ' @@ -195,7 +203,9 @@ List resolveCandidatePaths( var parent = root; for (var i = 0; i < parentLevels; i++) { final lastSlash = parent.lastIndexOf('/'); - if (lastSlash <= 0) break; + if (lastSlash <= 0) { + break; + } parent = parent.substring(0, lastSlash); candidates.add('$parent/$normalized'); } diff --git a/rohd_extension/dart/test/flc_data_test.dart b/rohd_extension/dart/test/flc_data_test.dart index 9c33e078f..b0a4fa300 100644 --- a/rohd_extension/dart/test/flc_data_test.dart +++ b/rohd_extension/dart/test/flc_data_test.dart @@ -237,13 +237,14 @@ void main() { }); test('returns empty FlcData when modules is null', () { - final flc = FlcData.fromJson({'version': 5, 'files': []}); + final flc = FlcData.fromJson({'version': 5, 'files': []}); expect(flc.isEmpty, isTrue); expect(flc.files, isEmpty); }); test('returns empty FlcData when modules is empty', () { - final flc = FlcData.fromJson({'version': 5, 'files': [], 'modules': {}}); + final flc = FlcData.fromJson( + {'version': 5, 'files': [], 'modules': {}}); expect(flc.isEmpty, isTrue); }); diff --git a/tool/generate_coverage.sh b/tool/generate_coverage.sh index ddaebabb4..9ef97f930 100755 --- a/tool/generate_coverage.sh +++ b/tool/generate_coverage.sh @@ -16,6 +16,14 @@ # the progress of the execution, but MAY REVEAL ANY SECRETS PASSED TO THE SCRIPT! set -euxo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TOOL_PACKAGE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +TARGET_DIR="${PWD}" +IS_FLUTTER_PACKAGE=false +if [ -f pubspec.yaml ] && grep -Eq '^ flutter:|^ flutter_test:' pubspec.yaml; then + IS_FLUTTER_PACKAGE=true +fi + #=============# # Parse arguments @@ -34,8 +42,15 @@ done # Remove old coverage data rm -rf coverage -# Run tests with line and branch coverage -dart test --coverage=coverage --branch-coverage || true +if [ "${IS_FLUTTER_PACKAGE}" = true ]; then + # Flutter writes LCOV directly, unlike `dart test --coverage` which writes + # VM coverage JSON that must be converted with coverage:format_coverage. + flutter test --coverage --coverage-path=coverage/lcov.info || true +else + # Benchmarks are smoke-tested separately and can take too long under + # coverage instrumentation. + dart test --coverage=coverage --exclude-tags benchmark || true +fi # Check if coverage was generated if [ ! -d "coverage" ]; then @@ -43,13 +58,18 @@ if [ ! -d "coverage" ]; then exit 1 fi -# Format to LCOV -dart run coverage:format_coverage \ - --lcov \ - --in=coverage \ - --out=coverage/lcov.info \ - --packages=.dart_tool/package_config.json \ - --report-on=lib +if [ "${IS_FLUTTER_PACKAGE}" != true ]; then + # Format to LCOV + ( + cd "${TOOL_PACKAGE_DIR}" + dart run coverage:format_coverage \ + --lcov \ + --in="${TARGET_DIR}/coverage" \ + --out="${TARGET_DIR}/coverage/lcov.info" \ + --package="${TARGET_DIR}" \ + --report-on="${TARGET_DIR}/lib" + ) +fi # package:coverage emits branch details as BRDA records without BRF/BRH totals, # which genhtml 2.x does not summarize. Calculate the branch rate directly. diff --git a/tool/view_coverage.sh b/tool/view_coverage.sh new file mode 100755 index 000000000..3ec2d2d5a --- /dev/null +++ b/tool/view_coverage.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TARGET_DIR="$(pwd -P)" +COVERAGE_DIR="${TARGET_DIR}/coverage/html" +REGENERATE=false + +usage() { + cat << EOF +Usage: $(basename "$0") [--regenerate] + +Serve the coverage HTML report for the current package directory. + +Options: + -f, --force, --regenerate Regenerate coverage before serving. + -h, --help Show this help text. +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + -f|--force|--regenerate) + REGENERATE=true + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" + usage + exit 2 + ;; + esac + shift +done + +if [ "${REGENERATE}" = true ] || [ ! -f "${COVERAGE_DIR}/index.html" ]; then + ( + cd "${TARGET_DIR}" + "${SCRIPT_DIR}/generate_coverage.sh" + ) +fi + +COVERAGE_DIR="${TARGET_DIR}/coverage/html" +if [ ! -f "${COVERAGE_DIR}/index.html" ]; then + echo "Coverage HTML not found at ${COVERAGE_DIR}/index.html" + exit 1 +fi + +PORT="${PORT:-8000}" +MAX_PORT=$((PORT + 20)) +while ! python3 -c 'import socket, sys; server = socket.socket(); server.bind(("127.0.0.1", int(sys.argv[1]))); server.close()' "${PORT}" 2>/dev/null +do + PORT=$((PORT + 1)) + if [ "${PORT}" -gt "${MAX_PORT}" ]; then + echo "No available HTTP port found between $((MAX_PORT - 20)) and ${MAX_PORT}." + exit 1 + fi +done + +printf 'Serving coverage from %s\n' "${COVERAGE_DIR}" +URL="http://127.0.0.1:${PORT}/index.html" +printf 'Open %s\n' "${URL}" + +python3 -m http.server "${PORT}" --bind 127.0.0.1 --directory "${COVERAGE_DIR}" & +SERVER_PID=$! + +cleanup() { + if kill -0 "${SERVER_PID}" 2>/dev/null; then + kill "${SERVER_PID}" + fi +} +trap cleanup EXIT INT TERM + +for _ in $(seq 1 50); do + if python3 -c 'import socket, sys; client = socket.create_connection(("127.0.0.1", int(sys.argv[1])), timeout=0.1); client.close()' "${PORT}" 2>/dev/null + then + break + fi + sleep 0.1 +done + +if [ -n "${BROWSER:-}" ]; then + "${BROWSER}" "${URL}" >/dev/null 2>&1 & +else + echo 'BROWSER is not set; open the URL above manually.' +fi + +wait "${SERVER_PID}"