diff --git a/analysis_options.yaml b/analysis_options.yaml index 65d475023..46241ddcb 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -8,6 +8,7 @@ analyzer: strict-raw-types: true exclude: - doc/tutorials/chapter_9/rohd_vf_example + - packages/rohd_hierarchy - rohd_devtools_extension # keep up to date, matching https://dart.dev/tools/linter-rules/all @@ -129,7 +130,9 @@ linter: - overridden_fields - package_names - package_prefixed_library_names - - parameter_assignments + # parameter_assignments - disabled; ROHD idiomatically reassigns + # constructor parameters via addInput/addOutput. + # - parameter_assignments - prefer_adjacent_string_concatenation - prefer_asserts_in_initializer_lists - prefer_asserts_with_message diff --git a/lib/src/module.dart b/lib/src/module.dart index 0fd51eac7..92fc410e0 100644 --- a/lib/src/module.dart +++ b/lib/src/module.dart @@ -667,7 +667,6 @@ abstract class Module { } if (source is LogicStructure) { - // ignore: parameter_assignments source = source.packed; } @@ -704,7 +703,6 @@ abstract class Module { String name, LogicType source) { _checkForSafePortName(name); - // ignore: parameter_assignments source = _validateType(source, isOutput: false, name: name); if (source.isNet || (source is LogicStructure && source.hasNets)) { @@ -813,7 +811,6 @@ abstract class Module { throw PortTypeException(source, 'Typed inOuts must be nets.'); } - // ignore: parameter_assignments source = _validateType(source, isOutput: false, name: name); _inOutDrivers.add(source); diff --git a/lib/src/signals/logic.dart b/lib/src/signals/logic.dart index 88afba0d6..4c5f99e5e 100644 --- a/lib/src/signals/logic.dart +++ b/lib/src/signals/logic.dart @@ -377,7 +377,6 @@ class Logic { // If we are connecting a `LogicStructure` to this simple `Logic`, // then pack it first. if (other is LogicStructure) { - // ignore: parameter_assignments other = other.packed; } diff --git a/lib/src/signals/wire_net.dart b/lib/src/signals/wire_net.dart index 78e8b1beb..f93529b0f 100644 --- a/lib/src/signals/wire_net.dart +++ b/lib/src/signals/wire_net.dart @@ -189,7 +189,6 @@ class _WireNetBlasted extends _Wire implements _WireNet { other as _WireNet; if (other is! _WireNetBlasted) { - // ignore: parameter_assignments other = other.toBlasted(); } diff --git a/lib/src/utilities/simcompare.dart b/lib/src/utilities/simcompare.dart index 3a25f4074..d7850df4e 100644 --- a/lib/src/utilities/simcompare.dart +++ b/lib/src/utilities/simcompare.dart @@ -282,7 +282,6 @@ abstract class SimCompare { : 'logic'); if (adjust != null) { - // ignore: parameter_assignments signalName = adjust(signalName); } diff --git a/lib/src/values/logic_value.dart b/lib/src/values/logic_value.dart index 0cdc3c1df..81fc7304b 100644 --- a/lib/src/values/logic_value.dart +++ b/lib/src/values/logic_value.dart @@ -218,7 +218,6 @@ abstract class LogicValue implements Comparable { if (val.width == 1 && (!val.isValid || fill)) { if (!val.isValid) { - // ignore: parameter_assignments width ??= 1; } if (width == null) { @@ -243,7 +242,6 @@ abstract class LogicValue implements Comparable { if (val.length == 1 && (val == 'x' || val == 'z' || fill)) { if (val == 'x' || val == 'z') { - // ignore: parameter_assignments width ??= 1; } if (width == null) { @@ -269,7 +267,6 @@ abstract class LogicValue implements Comparable { if (val.length == 1 && (val.first == LogicValue.x || val.first == LogicValue.z || fill)) { if (!val.first.isValid) { - // ignore: parameter_assignments width ??= 1; } if (width == null) { diff --git a/packages/rohd_hierarchy/README.md b/packages/rohd_hierarchy/README.md new file mode 100644 index 000000000..4b03c519f --- /dev/null +++ b/packages/rohd_hierarchy/README.md @@ -0,0 +1,243 @@ +# rohd_hierarchy + +An incremental design dictionary for hardware module hierarchies. + +## Motivation + +A remote agent — a debugger, a waveform viewer, a schematic renderer, an +AI assistant — needs to understand the structure of a hardware design in order +to ask useful questions about it. Transferring the full design every time is +wasteful. What both sides of a link really need is a shared **dictionary** of +the design: the modules, occurrences, and signals that make it up, plus +a compact way to refer to any object by address. + +Once both sides share the same dictionary, communication becomes cheap: +either side can request data about a specific object by its address alone, +without re-transmitting structural context. + +### What is a design dictionary? + +A design dictionary captures the **hierarchy and connectivity** of a +hardware design: + +- **Occurrences** — unfolded instances of module definitions in the + hierarchy tree. Each has a `name`, an optional `definition` (the + module type), child occurrences, and signals. +- **Signals** — named wires within an occurrence. Each has a `name`, + `width`, optional `direction` (input/output/inout), and optional + `value`. + +The full "unfolded" view of a design is its **address space**: every +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 +the tree. + +Addresses are **relative within each occurrence**: an occurrence's address +table maps local indices to its children and signals without relying on any +global namespace. This locality property is what makes the dictionary +**incrementally expandable** — a remote agent can: + +1. Request the top-level dictionary table (the root occurrence's children + and signals). +2. Drill into any child by requesting that child's dictionary table. +3. Continue expanding only the parts of the hierarchy it actually needs. + +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 + +`rohd_hierarchy` is a source-agnostic Dart package that implements this +dictionary model. It provides data models, search utilities, and adapter +interfaces that work independently of any particular HDL toolchain or +transport layer. + +### Data models + +- **`HierarchyOccurrence`** — An occurrence of a module definition in the + unfolded hierarchy tree, with children, signals, name, an optional + `definition` (module type), and a primitive flag. Call `buildAddresses()` + to assign a canonical `OccurrenceAddress` to every occurrence and signal + in O(n). Use `signalCount` and `computedSignalCount` for efficient + subtree counts. +- **`OccurrenceAddress`** — An immutable, index-based path through the + tree (e.g. `[0, 2, 4]`). Supports conversion to/from dot-separated + strings. Works as an O(1) cache key. +- **`SignalOccurrence`** — Signal metadata: name, width, optional + direction, and optional value. Signals with a `direction` serve as + ports (input, output, inout). + +### Services & adapters + +- **`HierarchyService`** — A mixin providing tree-walking search and + navigation: `searchSignals()`, `searchOccurrences()`, + `autocompletePaths()`, regex/glob search (`searchSignalsRegex()`, + `searchOccurrencesRegex()`), and address↔pathname conversion. +- **`BaseHierarchyAdapter`** — An abstract class wrapping a + `HierarchyOccurrence` tree with `HierarchyService`. Use + `BaseHierarchyAdapter.fromTree()` to wrap an existing tree. +- **`NetlistHierarchyAdapter`** — A concrete adapter that parses netlist + JSON into a `HierarchyOccurrence` tree. + +### Search queries + +- **`HierarchyQuery`** — Abstract base class for pluggable search + strategies. The matching logic is decoupled from tree traversal. +- **`PrefixQuery`** — Prefix-substring matching. Segments split on `/` + or `.` are matched case-insensitively via `startsWith` (signals) or + `contains` (occurrences). Created via `HierarchyQuery.prefix()`. +- **`RegexQuery`** — Regex/glob matching. Each segment is compiled as a + regex. Supports `*` (any chars), `?` (one char), `**` (zero or more + hierarchy levels), character classes (`[0-9]`), alternation + (`(clk|reset)`), and quantifiers. Created via `HierarchyQuery.regex()`. + +### Search controller + +- **`HierarchySearchController`** — A pure-Dart controller for + keyboard-navigable search result lists, with `updateQuery()`, + `selectNext()` / `selectPrevious()`, `tabComplete()`, and scroll-offset + helpers. Factories `forSignals()` and `forOccurrences()` cover the + common cases. + +## Usage + +### Building a dictionary from a netlist + +```dart +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; + +final dict = NetlistHierarchyAdapter.fromJson(netlistJsonString); +final root = dict.root; // the top-level dictionary table +``` + +### Wrapping an existing tree + +When you already have a `HierarchyOccurrence` tree (e.g. from a VCD +parser, a ROHD simulation, or any other source), wrap it to gain search +and address resolution: + +```dart +final dict = BaseHierarchyAdapter.fromTree(rootNode); +``` + +### Incremental expansion by a remote agent + +A remote agent does not need the full tree up front. It can expand the +dictionary one level at a time: + +```dart +// Agent receives the root table +final root = dict.root; + +// Agent picks a child to expand (e.g. child 2) +final child = root.children[2]; + +// The child's own children and signals are its local dictionary table. +// The agent now knows addresses 2.0, 2.1, ... for that subtree. +``` + +### Compact address-based communication + +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'); + +// Send the compact address over the wire: "0.1" +final wire = addr!.toDotString(); + +// The other side resolves it back +final resolved = dict.occurrenceByAddress(OccurrenceAddress.fromDotString(wire)); +final pathname = dict.addressToPathname(addr!); +``` + +### Searching the dictionary + +#### Prefix search (default) + +Segments are split on `/` or `.` and matched as case-insensitive +substrings: + +```dart +// Find all signals whose path contains 'cpu' then 'clk' +final signals = dict.searchSignals('cpu/clk'); + +// Find occurrences containing 'counter' +final modules = dict.searchOccurrences('counter'); + +// Tab-completion for partial paths +final completions = dict.autocompletePaths('Top/CPU/'); +``` + +#### Regex / glob search + +Each segment is a regex anchored to the full name. Glob wildcards `*` +and `?` are auto-converted. Use `**` to match across hierarchy levels: + +```dart +// All 'clk' signals anywhere in the design +final clocks = dict.searchSignalsRegex('Top/**/clk'); + +// Signals named d0–d15 in any regfile +final data = dict.searchSignalsRegex('Top/**/regfile/d[0-9]+'); + +// Either 'clk' or 'reset' anywhere +final resets = dict.searchSignalsRegex('Top/**/(clk|reset)'); + +// All cache channels ch0–ch2 +final channels = dict.searchOccurrencesRegex('Top/mem_ctrl/ch[0-2]'); + +// Signals containing 'mux' in their name +final muxed = dict.searchSignalsRegex('Top/**/.*mux.*'); + +// All signals in a specific module +final all = dict.searchSignalsRegex('Top/CPU/ALU/*'); +``` + +### Constructing occurrences manually + +```dart +final root = HierarchyOccurrence( + name: 'Counter', + definition: 'Counter', + signals: [ + SignalOccurrence(name: 'clk', width: 1, direction: 'input'), + SignalOccurrence(name: 'count', width: 8, direction: 'output'), + ], + children: [ + HierarchyOccurrence( + name: 'adder', + definition: 'Adder', + signals: [ + SignalOccurrence(name: 'a', width: 8), + SignalOccurrence(name: 'b', width: 8), + SignalOccurrence(name: 'sum', width: 8), + ], + ), + ], +); + +// Assign canonical addresses +root.buildAddresses(); + +// Now every occurrence and signal has an address +print(root.children.first.path()); // 'Counter/adder' +print(root.signals.first.path()); // 'Counter/clk' +``` + +## Design principles + +| Principle | How it is achieved | +|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Source-agnostic** | The data model is independent of any HDL toolchain. `NetlistHierarchyAdapter` handles netlist JSON; `BaseHierarchyAdapter.fromTree()` wraps any tree. | +| **Incremental** | Addresses are relative within each occurrence. A remote agent expands only the subtrees it needs, one dictionary table at a time. | +| **Compact** | `OccurrenceAddress` is a short index path (e.g. `0.2.4`), not a full dotted pathname. Both sides resolve it locally. | +| **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. | diff --git a/packages/rohd_hierarchy/analysis_options.yaml b/packages/rohd_hierarchy/analysis_options.yaml new file mode 100644 index 000000000..f04c6cf0f --- /dev/null +++ b/packages/rohd_hierarchy/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/rohd_hierarchy/lib/rohd_hierarchy.dart b/packages/rohd_hierarchy/lib/rohd_hierarchy.dart new file mode 100644 index 000000000..86ed7c336 --- /dev/null +++ b/packages/rohd_hierarchy/lib/rohd_hierarchy.dart @@ -0,0 +1,50 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// rohd_hierarchy.dart +// Main library export for rohd_hierarchy package. +// +// 2026 January +// Author: Desmond Kirkpatrick + +/// Generic hierarchy data models for hardware module navigation. +/// +/// This library provides source-agnostic data models for representing +/// hardware module hierarchies: +/// +/// ## Core Data Models +/// - `OccurrenceAddress` - Efficient index-based addressing for tree navigation +/// - `HierarchyOccurrence` - An occurrence of a module definition in the tree +/// - `SignalOccurrence` - A signal in the hierarchy +/// +/// ## Search & Navigation +/// - `SignalSearchResult` - Result of a signal search with enriched metadata +/// - `OccurrenceSearchResult` - Result of an occurrence search with metadata +/// - `HierarchyService` - Abstract interface for hierarchy navigation +/// - `HierarchySearchController` - Pure Dart search state controller +/// +/// ## Adapters +/// - `BaseHierarchyAdapter` - Base class with shared adapter implementation +/// - `NetlistHierarchyAdapter` - Adapter for netlist JSON format +/// +/// This package has no dependencies and can be used standalone by any +/// application that needs to navigate hardware hierarchies. +/// +/// ## Quick Start +/// ```dart +/// 1. Create hierarchy +/// final root = HierarchyOccurrence(id: 'top', name: 'top'); +/// root.buildAddresses(); // Enable address-based navigation +/// +/// 2. Search +/// final service = BaseHierarchyAdapter.fromTree(root); +/// final results = service.searchSignals('clk'); +/// ``` +library; + +export 'src/base_hierarchy_adapter.dart'; +export 'src/hierarchy_models.dart'; +export 'src/hierarchy_query.dart'; +export 'src/hierarchy_search_controller.dart'; +export 'src/hierarchy_service.dart'; +export 'src/netlist_hierarchy_adapter.dart'; diff --git a/packages/rohd_hierarchy/lib/src/base_hierarchy_adapter.dart b/packages/rohd_hierarchy/lib/src/base_hierarchy_adapter.dart new file mode 100644 index 000000000..772007497 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/base_hierarchy_adapter.dart @@ -0,0 +1,80 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// base_hierarchy_adapter.dart +// Base class with shared implementation for hierarchy adapters. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/src/hierarchy_models.dart'; +import 'package:rohd_hierarchy/src/hierarchy_service.dart'; + +/// Base class providing shared implementation for hierarchy adapters. +/// +/// The [HierarchyOccurrence] tree rooted at [root] is the single source of +/// truth. Children and signals are read directly from each occurrence's +/// [HierarchyOccurrence.children] and [HierarchyOccurrence.signals] lists. +/// Lookups use [OccurrenceAddress]-based navigation. +/// +/// Concrete adapters should: +/// 1. Extend this class +/// 2. Build a complete [HierarchyOccurrence] tree (with children and signals +/// populated on each occurrence) +/// 3. Set the [root] occurrence +/// +/// Search, autocomplete, and signal lookup are implemented by +/// [HierarchyService] via recursive tree walking. +abstract class BaseHierarchyAdapter with HierarchyService { + HierarchyOccurrence? _root; + + /// Creates a [BaseHierarchyAdapter]. + BaseHierarchyAdapter(); + + /// Creates an adapter wrapping an existing [HierarchyOccurrence] tree. + /// + /// The tree itself is the single source of truth — children and signals + /// are read directly from the [HierarchyOccurrence] lists. + /// + /// Example usage: + /// ```dart + /// final treeRoot = await dataSource.evalModuleTree(); + /// final service = BaseHierarchyAdapter.fromTree(treeRoot); + /// final paths = service.searchSignalPaths('clk'); + /// ``` + factory BaseHierarchyAdapter.fromTree( + HierarchyOccurrence rootNode, + ) = _TreeBackedAdapter; + + /// Sets the root occurrence. Call this once during initialisation. + set root(HierarchyOccurrence node) { + _root = node; + } + + // ───────────────────────────────────────────────────────────────────────── + // HierarchyService concrete accessors — all tree-walking, no flat maps + // ───────────────────────────────────────────────────────────────────────── + + @override + HierarchyOccurrence get root { + if (_root == null) { + throw StateError( + 'Root occurrence not set. Call setRoot() during initialization.'); + } + return _root!; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tree-backed implementation returned by BaseHierarchyAdapter.fromTree() +// ───────────────────────────────────────────────────────────────────────────── + +/// Private adapter that wraps an existing [HierarchyOccurrence] tree. +/// +/// Children and signals are read directly from the tree occurrences. +class _TreeBackedAdapter extends BaseHierarchyAdapter { + _TreeBackedAdapter(HierarchyOccurrence rootNode) { + root = rootNode; + rootNode.buildAddresses(); + } +} diff --git a/packages/rohd_hierarchy/lib/src/hierarchy_models.dart b/packages/rohd_hierarchy/lib/src/hierarchy_models.dart new file mode 100644 index 000000000..6d686e053 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_models.dart @@ -0,0 +1,15 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_models.dart +// Barrel file re-exporting all hierarchy data model classes. +// +// 2026 January +// Author: Desmond Kirkpatrick + +export 'hierarchy_occurrence.dart'; +export 'hierarchy_search_result.dart'; +export 'occurrence_address.dart'; +export 'occurrence_search_result.dart'; +export 'signal_occurrence.dart'; +export 'signal_search_result.dart'; diff --git a/packages/rohd_hierarchy/lib/src/hierarchy_occurrence.dart b/packages/rohd_hierarchy/lib/src/hierarchy_occurrence.dart new file mode 100644 index 000000000..5a0e9ac30 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_occurrence.dart @@ -0,0 +1,224 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_occurrence.dart +// An occurrence of a module definition in the unfolded hierarchy tree. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:meta/meta.dart'; +import 'package:rohd_hierarchy/src/occurrence_address.dart'; +import 'package:rohd_hierarchy/src/signal_occurrence.dart'; + +/// An occurrence of a module definition in the unfolded hierarchy tree. +/// +/// This is the core structural data model, independent of waveform data. +/// Path strings are computed on demand from parent references rather than +/// stored — call [path] with your desired separator. +class HierarchyOccurrence { + /// Display name of this occurrence (instance name within its parent). + final String name; + + /// Definition (module) name for this occurrence. + final String? definition; + + /// Whether this occurrence is a primitive cell (gate, operator, register, + /// etc.) whose internal structure is not useful for design navigation. + /// + /// Set by the parser/adapter that creates the occurrence. The netlist + /// adapter sets this for cells that lack a module definition in the JSON or + /// whose definition starts with `$` (netlist built-in primitives). + /// Tool-specific primitives (e.g. ROHD's FlipFlop → `$dff`) are handled by + /// the synthesizer mapping them to `$`-prefixed definitions before the JSON + /// is written. + final bool isPrimitive; + + /// Signals within this occurrence (includes both internal signals and + /// ports). Empty for leaf occurrences. + final List signals; + + /// Child occurrences. Populated from sub-modules in the hierarchy. + final List children; + + /// Hierarchical address for this occurrence. + /// Assigned by [buildAddresses] to enable efficient navigation. + /// Format: [child0, child1, ..., childN] for nested occurrences. + OccurrenceAddress? get address => _address; + OccurrenceAddress? _address; + + /// Parent occurrence, or `null` for the root. + /// Set by [buildAddresses]. + HierarchyOccurrence? get parent => _parent; + HierarchyOccurrence? _parent; + + /// Creates a [HierarchyOccurrence] with the given properties. + HierarchyOccurrence({ + required this.name, + this.definition, + this.isPrimitive = false, + List? signals, + List? children, + }) : signals = signals ?? [], + children = children ?? []; + + /// Compute the full hierarchical path by walking up the parent chain. + /// + /// Uses [separator] between path segments (default `/`). + /// Returns just [name] for the root (no parent). + String path({String separator = '/'}) { + if (_parent == null) { + return name; + } + final parts = []; + HierarchyOccurrence? cur = this; + while (cur != null) { + parts.add(cur.name); + cur = cur._parent; + } + return parts.reversed.join(separator); + } + + /// Returns only signals that are ports (have a direction). + List get ports => signals.where((s) => s.isPort).toList(); + + // ───────────────── Name → offset (index) lookups ───────────────── + + /// Lazily-built index: child name → offset in [children]. + Map? _childNameIndex; + + /// Lazily-built index: signal name → offset in [signals]. + Map? _signalNameIndex; + + /// Return the offset (index) of the child with [name] in [children], + /// or -1 if not found. Case-sensitive. + /// O(1) after first call (lazily builds index). + int childIndexByName(String name) { + _childNameIndex ??= { + for (var i = 0; i < children.length; i++) children[i].name: i, + }; + return _childNameIndex![name] ?? -1; + } + + /// Return the offset (index) of the signal with [name] in [signals], + /// or -1 if not found. Case-sensitive. + /// O(1) after first call (lazily builds index). + int signalIndexByName(String name) { + _signalNameIndex ??= { + for (var i = 0; i < signals.length; i++) signals[i].name: i, + }; + return _signalNameIndex![name] ?? -1; + } + + /// Whether [cellType] represents a netlist built-in primitive cell type. + /// + /// Returns `true` for `$`-prefixed types (`$mux`, `$dff`, `$and`, etc.) + /// which are netlist built-in operators and primitives. + /// + /// Tool-specific primitive types (e.g. ROHD's `FlipFlop`) should be + /// handled by the producer: the synthesizer should map them to + /// `$`-prefixed cell types in the JSON output, or the adapter should + /// set [isPrimitive] on the occurrence at construction time. + /// + /// Use this before a [HierarchyOccurrence] exists (e.g. when deciding + /// whether to recurse into a netlist cell definition). For an existing + /// occurrence, use the getter [isPrimitiveCell] instead. + static bool isPrimitiveType(String cellType) => cellType.startsWith(r'$'); + + /// Whether this occurrence represents a primitive cell that should be hidden + /// from the occurrence tree. + /// + /// Checks the [isPrimitive] field (set by the adapter at construction time) + /// and falls back to [isPrimitiveType] on the occurrence's [definition]. + bool get isPrimitiveCell => + isPrimitive || (definition != null && isPrimitiveType(definition!)); + + /// Returns only input signals. + List get inputs => + signals.where((s) => s.direction == 'input').toList(); + + /// Returns only output signals. + List get outputs => + signals.where((s) => s.direction == 'output').toList(); + + /// Number of port signals in this occurrence. + int get portCount => signals.where((s) => s.isPort).length; + + /// Collect all signals under this occurrence in depth-first order. + /// + /// Visits this occurrence's [signals] first, then recurses into + /// [children] in order. Useful for flat iteration or counting, but + /// signals should always be identified by their [OccurrenceAddress] or + /// path — never by a positional index in this list. + /// + /// Production code should use [signalCount], [computedSignalCount], or + /// a recursive visitor instead of materializing the full list. + @visibleForTesting + List depthFirstSignals() => + [...signals, ...children.expand((c) => c.depthFirstSignals())]; + + /// Total number of signals in this subtree (O(n) recursive count). + /// + /// Equivalent to `depthFirstSignals().length` but avoids allocating the + /// intermediate list. + int get signalCount => + signals.length + children.fold(0, (sum, c) => sum + c.signalCount); + + /// Number of computed signals in this subtree. + /// + /// Equivalent to + /// `depthFirstSignals().where((s) => s.isComputed).length` + /// but avoids allocating the intermediate list. + int get computedSignalCount => + signals.where((s) => s.isComputed).length + + children.fold(0, (sum, c) => sum + c.computedSignalCount); + + /// Build hierarchical addresses for this occurrence and all descendants. + /// + /// This performs a single O(n) tree traversal to assign [OccurrenceAddress] + /// to every occurrence and signal in the tree. Call this once after tree + /// construction to enable efficient address-based navigation. + /// + /// **Signal address ordering**: ports (signals with a non-null + /// [SignalOccurrence.direction]) are assigned indices first + /// (`0 .. portCount-1`), followed by internal signals + /// (`portCount .. signals.length-1`). Within each group the + /// original list order is preserved. + /// + /// This means a port's [SignalOccurrence.portIndex] always equals its + /// signal address index, which consumers (e.g. schematic hyperedges) can + /// rely on remaining stable across incremental expansion. + /// + /// Example: + /// ```dart + /// root.buildAddresses(); // Assign addresses to all occurrences/signals + /// final signalAddr = signals[0].address; // Now available + /// ``` + void buildAddresses([OccurrenceAddress startAddr = OccurrenceAddress.root]) { + _address = startAddr; + + // Assign ports first, then internal signals, so that port indices + // are stable across incremental hierarchy expansion. + var idx = 0; + for (final s in signals) { + if (s.isPort) { + s + ..address = startAddr.signal(idx++) + ..parent = this; + } + } + for (final s in signals) { + if (!s.isPort) { + s + ..address = startAddr.signal(idx++) + ..parent = this; + } + } + + for (final (i, c) in children.indexed) { + c + .._parent = this + ..buildAddresses(startAddr.child(i)); + } + } +} diff --git a/packages/rohd_hierarchy/lib/src/hierarchy_query.dart b/packages/rohd_hierarchy/lib/src/hierarchy_query.dart new file mode 100644 index 000000000..c350a2391 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_query.dart @@ -0,0 +1,152 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_query.dart +// Pluggable search query abstraction for hierarchy search. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/src/hierarchy_occurrence.dart'; +import 'package:rohd_hierarchy/src/hierarchy_service.dart'; +import 'package:rohd_hierarchy/src/prefix_query.dart'; +import 'package:rohd_hierarchy/src/regex_query.dart'; + +// Re-export so callers importing hierarchy_query.dart get the concrete types. +export 'package:rohd_hierarchy/src/prefix_query.dart'; +export 'package:rohd_hierarchy/src/regex_query.dart'; + +/// What kind of hierarchy elements a query should match. +enum SearchTarget { + /// Match only [HierarchyOccurrence] nodes (modules, instances). + occurrences, + + /// Match only signals within occurrences. + signals, + + /// Match both occurrences and signals. + both, +} + +/// Abstract base class for hierarchy search queries. +/// +/// A [HierarchyQuery] encapsulates the *matching strategy* (how names are +/// compared) independently of the *tree traversal* (which is always +/// performed by [HierarchyService]). +/// +/// ## Contract with [HierarchyService] +/// +/// The service walks the [HierarchyOccurrence] tree depth-first. +/// At each node it calls: +/// +/// 1. [matchOccurrence] — does this occurrence name satisfy the query at the +/// current match state? Returns a set of successor states (empty = +/// prune this branch). +/// 2. [matchSignal] — does this signal name satisfy the query at the +/// current match state? +/// 3. [isComplete] — have all parts of the query been consumed at the +/// given state? +/// +/// "Match state" is an opaque integer that the query owns. It typically +/// tracks how many segments/tokens of the query have been consumed so far. +/// The initial state is always `0`. +/// +/// ## Crossing hierarchy boundaries +/// +/// If [crossesBoundaries] is true the service will, at each depth, +/// additionally try advancing with the *current* state even when the +/// occurrence doesn't match — allowing matches to span across +/// intermediate hierarchy levels (like `**` in glob patterns). +/// +/// ## Subclassing +/// +/// Implement a concrete query by overriding at least [matchOccurrence], +/// [matchSignal], [isComplete], and [segmentCount]. +/// +/// The factory [HierarchyQuery.prefix] creates the default +/// prefix-substring query. [HierarchyQuery.regex] creates a +/// regex/glob query. +/// +/// ```dart +/// // Custom fuzzy query +/// class FuzzyQuery extends HierarchyQuery { +/// FuzzyQuery(String rawQuery) +/// : super(rawQuery, target: SearchTarget.signals); +/// ... +/// } +/// ``` +abstract class HierarchyQuery { + /// The original user-supplied query string. + final String rawQuery; + + /// What this query matches — occurrences, signals, or both. + final SearchTarget target; + + /// Whether this query can match across hierarchy boundaries. + /// + /// When true, the tree walker will try the current match state at + /// deeper levels even when intermediate occurrences don't match. + /// Conceptually equivalent to an implicit `**` between segments. + final bool crossesBoundaries; + + /// Creates a query from [rawQuery]. + /// + /// Subclasses should parse/compile the query in their constructor. + const HierarchyQuery( + this.rawQuery, { + this.target = SearchTarget.signals, + this.crossesBoundaries = false, + }); + + /// Number of logical segments in the parsed query. + /// + /// Used by the tree walker to know when the query is fully consumed. + int get segmentCount; + + /// Whether the query is empty / trivial (should return no results). + bool get isEmpty => rawQuery.trim().isEmpty; + + /// Try matching an occurrence name at match state [stateIndex]. + /// + /// Returns a set of successor states. Multiple successors arise when + /// the query is ambiguous at this point (e.g. a glob-star `**` can + /// consume zero or more levels). + /// + /// An empty set means "no match — prune this subtree". + Set matchOccurrence(String occurrenceName, int stateIndex); + + /// Whether [signalName] matches the query at state [stateIndex]. + /// + /// Only called when [target] includes signals. + bool matchSignal(String signalName, int stateIndex); + + /// Whether the query is fully consumed at [stateIndex]. + /// + /// Returns true when all segments have been matched and the current + /// tree position is a valid result. + bool isComplete(int stateIndex); + + // ──────────────── Built-in query factories ──────────────── + + /// Create a **prefix-substring** query. + /// + /// The query is split on `/` or `.` into segments. Each segment is + /// matched via `startsWith` (for signals) or + /// `contains` (for occurrences) against names at successive depths. + factory HierarchyQuery.prefix( + String rawQuery, { + SearchTarget target, + }) = PrefixQuery; + + /// Create a **regex/glob** query. + /// + /// Segments are separated by `/`. Each segment is compiled as a + /// case-sensitive regex anchored to the full name. The special + /// segment `**` matches zero or more hierarchy levels. + /// + /// Glob wildcards `*` and `?` are auto-converted to regex equivalents. + factory HierarchyQuery.regex( + String rawQuery, { + SearchTarget target, + }) = RegexQuery; +} diff --git a/packages/rohd_hierarchy/lib/src/hierarchy_search_controller.dart b/packages/rohd_hierarchy/lib/src/hierarchy_search_controller.dart new file mode 100644 index 000000000..a82375b4b --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_search_controller.dart @@ -0,0 +1,216 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_search_controller.dart +// Pure Dart controller for hierarchy search list navigation. +// +// 2026 February +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; + +/// Pure Dart controller for hierarchy search list navigation. +/// +/// Manages search results and keyboard-style list selection without +/// any Flutter dependency. Widgets call controller methods, then +/// refresh their own UI (e.g. `setState`). +/// +/// Generic over the result type [R] — typically [SignalSearchResult] +/// or [OccurrenceSearchResult]. +/// +/// ```dart +/// // In a Flutter widget: +/// final controller = HierarchySearchController.forSignals(hierarchy); +/// +/// void _onSearchChanged() { +/// controller.updateQuery(_textController.text); +/// setState(() {}); +/// } +/// ``` +class HierarchySearchController { + /// The search function that produces results from a normalised query. + final List Function(String normalizedQuery) _searchFn; + + /// Normalises a raw user query (e.g. replaces `.` with `/`). + final String Function(String rawQuery) _normalizeFn; + + List _results = []; + int _selectedIndex = 0; + + /// Create a controller with custom search and normalise functions. + HierarchySearchController({ + required List Function(String normalizedQuery) searchFn, + required String Function(String rawQuery) normalizeFn, + }) : _searchFn = searchFn, + _normalizeFn = normalizeFn; + + /// Create a controller for **signal** search on the given + /// [HierarchyService]. + /// + /// When the query contains glob/regex metacharacters, normalisation + /// is skipped so that `.` keeps its regex meaning (use `/` as the + /// hierarchy separator in regex patterns). + factory HierarchySearchController.forSignals( + HierarchyService hierarchy, + ) => + HierarchySearchController( + searchFn: (q) => hierarchy.searchSignals(q) as List, + normalizeFn: (q) => HierarchyService.hasRegexChars(q) + ? q + : HierarchySearchResult.normalizeQuery(q), + ); + + /// Create a controller for **occurrence** search on the given + /// [HierarchyService]. + /// + /// When the query contains glob/regex metacharacters, normalisation + /// is skipped so that `.` keeps its regex meaning (use `/` as the + /// hierarchy separator in regex patterns). + factory HierarchySearchController.forOccurrences( + HierarchyService hierarchy, + ) => + HierarchySearchController( + searchFn: (q) => hierarchy.searchOccurrences(q) as List, + normalizeFn: (q) => HierarchyService.hasRegexChars(q) + ? q + : HierarchySearchResult.normalizeQuery(q), + ); + + // ─────────────── State accessors ─────────────── + + /// The current search results. + List get results => _results; + + /// Index of the currently highlighted result. + int get selectedIndex => _selectedIndex; + + /// Whether there are any results. + bool get hasResults => _results.isNotEmpty; + + /// A human-readable counter string, e.g. `"3/12"`, or empty when + /// there are no results. + String get counterText => + hasResults ? '${_selectedIndex + 1}/${_results.length}' : ''; + + /// The currently selected result, or `null` if the list is empty. + R? get currentSelection => _results.isEmpty ? null : _results[_selectedIndex]; + + // ─────────────── Mutations ─────────────── + + /// Update search results for [rawQuery]. + /// + /// Normalises the query, runs the search function, and resets the + /// selection to the first result. The caller should rebuild its UI + /// after calling this. + void updateQuery(String rawQuery) { + if (rawQuery.isEmpty) { + _results = []; + _selectedIndex = 0; + return; + } + final normalized = _normalizeFn(rawQuery); + _results = _searchFn(normalized); + _selectedIndex = 0; + } + + /// Move selection to the next result, wrapping around. + void selectNext() { + if (_results.isEmpty) { + return; + } + _selectedIndex = (_selectedIndex + 1) % _results.length; + } + + /// Move selection to the previous result, wrapping around. + void selectPrevious() { + if (_results.isEmpty) { + return; + } + _selectedIndex = (_selectedIndex - 1 + _results.length) % _results.length; + } + + /// Move selection to a specific [index]. + /// + /// Clamps to valid range. Useful for tap-to-select in a list view. + void selectAt(int index) { + if (_results.isEmpty) { + return; + } + _selectedIndex = index.clamp(0, _results.length - 1); + } + + /// Clear all results and reset the selection index. + void clear() { + _results = []; + _selectedIndex = 0; + } + + // ─────────────── Tab-completion ─────────────── + + /// Compute the tab-completion expansion for [currentQuery]. + /// + /// Finds the longest common prefix of all current result display paths + /// and returns it if it is strictly longer than [currentQuery]. + /// Returns `null` when there is nothing to expand. + /// + /// [displayPath] extracts the comparable path string from each result. + /// The default implementation handles [SignalSearchResult] and + /// [OccurrenceSearchResult] automatically; pass a custom extractor for + /// other result types. + String? tabComplete( + String currentQuery, { + String Function(R result)? displayPath, + }) { + if (_results.isEmpty) { + return null; + } + + final extractor = displayPath ?? _defaultDisplayPath; + final paths = _results.map(extractor).toList(); + final prefix = HierarchyService.longestCommonPrefix(paths); + if (prefix == null) { + return null; + } + + // Normalise the query the same way UpdateQuery does so lengths are + // comparable (e.g. dots → slashes). + final normalizedQuery = _normalizeFn(currentQuery); + if (prefix.length <= normalizedQuery.length) { + return null; + } + return prefix; + } + + /// Default display-path extractor for the well-known result types. + static String _defaultDisplayPath(T result) { + if (result is HierarchySearchResult) { + return result.displayPath; + } + return result.toString(); + } + + // ─────────────── Scroll helper ─────────────── + + /// Compute the scroll offset needed to reveal the selected item in a + /// fixed-height list. + /// + /// Returns `null` if the item is already visible. The caller should + /// call `scrollController.jumpTo(offset)` with the returned value. + /// + /// This is a pure calculation with no Flutter dependency. + static double? scrollOffsetToReveal({ + required int selectedIndex, + required double itemHeight, + required double viewportHeight, + required double currentOffset, + }) { + final target = selectedIndex * itemHeight; + if (target < currentOffset) { + return target; + } + if (target + itemHeight > currentOffset + viewportHeight) { + return target + itemHeight - viewportHeight; + } + return null; + } +} diff --git a/packages/rohd_hierarchy/lib/src/hierarchy_search_result.dart b/packages/rohd_hierarchy/lib/src/hierarchy_search_result.dart new file mode 100644 index 000000000..3f40b4c82 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_search_result.dart @@ -0,0 +1,60 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_search_result.dart +// Base class for hierarchy search results. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:meta/meta.dart'; + +/// Base class for hierarchy search results. +/// +/// Holds the common fields shared by signal and occurrence search +/// results: a canonical ID string, pre-split path segments, and display +/// helpers that strip the top-level module name. +@immutable +abstract class HierarchySearchResult { + /// The full hierarchical path that was found. + /// Example: `"Top/counter/clk"` or `"Top/CPU/ALU"`. + final String id; + + /// The hierarchical path segments. + /// Example: `["Top", "counter", "clk"]`. + final List path; + + /// Creates a hierarchy search result. + const HierarchySearchResult({required this.id, required this.path}); + + /// The leaf name (last path segment). + String get name => path.isNotEmpty ? path.last : id; + + // ───────────────────── Display helpers ───────────────────── + + /// Display path with the top-level module name stripped. + /// + /// For `Top/counter/clk` this returns `counter/clk`. + /// For a single-segment path returns the original [id]. + String get displayPath => displaySegments.join('/'); + + /// Path segments with the top-level module name stripped. + /// + /// For `["Top", "counter", "clk"]` returns `["counter", "clk"]`. + List get displaySegments => path.length > 1 ? path.sublist(1) : path; + + /// Normalize a user query for hierarchy search. + /// + /// Converts common separators (`.`) to the canonical `/` separator. + static String normalizeQuery(String query) => query.replaceAll('.', '/'); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is HierarchySearchResult && + runtimeType == other.runtimeType && + id == other.id; + + @override + int get hashCode => id.hashCode; +} diff --git a/packages/rohd_hierarchy/lib/src/hierarchy_service.dart b/packages/rohd_hierarchy/lib/src/hierarchy_service.dart new file mode 100644 index 000000000..08c2add90 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/hierarchy_service.dart @@ -0,0 +1,872 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_service.dart +// Abstract interface for source-agnostic hardware hierarchy navigation. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/src/hierarchy_models.dart'; + +/// Default path separator used when constructing paths from the tree. +const String _hierarchySeparator = '/'; + +/// A source-agnostic interface for navigating hardware hierarchy. +/// +/// All search and navigation is driven by walking the [HierarchyOccurrence] +/// tree. Occurrences hold their [HierarchyOccurrence.name], +/// [HierarchyOccurrence.children], and [HierarchyOccurrence.signals]. Full +/// paths are constructed on the fly by joining names with [_hierarchySeparator] +/// — no pre-baked path strings are needed for search. +/// +/// Key methods: +/// - [searchSignals] — incremental signal search +/// - [searchOccurrences] — find occurrences by name +/// - [matchOccurrences] — find occurrences, returning [HierarchyOccurrence] +/// objects +/// - [autocompletePaths] — incremental path completion +abstract mixin class HierarchyService { + /// The root occurrence for the hierarchy. + HierarchyOccurrence get root; + + /// Maximum number of results returned by search methods when no explicit + /// `limit` is provided. + static const int _defaultSearchLimit = 100; + + // ───────────── Address-based occurrence/signal lookup ──────────────── + + /// Find an occurrence by its [OccurrenceAddress]. O(depth). + HierarchyOccurrence? occurrenceByAddress(OccurrenceAddress address) => + address.path.fold( + root, + (node, idx) => node != null && idx >= 0 && idx < node.children.length + ? node.children[idx] + : null); + + /// Find a signal by its [OccurrenceAddress]. + /// + /// The parent portion of [address] navigates to the owning occurrence; + /// the last index selects the signal within that occurrence. O(depth). + SignalOccurrence? signalByAddress(OccurrenceAddress address) { + if (address.path.isEmpty) { + return null; + } + final node = occurrenceByAddress( + OccurrenceAddress(address.path.sublist(0, address.path.length - 1))); + final sigIdx = address.path.last; + return (node != null && sigIdx >= 0 && sigIdx < node.signals.length) + ? node.signals[sigIdx] + : null; + } + + // ───────────── Address ↔ pathname conversion ────────────────── + + /// Convert a pathname (e.g. `"Top/sub/clk"` or `"Top.sub.clk"`) to a + /// [OccurrenceAddress] by walking the tree. + /// + /// Delegates to [OccurrenceAddress.tryFromPathname]. + OccurrenceAddress? pathnameToAddress(String pathname) => + OccurrenceAddress.tryFromPathname(pathname, root); + + /// Resolve a `/`-separated pathname to a [HierarchyOccurrence]. + /// + /// Convenience that composes [pathnameToAddress] and [occurrenceByAddress]. + /// Returns `null` when [pathname] does not match any occurrence in the + /// tree. + HierarchyOccurrence? occurrenceByPathname(String pathname) { + final addr = pathnameToAddress(pathname); + return addr == null ? null : occurrenceByAddress(addr); + } + + /// Convert a [OccurrenceAddress] back to a `/`-separated pathname by + /// walking the tree using child indices. + /// + /// Returns `null` if the address doesn't resolve in the current tree + /// (e.g. out-of-bounds indices). O(depth). + /// + /// For signal addresses, the last index is resolved as a signal within + /// the parent occurrence. For pure occurrence addresses, every index + /// is a child. + /// + /// Set [asSignal] to `true` when you know the address points to a signal + /// (the last index is a signal offset rather than a child offset). + /// When `false` (default), all indices are treated as child offsets. + String? addressToPathname(OccurrenceAddress address, + {bool asSignal = false}) { + if (address.path.isEmpty) { + return root.name; + } + + final indices = address.path; + final moduleEndIdx = asSignal ? indices.length - 1 : indices.length; + + final walked = indices + .sublist(0, moduleEndIdx) + .fold<({List parts, HierarchyOccurrence node})?>(( + parts: [root.name], + node: root, + ), (cur, idx) { + if (cur == null || idx < 0 || idx >= cur.node.children.length) { + return null; + } + final child = cur.node.children[idx]; + return (parts: [...cur.parts, child.name], node: child); + }); + if (walked == null) { + return null; + } + + if (asSignal && indices.isNotEmpty) { + final sigIdx = indices.last; + return (sigIdx >= 0 && sigIdx < walked.node.signals.length) + ? [...walked.parts, walked.node.signals[sigIdx].name] + .join(_hierarchySeparator) + : null; + } + return walked.parts.join(_hierarchySeparator); + } + + /// Resolve a waveform-style ID (dot-separated, e.g. `"dut.adder.clk"`) + /// to a [OccurrenceAddress]. + /// + /// Normalises `.` → `/` then delegates to [pathnameToAddress]. + OccurrenceAddress? waveformIdToAddress(String waveformId) => + pathnameToAddress(waveformId); + + // ───────────────────── Search / autocomplete ───────────────────── + + /// Find hierarchical signal paths matching [query]. + /// + /// Walks the tree, matching name segments incrementally. When the last + /// query segment partially matches a signal name at or below the current + /// node the full path is returned (e.g. `Top/block/signal`). + /// + /// Returns up to [limit] results. + List searchSignalPaths(String query, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + if (query.trim().isEmpty) { + return const []; + } + final parts = _splitPath(query); + final results = []; + _searchSignalsRecursive( + root, [root.name], parts, 0, results, effectiveLimit); + return results; + } + + /// Whether [query] contains glob or regex metacharacters that should + /// trigger the regex search engine instead of the plain substring search. + static bool hasRegexChars(String query) => + query.contains('*') || + query.contains('?') || + query.contains('[') || + query.contains('(') || + query.contains('|') || + query.contains('+'); + + /// Check if an occurrence or any of its descendants match [searchTerm]. + /// + /// The search term is split on `/` or `.` into hierarchical segments. + /// Each segment is matched via substring containment + /// against occurrence names at successive depths. + /// + /// Returns `true` if [searchTerm] is null/empty, or if the occurrence + /// (or a descendant) matches all segments in order. + /// + /// This is useful for tree-view filtering: show an occurrence only when + /// it or one of its descendants matches the user's query. + static bool isOccurrenceMatching( + HierarchyOccurrence node, String? searchTerm) { + if (searchTerm == null || searchTerm.isEmpty) { + return true; + } + + final normalizedQuery = searchTerm.replaceAll('.', '/'); + final queryParts = normalizedQuery + .split('/') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + + return _isOccurrenceMatchingRecursive(node, queryParts, 0); + } + + static bool _isOccurrenceMatchingRecursive( + HierarchyOccurrence node, List queryParts, int queryIdx) { + if (queryIdx >= queryParts.length) { + return true; + } + + final currentQueryPart = queryParts[queryIdx]; + final nodeName = node.name; + + final matched = nodeName.contains(currentQueryPart); + final nextQueryIdx = matched ? queryIdx + 1 : queryIdx; + + if (nextQueryIdx >= queryParts.length) { + return true; + } + + return node.children.any((child) => + _isOccurrenceMatchingRecursive(child, queryParts, nextQueryIdx)); + } + + /// Search for signals and return enriched [SignalSearchResult] objects. + /// + /// Automatically dispatches to [searchSignalsRegex] when the query + /// contains glob or regex metacharacters (`*`, `?`, `[`, `(`, `|`, + /// `+`). Otherwise uses [searchSignalPaths] for prefix-based matching. + List searchSignals(String query, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + if (hasRegexChars(query)) { + final pattern = (query.startsWith('**/') || query.startsWith('*/')) + ? query + : '*/$query'; + return searchSignalsRegex(pattern, limit: effectiveLimit); + } + return _toSignalResults(searchSignalPaths(query, limit: effectiveLimit)); + } + + /// Find hierarchical occurrence paths matching [query]. + /// + /// Similar to [searchSignalPaths] but for occurrences instead of + /// signals. Walks the tree, matching name segments incrementally. When + /// the query segments match occurrence names at or below the current + /// level the full path is returned (e.g. `Top/CPU/ALU`). + /// + /// Returns up to [limit] results. + List searchOccurrencePaths(String query, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + if (query.trim().isEmpty) { + return const []; + } + final parts = _splitPath(query); + final results = []; + _searchOccurrencePathsRecursive( + root, [root.name], parts, 0, results, effectiveLimit); + return results; + } + + /// Find hierarchy occurrences whose path matches [query]. + /// + /// Like [searchOccurrencePaths] but returns the [HierarchyOccurrence] objects + /// themselves instead of path strings. + List matchOccurrences(String query, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + if (query.trim().isEmpty) { + return const []; + } + final parts = _splitPath(query); + final results = []; + _matchOccurrencesRecursive(root, parts, 0, results, effectiveLimit); + return results; + } + + /// Autocomplete suggestions for a partial hierarchical path. + /// + /// The partial path is split into segments. Completed segments navigate + /// down the tree; the final (possibly empty) segment is used as a prefix + /// filter on children at that level. Returns up to [limit] full paths + /// (with `/` appended for nodes that have children). + List autocompletePaths(String partialPath, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + final normalized = partialPath.replaceAll('.', _hierarchySeparator); + final endsWithSep = normalized.endsWith(_hierarchySeparator); + final parts = _splitPath(partialPath); + + // Navigate to the deepest complete segment. + var current = root; + final completedParts = [root.name]; + + final navParts = endsWithSep || parts.isEmpty + ? parts + : parts.sublist(0, parts.length - 1); + for (final seg in navParts) { + // If the segment matches the current node name, stay at this level + // (handles the root name appearing as the first path segment). + if (current.name == seg) { + continue; + } + final child = current.children.where((c) => c.name == seg).firstOrNull; + if (child == null) { + return const []; + } + current = child; + completedParts.add(child.name); + } + + // The trailing prefix to filter on (empty if path ends with separator). + final prefix = (endsWithSep || parts.isEmpty) ? '' : parts.last; + + final suggestions = []; + + // When the prefix matches the current (root-level) node itself and we + // haven't navigated past it, suggest the root path so that typing a + // partial root name produces a completion. + if (prefix.isNotEmpty && + completedParts.length == 1 && + current == root && + current.name.startsWith(prefix)) { + final rootPath = current.name; + suggestions.add(current.children.isNotEmpty + ? '$rootPath$_hierarchySeparator' + : rootPath); + } + + for (final child in current.children) { + if (prefix.isEmpty || child.name.startsWith(prefix)) { + final path = [...completedParts, child.name].join(_hierarchySeparator); + suggestions.add( + child.children.isNotEmpty ? '$path$_hierarchySeparator' : path); + if (suggestions.length >= effectiveLimit) { + break; + } + } + } + return suggestions; + } + + /// Search for occurrences and return enriched + /// [OccurrenceSearchResult] objects. + /// + /// Automatically dispatches to [searchOccurrencesRegex] when the query + /// contains glob or regex metacharacters (`*`, `?`, `[`, `(`, `|`, + /// `+`). Otherwise uses [searchOccurrencePaths] for prefix-based matching. + List searchOccurrences(String query, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + if (hasRegexChars(query)) { + final pattern = (query.startsWith('**/') || query.startsWith('*/')) + ? query + : '**/$query'; + return searchOccurrencesRegex(pattern, limit: effectiveLimit); + } + return _toOccurrenceResults( + searchOccurrencePaths(query, limit: effectiveLimit)); + } + + // ───────────────── Regex search ───────────────── + + /// Search for signals whose hierarchical path matches a regex [pattern]. + /// + /// The pattern is split on `/` or `.` into segments. Each segment is + /// compiled as a [RegExp] and matched against the + /// corresponding depth in the hierarchy tree. Special segments: + /// + /// - `**` — matches zero or more hierarchy levels (glob-star). Use this + /// to search across hierarchy boundaries, e.g. `Top/**/clk` finds + /// `Top/CPU/ALU/clk`, `Top/Memory/clk`, etc. + /// - Any other string is compiled as a regex anchored to the full name + /// (`^…$`). Plain names therefore match exactly and regex meta- + /// characters like `.*`, `[0-9]+`, etc. work as expected. + /// + /// Returns up to [limit] full hierarchical signal paths. + /// + /// Examples: + /// ```text + /// 'Top/CPU/clk' — exact match at each level + /// 'Top/CPU/.*' — all signals in Top/CPU + /// 'Top/.*/clk' — clk signal one level below Top + /// 'Top/**/clk' — clk signal at any depth below Top + /// 'Top/**/c.*' — signals starting with 'c' at any depth + /// '**/(clk|reset)' — clk or reset anywhere in hierarchy + /// 'Top/CPU/d[0-9]+' — signals like d0, d1, d12 in Top/CPU + /// ``` + List searchSignalPathsRegex(String pattern, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + if (pattern.trim().isEmpty) { + return const []; + } + final segments = _splitRegexPattern(pattern); + final compiled = _compileSegments(segments); + final results = []; + _searchSignalsRegex( + root, [root.name], compiled, 0, results, effectiveLimit); + return results; + } + + /// Search for signals by regex pattern and return enriched results. + List searchSignalsRegex(String pattern, {int? limit}) => + _toSignalResults(searchSignalPathsRegex(pattern, limit: limit)); + + /// Search for occurrence paths matching a regex [pattern]. + /// + /// Same segment syntax as [searchSignalPathsRegex] but matches + /// occurrences instead of signals. + /// + /// Returns up to [limit] full hierarchical occurrence paths. + List searchOccurrencePathsRegex(String pattern, {int? limit}) { + final effectiveLimit = limit ?? _defaultSearchLimit; + if (pattern.trim().isEmpty) { + return const []; + } + final segments = _splitRegexPattern(pattern); + final compiled = _compileSegments(segments); + final results = []; + _matchOccurrencesRegex( + root, [root.name], compiled, 0, results, effectiveLimit); + return results; + } + + /// Search for occurrences by regex pattern and return enriched results. + List searchOccurrencesRegex(String pattern, + {int? limit}) => + _toOccurrenceResults(searchOccurrencePathsRegex(pattern, limit: limit)); + + // ─────────────────── Utility helpers ─────────────────── + + /// Returns the longest common prefix shared by all [paths]. + /// + /// Comparison is case-sensitive. Returns `null` when [paths] is empty + /// or no common prefix exists. + static String? longestCommonPrefix(List paths) { + if (paths.isEmpty) { + return null; + } + final prefix = paths.skip(1).fold(paths.first, (pre, s) { + if (pre == null || pre.isEmpty) { + return null; + } + final end = pre.length < s.length ? pre.length : s.length; + final j = + Iterable.generate(end).takeWhile((i) => pre[i] == s[i]).length; + return j > 0 ? pre.substring(0, j) : null; + }); + return prefix; + } + + // ─────────────────── Private helpers ─────────────────── + + /// Split a query or path on `/` or `.` into non-empty segments. + static List _splitPath(String input) => input + .replaceAll('.', _hierarchySeparator) + .split(_hierarchySeparator) + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + + /// Split a path on `/` or `.` into non-empty segments, preserving case. + /// + /// Use this when the result is for display or building [SignalSearchResult] + /// path parts — not for matching. + static List _splitPathPreserveCase(String input) => input + .replaceAll('.', _hierarchySeparator) + .split(_hierarchySeparator) + .where((s) => s.isNotEmpty) + .toList(); + + /// Enrich signal paths into [SignalSearchResult] objects. + List _toSignalResults(List paths) => + paths.map((fullPath) { + final addr = OccurrenceAddress.tryFromPathname(fullPath, root); + return SignalSearchResult( + signalId: fullPath, + path: _splitPathPreserveCase(fullPath), + signal: addr != null ? signalByAddress(addr) : null, + ); + }).toList(); + + /// Enrich occurrence paths into [OccurrenceSearchResult] objects. + List _toOccurrenceResults(List paths) => + paths.map((fullPath) { + final addr = OccurrenceAddress.tryFromPathname(fullPath, root); + return OccurrenceSearchResult( + occurrenceId: fullPath, + path: _splitPathPreserveCase(fullPath), + occurrence: (addr != null ? occurrenceByAddress(addr) : null) ?? root, + ); + }).toList(); + + /// Recursively search for signals matching query parts. + /// + /// Walks the tree maintaining the path of names. When the accumulated match + /// depth reaches the query length, checks signals at that node. Partial + /// last-segment matching also checks signals at partially-matched nodes. + /// + /// Uses [HierarchyOccurrence.children] and [HierarchyOccurrence.signals] + /// directly. + void _searchSignalsRecursive( + HierarchyOccurrence node, + List pathSoFar, + List queryParts, + int qIdx, + List results, + int limit, + ) { + if (results.length >= limit) { + return; + } + + // Try matching current node name against current query part + final nodeName = node.name; + final currentQuery = qIdx < queryParts.length ? queryParts[qIdx] : null; + final matched = currentQuery != null && nodeName.startsWith(currentQuery); + final nextIdx = matched ? qIdx + 1 : qIdx; + + // Determine how many query parts remain after any node-name match. + final remaining = queryParts.length - nextIdx; + + // If 0 or 1 query parts remain, search signals at this node. + if (remaining <= 1) { + // When the current node consumed the last segment (remaining==0, + // matched==true), reuse that segment as the signal filter so that + // e.g. "a" doesn't return every signal under a module named "alu". + // When remaining==0 because we're recursing into a subtree where + // a parent already consumed all segments, use empty (return all). + final signalQuery = remaining == 1 + ? queryParts[nextIdx] + : (matched && qIdx < queryParts.length ? queryParts[qIdx] : ''); + for (final signal in node.signals) { + if (results.length >= limit) { + return; + } + if (signalQuery.isEmpty || signal.name.startsWith(signalQuery)) { + final fullPath = + [...pathSoFar, signal.name].join(_hierarchySeparator); + results.add(fullPath); + } + } + } + + // Recurse into children + for (final child in node.children) { + if (results.length >= limit) { + return; + } + _searchSignalsRecursive( + child, + [...pathSoFar, child.name], + queryParts, + nextIdx, + results, + limit, + ); + } + } + + /// Recursively search for occurrences matching query parts. + /// + /// Similar to [_searchSignalsRecursive] but matches occurrences instead + /// of signals. Walks the tree maintaining the path of names. When the + /// query segments match occurrence names, adds them to results. + void _searchOccurrencePathsRecursive( + HierarchyOccurrence node, + List pathSoFar, + List queryParts, + int qIdx, + List results, + int limit, + ) { + if (results.length >= limit) { + return; + } + + // Try matching current node name against current query part + final nodeName = node.name; + final currentQuery = qIdx < queryParts.length ? queryParts[qIdx] : null; + final matched = currentQuery != null && nodeName.contains(currentQuery); + final nextIdx = matched ? qIdx + 1 : qIdx; + + // If all query parts are matched, this node is a result + if (nextIdx >= queryParts.length) { + final fullPath = pathSoFar.join(_hierarchySeparator); + results.add(fullPath); + if (results.length >= limit) { + return; + } + } + + // Recurse into children + for (final child in node.children) { + if (results.length >= limit) { + return; + } + _searchOccurrencePathsRecursive( + child, + [...pathSoFar, child.name], + queryParts, + nextIdx, + results, + limit, + ); + } + } + + /// Recursively search for occurrences matching query parts, returning + /// the occurrences. + void _matchOccurrencesRecursive( + HierarchyOccurrence node, + List queryParts, + int qIdx, + List results, + int limit) { + if (results.length >= limit) { + return; + } + + final matched = + qIdx < queryParts.length && node.name.contains(queryParts[qIdx]); + final nextIdx = matched ? qIdx + 1 : qIdx; + + if (nextIdx >= queryParts.length) { + results.add(node); + if (results.length >= limit) { + return; + } + } + + for (final child in node.children) { + _matchOccurrencesRecursive(child, queryParts, nextIdx, results, limit); + if (results.length >= limit) { + return; + } + } + } + + // ─────────────── Regex search helpers ─────────────── + + /// A compiled regex segment. `isGlobStar` indicates a `**` segment that + /// matches zero or more hierarchy levels. + static const _globStarSentinel = '**'; + + /// Split `pattern` into segments on `/` only. + /// + /// Unlike [_splitPath] (which also splits on `.`), regex patterns use only + /// `/` as the hierarchy separator because `.` has meaning inside regular + /// expressions (e.g. `.*`, `a.b`). + List _splitRegexPattern(String input) => + input.split('/').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); + + /// Convert glob-style `*` and `?` wildcards to regex equivalents. + /// + /// A standalone `*` (not preceded/followed by another regex metachar) + /// becomes `.*` (match anything). `?` becomes `.` (match one char). + /// This lets users write natural patterns like `*m`, `clk*`, `*data*` + /// without needing to know regex syntax. + String _globToRegex(String segment) { + final buf = StringBuffer(); + for (var i = 0; i < segment.length; i++) { + final c = segment[i]; + if (c == '*') { + // If already preceded by `.` (i.e. user wrote `.*`), skip conversion. + if (buf.toString().endsWith('.')) { + buf.write('*'); + } else { + buf.write('.*'); + } + } else if (c == '?') { + // If already preceded by a valid quantifier target, keep literal `?`. + // Otherwise treat as single-char wildcard `.`. + if (i > 0 && !'.?*+'.contains(segment[i - 1])) { + buf.write('?'); + } else { + buf.write('.'); + } + } else { + buf.write(c); + } + } + return buf.toString(); + } + + /// Compile string segments into [_RegexSegment] list. + /// + /// Each segment is first run through [_globToRegex] so that glob-style + /// wildcards (`*`, `?`) work alongside full regex syntax. + List<_RegexSegment> _compileSegments(List segments) => + segments.map((s) { + if (s == _globStarSentinel) { + return _RegexSegment.globStar(); + } + final pattern = _globToRegex(s); + // Anchor the regex to match the full name. + return _RegexSegment(RegExp('^$pattern\$')); + }).toList(); + + /// Recursive signal search driven by compiled regex segments. + /// + /// [segIdx] is the index into [segments] that we are currently trying to + /// match at this tree depth. + void _searchSignalsRegex( + HierarchyOccurrence node, + List pathSoFar, + List<_RegexSegment> segments, + int segIdx, + List results, + int limit, + ) { + if (results.length >= limit) { + return; + } + + // Determine how many segments remain after consuming the current node. + final consumed = _matchNode(node.name, segments, segIdx); + + for (final nextIdx in consumed) { + if (results.length >= limit) { + return; + } + + // Try to match signals at this node. + // Find all indices reachable from nextIdx by skipping glob-stars + // where a signal-level regex (or end-of-pattern) can be applied. + for (final sigIdx in _signalReachableIndices(segments, nextIdx)) { + if (results.length >= limit) { + return; + } + if (sigIdx >= segments.length) { + // All segments consumed: collect all signals at this node. + for (final signal in node.signals) { + if (results.length >= limit) { + return; + } + results.add([...pathSoFar, signal.name].join(_hierarchySeparator)); + } + } else { + // sigIdx points to a non-** regex that should match signal names. + final sigSeg = segments[sigIdx]; + // Only use as signal-level match if this is the last non-** segment + // (possibly followed by more **'s that can match zero levels). + if (_allGlobStarAfter(segments, sigIdx + 1)) { + for (final signal in node.signals) { + if (results.length >= limit) { + return; + } + if (sigSeg.regex!.hasMatch(signal.name)) { + results + .add([...pathSoFar, signal.name].join(_hierarchySeparator)); + } + } + } + } + } + + // Recurse into children. + for (final child in node.children) { + if (results.length >= limit) { + return; + } + _searchSignalsRegex( + child, + [...pathSoFar, child.name], + segments, + nextIdx, + results, + limit, + ); + } + } + } + + /// Recursive occurrence search driven by compiled regex segments. + void _matchOccurrencesRegex( + HierarchyOccurrence node, + List pathSoFar, + List<_RegexSegment> segments, + int segIdx, + List results, + int limit, + ) { + if (results.length >= limit) { + return; + } + + final consumed = _matchNode(node.name, segments, segIdx); + + for (final nextIdx in consumed) { + if (results.length >= limit) { + return; + } + + // All segments consumed (or only trailing **'s remain) → match. + if (_allGlobStarAfter(segments, nextIdx)) { + results.add(pathSoFar.join(_hierarchySeparator)); + if (results.length >= limit) { + return; + } + } + + // Recurse into children. + for (final child in node.children) { + if (results.length >= limit) { + return; + } + _matchOccurrencesRegex( + child, + [...pathSoFar, child.name], + segments, + nextIdx, + results, + limit, + ); + } + } + } + + /// Try to match [nodeName] against the segment at [segIdx]. + /// + /// Returns a set of possible next-segment indices (branching is needed + /// because `**` can consume zero or more levels). + Set _matchNode( + String nodeName, List<_RegexSegment> segments, int segIdx) { + final results = {}; + if (segIdx >= segments.length) { + // No more segments to match — nothing to advance to. + return results; + } + + final seg = segments[segIdx]; + + if (seg.isGlobStar) { + // ** matches zero levels (skip the **) … + results + ..addAll(_matchNode(nodeName, segments, segIdx + 1)) + // … or consumes this node and stays at ** (one-or-more levels). + ..add(segIdx); + } else if (seg.regex!.hasMatch(nodeName)) { + results.add(segIdx + 1); + } + // If the segment doesn't match at all, return empty → prune this branch. + return results; + } + + /// Returns indices in [segments] reachable from [fromIdx] by skipping + /// consecutive `**` glob-star segments. Always includes [fromIdx] itself + /// if it is in range (or == segments.length, meaning "past the end"). + Set _signalReachableIndices(List<_RegexSegment> segments, int fromIdx) { + final result = {}; + var i = fromIdx; + // Walk forward: each time we see a **, we can skip it (zero levels). + while (i < segments.length) { + if (segments[i].isGlobStar) { + // ** can match zero levels → skip and also record i (stay at **). + result.add(i + 1); // skip the ** + i++; + } else { + result.add(i); + break; // stop at first non-** segment + } + } + // If we walked past the end, record that too. + if (i >= segments.length) { + result.add(segments.length); + } + return result; + } + + /// Returns true if all segments from [fromIdx] onward are glob-stars + /// (or if [fromIdx] >= length, i.e. no more segments). + bool _allGlobStarAfter(List<_RegexSegment> segments, int fromIdx) => + segments.skip(fromIdx).every((s) => s.isGlobStar); +} + +/// Internal representation of a compiled regex segment. +class _RegexSegment { + final RegExp? regex; + final bool isGlobStar; + + _RegexSegment(this.regex) : isGlobStar = false; + _RegexSegment.globStar() + : regex = null, + isGlobStar = true; +} diff --git a/packages/rohd_hierarchy/lib/src/netlist_hierarchy_adapter.dart b/packages/rohd_hierarchy/lib/src/netlist_hierarchy_adapter.dart new file mode 100644 index 000000000..4d181ce5d --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/netlist_hierarchy_adapter.dart @@ -0,0 +1,211 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// netlist_hierarchy_adapter.dart +// Hierarchy adapter for netlist JSON format (derived from Yosys JSON) +// using rohd_hierarchy. +// +// 2026 January +// Author: Desmond Kirkpatrick + +import 'dart:convert'; + +import 'package:rohd_hierarchy/src/base_hierarchy_adapter.dart'; +import 'package:rohd_hierarchy/src/hierarchy_models.dart'; + +/// Adapter that exposes a netlist as a source-agnostic hierarchy. +/// +/// Extends [BaseHierarchyAdapter] from rohd_hierarchy package, using the shared +/// implementation for search, autocomplete, and lookup methods. +/// Only the netlist format-specific +/// JSON parsing logic is implemented here. +/// +/// Features: +/// - Parses ports, netnames, and cells from netlist JSON +/// - Filters auto-generated netnames (`hide_name`, `$`-prefixed, port dupes) +/// - Extracts `port_directions` on primitive cells for signal visibility +/// - Supports optional root-name override for VCD name alignment +class NetlistHierarchyAdapter extends BaseHierarchyAdapter { + NetlistHierarchyAdapter._(); + + /// Convenience factory to parse a netlist JSON string directly. + /// + /// [rootNameOverride] replaces the top-module name derived from the JSON. + /// Use this when VCD scopes use instance names that differ from the + /// definition names in the netlist output (often capitalized). + factory NetlistHierarchyAdapter.fromJson( + String netlistJson, { + String? rootNameOverride, + }) { + final obj = jsonDecode(netlistJson); + if (obj is! Map) { + throw const FormatException('Invalid netlist JSON root'); + } + return NetlistHierarchyAdapter.fromMap( + obj, + rootNameOverride: rootNameOverride, + ); + } + + /// Factory to parse a pre-decoded netlist JSON map. + /// + /// [netlistJson] must contain a top-level `modules` key. + /// [rootNameOverride] optionally replaces the detected top-module name. + factory NetlistHierarchyAdapter.fromMap( + Map netlistJson, { + String? rootNameOverride, + }) { + final adapter = NetlistHierarchyAdapter._() + .._buildFromNetlist(netlistJson, rootNameOverride: rootNameOverride); + return adapter; + } + + void _buildFromNetlist( + Map netlistJson, { + String? rootNameOverride, + }) { + final modules = netlistJson['modules'] as Map?; + if (modules == null || modules.isEmpty) { + throw const FormatException('Netlist JSON contained no modules'); + } + + // Find top module or default to first + final topName = modules.entries + .where((e) => + ((e.value as Map)['attributes'] + as Map?)?['top'] == + 1) + .map((e) => e.key) + .firstOrNull ?? + modules.keys.first; + + final resolvedRootName = rootNameOverride ?? topName; + + final rootNode = _parseModule( + name: resolvedRootName, + moduleData: modules[topName] as Map, + allModules: modules, + ); + root = rootNode; + rootNode.buildAddresses(); + } + + /// Parse a module definition and return the created + /// [HierarchyOccurrence]. + HierarchyOccurrence _parseModule({ + required String name, + required Map moduleData, + required Map allModules, + }) { + // Ports (signals with direction) + final portsData = moduleData['ports'] as Map?; + final signalsList = [ + if (portsData != null) + ...portsData.entries.indexed.map((entry) { + final (idx, kv) = entry; + final p = kv.value as Map; + final dir = p['direction']?.toString() ?? 'inout'; + final bits = (p['bits'] as List?)?.length ?? 0; + return SignalOccurrence( + name: kv.key, + direction: dir, + width: bits > 0 ? bits : 1, + portIndex: idx, + ); + }), + ]; + + // Netnames (internal signals without direction). + // Netlist `netnames` contains ALL named signals including port-connected + // ones. We skip names already covered by `ports` above, as well as + // auto-generated names (hide_name=1 or $-prefixed). + final netsData = moduleData['netnames'] as Map?; + if (netsData != null) { + final portNames = portsData?.keys.toSet() ?? {}; + signalsList.addAll(netsData.entries + .where((entry) => + !portNames.contains(entry.key) && + !entry.key.startsWith(r'$') && + () { + final h = (entry.value as Map)['hide_name']; + return h != 1 && h != '1'; + }()) + .map((entry) { + final netData = entry.value as Map; + final bits = (netData['bits'] as List?)?.length ?? 0; + final attrs = netData['attributes'] as Map?; + final isComputed = + attrs?['computed'] == 1 || attrs?['computed'] == true; + return SignalOccurrence( + name: entry.key, + width: bits > 0 ? bits : 1, + isComputed: isComputed, + ); + })); + } + + // Cells -> submodules or instances + final childNodes = []; + final cells = moduleData['cells'] as Map?; + if (cells != null) { + for (final entry in cells.entries) { + final cellName = entry.key; + final cellData = entry.value as Map; + final cellType = cellData['type']?.toString() ?? ''; + + if (allModules.containsKey(cellType) && + !HierarchyOccurrence.isPrimitiveType(cellType)) { + final childNode = _parseModule( + name: cellName, + moduleData: allModules[cellType] as Map, + allModules: allModules, + ); + childNodes.add(childNode); + } else { + // Primitive cell — create leaf occurrence. + // Extract port signals from `port_directions` when available so + // that primitive I/O appears in signal search results. + final isCellComputed = cellType.startsWith(r'$'); + final portDirections = + cellData['port_directions'] as Map?; + final connections = cellData['connections'] as Map?; + final portWidths = cellData['port_widths'] as Map?; + final cellSignals = [ + if (portDirections != null) + ...portDirections.entries.indexed.map((pEntry) { + final (pIdx, kv) = pEntry; + final pName = kv.key; + final pDir = kv.value.toString(); + final bits = (connections?[pName] as List?)?.length ?? + (portWidths?[pName] as int?) ?? + 1; + return SignalOccurrence( + name: pName, + direction: pDir, + width: bits, + isComputed: isCellComputed, + portIndex: pIdx, + ); + }), + ]; + + final instNode = HierarchyOccurrence( + name: cellName, + definition: cellType, + isPrimitive: true, + signals: cellSignals, + ); + childNodes.add(instNode); + } + } + } + + // Create the occurrence with children and signals embedded + return HierarchyOccurrence( + name: name, + definition: name, + signals: signalsList, + children: childNodes, + ); + } +} diff --git a/packages/rohd_hierarchy/lib/src/occurrence_address.dart b/packages/rohd_hierarchy/lib/src/occurrence_address.dart new file mode 100644 index 000000000..b8a9c8bba --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/occurrence_address.dart @@ -0,0 +1,142 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// occurrence_address.dart +// Efficient hierarchical address using indices instead of strings. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; + +import 'package:rohd_hierarchy/src/hierarchy_occurrence.dart'; + +/// Efficient hierarchical address using indices instead of strings. +/// +/// Format: [index0, index1, ...] or [] for root. +/// Example: [0, 2, 4] means root's 0th child, then 2nd child of that, then +/// the 4th child (occurrence) or 4th signal, depending on context. +/// +/// Advantages: +/// - O(1) address creation (just append index) +/// - O(depth) tree navigation (direct array indexing) +/// - Deterministic serialization (no parsing needed) +/// - Natural alignment with waveform dictionary (integer indices) +/// - Supports hierarchical queries (ancestor matching, batching by prefix) +/// +/// This replaces string-based path lookups with typed, semantic addressing. +@immutable +class OccurrenceAddress { + /// Path through tree as indices stored as immutable list. + /// Empty list represents the root occurrence. + /// Non-empty list: indices navigate through the hierarchy. The last index + /// refers to either a child occurrence or a signal, depending on context. + final List path; + + /// Create a hierarchy address from a path list. + const OccurrenceAddress(this.path); + + /// Root address (empty path). + static const OccurrenceAddress root = OccurrenceAddress([]); + + /// Create a child address by appending an occurrence index. + /// Use this when navigating to a child occurrence. + OccurrenceAddress child(int childIndex) => + OccurrenceAddress([...path, childIndex]); + + /// Create a signal address by appending signal index. + /// Use this when addressing a signal within current occurrence. + OccurrenceAddress signal(int signalIndex) => + OccurrenceAddress([...path, signalIndex]); + + /// Serialize to a dot-separated string suitable for use as a JSON key. + /// + /// Examples: `""` (root), `"0"`, `"0.2.4"`. + /// Round-trips with [OccurrenceAddress.fromDotString]. + String toDotString() => path.join('.'); + + /// Deserialize from a dot-separated string produced by [toDotString]. + /// + /// An empty string returns [root]. + factory OccurrenceAddress.fromDotString(String s) { + if (s.isEmpty) { + return root; + } + return OccurrenceAddress(s.split('.').map(int.parse).toList()); + } + + @override + String toString() { + if (path.isEmpty) { + return '[ROOT]'; + } + return '[${path.join(".")}]'; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is OccurrenceAddress && + const ListEquality().equals(path, other.path); + + @override + int get hashCode => Object.hashAll(path); + + /// Resolve a pathname string (e.g. `"Top/counter/clk"` or + /// `"Top.counter.clk"`) to a [OccurrenceAddress] by walking [root]. + /// + /// Supports both `/` and `.` as separators. If the first segment + /// matches [root]'s name, it is skipped — the root + /// occurrence is always at the empty address. + /// + /// The last segment is first tried as a **signal** name within the + /// current occurrence; if that fails it is tried as a **child** + /// occurrence name. + /// This mirrors the pathname convention where a signal path has one more + /// segment than its parent module path. + /// + /// Returns `null` if any segment cannot be resolved. + /// + /// ```dart + /// final addr = OccurrenceAddress.tryFromPathname('Top/cpu/clk', root); + /// if (addr != null) { + /// final signal = service.signalByAddress(addr); + /// } + /// ``` + static OccurrenceAddress? tryFromPathname( + String pathname, + HierarchyOccurrence root, + ) { + final rootAddr = root.address ?? OccurrenceAddress.root; + final parts = pathname + .replaceAll('.', '/') + .split('/') + .where((s) => s.isNotEmpty) + .toList(); + + // Skip leading segment that matches the root name. + final segments = + parts.isNotEmpty && parts.first == root.name ? parts.skip(1) : parts; + + ({HierarchyOccurrence node, OccurrenceAddress addr})? step( + ({HierarchyOccurrence node, OccurrenceAddress addr})? cur, + String segment, + ) { + if (cur == null) { + return null; + } + final si = cur.node.signalIndexByName(segment); + if (identical(segment, segments.last) && si >= 0) { + return (node: cur.node, addr: cur.addr.signal(si)); + } + final ci = cur.node.childIndexByName(segment); + return ci >= 0 + ? (node: cur.node.children[ci], addr: cur.addr.child(ci)) + : null; + } + + return segments.fold<({HierarchyOccurrence node, OccurrenceAddress addr})?>( + (node: root, addr: rootAddr), step)?.addr; + } +} diff --git a/packages/rohd_hierarchy/lib/src/occurrence_search_result.dart b/packages/rohd_hierarchy/lib/src/occurrence_search_result.dart new file mode 100644 index 000000000..fafe8623c --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/occurrence_search_result.dart @@ -0,0 +1,45 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// occurrence_search_result.dart +// Result of a module/node search with enriched metadata. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:meta/meta.dart'; + +import 'package:rohd_hierarchy/src/hierarchy_occurrence.dart'; +import 'package:rohd_hierarchy/src/hierarchy_search_result.dart'; + +/// Result of an occurrence search with enriched metadata. +/// +/// Contains the occurrence's full path, parsed path segments, and the full +/// [HierarchyOccurrence] object. This mirrors `SignalSearchResult` for +/// occurrences and provides a consistent search results interface. +@immutable +class OccurrenceSearchResult extends HierarchySearchResult { + /// Alias for [id] — the occurrence's full hierarchical path. + String get occurrenceId => id; + + /// The underlying [HierarchyOccurrence] from the hierarchy service. + /// Contains the occurrence's name, type, children, and signals. + final HierarchyOccurrence occurrence; + + /// Creates an occurrence search result. + const OccurrenceSearchResult({ + required String occurrenceId, + required super.path, + required this.occurrence, + }) : super(id: occurrenceId); + + /// Whether this occurrence has sub-hierarchy (i.e. is not a primitive + /// leaf). + bool get isModule => !occurrence.isPrimitive; + + /// Number of direct child occurrences. + int get childCount => occurrence.children.length; + + @override + String toString() => 'OccurrenceSearchResult($id)'; +} diff --git a/packages/rohd_hierarchy/lib/src/prefix_query.dart b/packages/rohd_hierarchy/lib/src/prefix_query.dart new file mode 100644 index 000000000..2ce617683 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/prefix_query.dart @@ -0,0 +1,60 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// prefix_query.dart +// Prefix-substring query implementation for hierarchy search. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/src/hierarchy_query.dart'; + +/// Prefix-substring query: segments are matched via `startsWith` (signals) +/// or `contains` (occurrences) at successive hierarchy depths. +class PrefixQuery extends HierarchyQuery { + /// Non-empty segments parsed from the raw query. + late final List segments; + + /// Create a prefix query from [rawQuery]. + PrefixQuery( + super.rawQuery, { + super.target = SearchTarget.signals, + }) : super(crossesBoundaries: false) { + segments = rawQuery + .replaceAll('.', '/') + .split('/') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + } + + @override + int get segmentCount => segments.length; + + @override + Set matchOccurrence(String occurrenceName, int stateIndex) { + if (stateIndex >= segments.length) { + return {stateIndex}; + } + final name = occurrenceName; + if (name.contains(segments[stateIndex])) { + return {stateIndex + 1}; + } + return const {}; + } + + @override + bool matchSignal(String signalName, int stateIndex) { + if (stateIndex >= segments.length) { + return true; + } + // Only the last segment can match a signal name. + if (stateIndex != segments.length - 1) { + return false; + } + return signalName.startsWith(segments[stateIndex]); + } + + @override + bool isComplete(int stateIndex) => stateIndex >= segments.length; +} diff --git a/packages/rohd_hierarchy/lib/src/regex_query.dart b/packages/rohd_hierarchy/lib/src/regex_query.dart new file mode 100644 index 000000000..6c0ebfe41 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/regex_query.dart @@ -0,0 +1,176 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// regex_query.dart +// Regex/glob query implementation for hierarchy search. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/src/hierarchy_query.dart'; + +/// Regex/glob query: each segment is a compiled regex, with `**` support +/// for crossing hierarchy boundaries. +/// +/// ## Segment syntax +/// +/// The query string is split on `/` into segments. Each segment is +/// independently compiled as a case-insensitive [RegExp] anchored to the +/// full occurrence or signal name (`^…$`). This means: +/// +/// - **Plain names** match exactly: `Top/CPU/clk`. +/// - **Glob wildcards** are auto-converted before compilation: +/// - `*` → `.*` (match any characters) +/// - `?` → `.` (match one character) +/// - These compose naturally: `clk*` matches `clk`, `clk_gated`, +/// `clk_div2`, etc. +/// - **Full regex** is supported within each segment since the string +/// is passed to [RegExp]: +/// - `d[0-9]+` — signals named `d0`, `d1`, `d12`, … +/// - `(clk|reset)` — either `clk` or `reset` +/// - `data_[a-z]{2}` — `data_ab`, `data_xy`, … +/// - `.*mux.*` — any name containing `mux` +/// - `ch[0-3]` — `ch0`, `ch1`, `ch2`, `ch3` +/// - `r[0-9]{1,2}` — `r0` through `r99` +/// - **`**`** (double-star, as its own segment) matches zero or more +/// hierarchy levels, allowing searches to cross boundaries: +/// - `Top/**/clk` — `clk` at any depth below `Top` +/// - `**/d[0-9]+` — any signal like `d0` anywhere +/// - `Top/**/ch*/data_*` — `data_*` signals inside `ch*` modules +/// +/// ## Interaction between glob and regex +/// +/// Glob conversion happens *before* regex compilation, so `*` and `?` +/// are always expanded. If you need a literal `*` or `?` in the regex, +/// escape them: `\*`, `\?`. All other regex metacharacters (`.`, `+`, +/// `|`, `(`, `)`, `[`, `]`, `{`, `}`, `^`, `$`) work as-is inside +/// each segment. +/// +/// ## Examples +/// +/// ```text +/// Query Matches +/// ───────────────────────────────────────────────────────────── +/// Top/CPU/clk exact: Top → CPU → clk +/// Top/CPU/* all signals in Top/CPU +/// Top/*/clk clk one level below Top +/// Top/**/clk clk at any depth below Top +/// Top/**/c.* signals starting with 'c' anywhere +/// **/clk clk anywhere in hierarchy +/// **/(clk|reset) clk or reset anywhere +/// Top/CPU/d[0-9]+ d0, d1, d12, … in Top/CPU +/// Top/**/ch[0-3]/data_* data_* in ch0–ch3 at any depth +/// Top/mem_*/addr[0-9]* addr0, addr1, … in mem_* modules +/// **/.*mux.* any name containing 'mux' anywhere +/// ``` +class RegexQuery extends HierarchyQuery { + /// Compiled segments — either a regex or a glob-star sentinel. + late final List segments; + + /// Create a regex query from [rawQuery]. + /// + /// A standalone `*` is converted to `.*`, `?` to `.`. The segment + /// `**` matches zero or more hierarchy levels. + RegexQuery( + super.rawQuery, { + super.target = SearchTarget.signals, + }) : super(crossesBoundaries: false) { + final parts = rawQuery + .split('/') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + segments = parts.map((s) { + if (s == '**') { + return RegexSegment.globStar(); + } + final pattern = _globToRegex(s); + return RegexSegment(RegExp('^$pattern\$')); + }).toList(); + } + + @override + int get segmentCount => segments.length; + + @override + Set matchOccurrence(String occurrenceName, int stateIndex) { + if (stateIndex >= segments.length) { + return const {}; + } + final seg = segments[stateIndex]; + final results = {}; + if (seg.isGlobStar) { + // ** matches zero levels (skip) … + results + ..addAll(matchOccurrence(occurrenceName, stateIndex + 1)) + // … or consumes this node and stays at ** (one-or-more levels). + ..add(stateIndex); + } else if (seg.regex!.hasMatch(occurrenceName)) { + results.add(stateIndex + 1); + } + return results; + } + + @override + bool matchSignal(String signalName, int stateIndex) { + // Walk past any trailing **'s to find the signal-matching segment. + var i = stateIndex; + while (i < segments.length && segments[i].isGlobStar) { + i++; + } + if (i >= segments.length) { + return true; // all consumed + } + // The segment at i must be the last real regex. + if (!_allGlobStarAfter(i + 1)) { + return false; + } + return segments[i].regex!.hasMatch(signalName); + } + + @override + bool isComplete(int stateIndex) => + stateIndex >= segments.length || + segments.skip(stateIndex).every((s) => s.isGlobStar); + + /// Check if all segments from [fromIdx] onward are glob-stars. + bool _allGlobStarAfter(int fromIdx) => + segments.skip(fromIdx).every((s) => s.isGlobStar); + + /// Convert glob wildcards to regex equivalents. + static String _globToRegex(String segment) { + final buf = StringBuffer(); + for (var i = 0; i < segment.length; i++) { + final c = segment[i]; + if (c == '*') { + if (buf.toString().endsWith('.')) { + buf.write('*'); + } else { + buf.write('.*'); + } + } else if (c == '?') { + buf.write('.'); + } else { + buf.write(c); + } + } + return buf.toString(); + } +} + +/// A compiled regex segment for [RegexQuery]. +class RegexSegment { + /// The compiled regex, or null for glob-star segments. + final RegExp? regex; + + /// Whether this segment is a `**` glob-star. + final bool isGlobStar; + + /// Create a regex segment. + RegexSegment(this.regex) : isGlobStar = false; + + /// Create a glob-star segment (`**`). + RegexSegment.globStar() + : regex = null, + isGlobStar = true; +} diff --git a/packages/rohd_hierarchy/lib/src/signal_occurrence.dart b/packages/rohd_hierarchy/lib/src/signal_occurrence.dart new file mode 100644 index 000000000..432df6eac --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/signal_occurrence.dart @@ -0,0 +1,117 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_occurrence.dart +// A signal in the hardware occurrence hierarchy. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:meta/meta.dart'; +import 'package:rohd_hierarchy/src/hierarchy_occurrence.dart'; +import 'package:rohd_hierarchy/src/occurrence_address.dart'; + +/// Signals are the fundamental data carriers in hardware. A signal can be: +/// - An internal signal within an occurrence +/// - A port on an occurrence interface (has direction: input/output/inout) +/// +/// This is a structural model without waveform data. Path strings are +/// computed on demand from the parent occurrence reference — call [path] +/// with your desired separator. +class SignalOccurrence { + /// The name of the signal (bare name within its scope). + /// + /// Used for display, search, and local lookups within an occurrence. + /// Not guaranteed unique across the full hierarchy — use [path] for + /// unique keying. + final String name; + + /// The bit width of the signal. + final int width; + + /// Direction of the signal if it's a port. + /// Null for internal signals. + /// "input", "output", or "inout" for ports. + final String? direction; + + /// Current runtime value of the signal (if available). + /// Typically a hex or binary string representation. + final String? value; + + /// Whether this signal's value is computed/derivable (e.g. constant, + /// gate output, InlineSystemVerilog result) rather than directly tracked + /// by the waveform service. + final bool isComputed; + + /// Stable ordering index among ports in the parent occurrence. + /// + /// Set by the adapter that creates the signal. For ports (signals with + /// a [direction]), this records the deterministic position from the + /// original source (netlist JSON iteration order, ROHD module port + /// declaration order, etc.). Internal signals have `null`. + /// + /// [HierarchyOccurrence.buildAddresses] places ports before internal + /// signals when assigning [OccurrenceAddress] indices, so a port with + /// `portIndex == k` will receive signal address index `k`. + /// + /// Consumers that store connectivity by `(nodeId, portIndex)` tuples + /// (e.g. schematic hyperedges) rely on this value remaining stable + /// across incremental hierarchy expansion. + final int? portIndex; + + /// Hierarchical address for this signal. Assigned by + /// [HierarchyOccurrence.buildAddresses] to enable efficient navigation. + /// Format: [...occurrenceIndices, signalIndex] + OccurrenceAddress? get address => _address; + OccurrenceAddress? _address; + + /// Sets the address. Only for use by [HierarchyOccurrence.buildAddresses]. + @internal + set address(OccurrenceAddress? value) => _address = value; + + /// Parent occurrence containing this signal. Set by + /// [HierarchyOccurrence.buildAddresses]. + HierarchyOccurrence? get parent => _parent; + HierarchyOccurrence? _parent; + + /// Sets the parent. Only for use by [HierarchyOccurrence.buildAddresses]. + @internal + set parent(HierarchyOccurrence? value) => _parent = value; + + /// Creates a [SignalOccurrence] with the given properties. + SignalOccurrence({ + required this.name, + required this.width, + this.direction, + this.value, + this.isComputed = false, + this.portIndex, + }); + + /// Compute the full hierarchical path for this signal. + /// + /// Joins the parent occurrence's path with this signal's [name] using + /// [separator]. Falls back to just [name] if parent is not yet set + /// (e.g. in test fixtures before `buildAddresses`). + String path({String separator = '/'}) { + if (_parent == null) { + return name; + } + return '${_parent!.path(separator: separator)}$separator$name'; + } + + /// Returns true if this signal is a port (has a direction). + bool get isPort => direction != null; + + /// Returns true if this is an input port. + bool get isInput => direction == 'input'; + + /// Returns true if this is an output port. + bool get isOutput => direction == 'output'; + + /// Returns true if this is a bidirectional port. + bool get isInout => direction == 'inout'; + + @override + String toString() => '$name (width=$width${isPort ? ', $direction' : ''})'; +} diff --git a/packages/rohd_hierarchy/lib/src/signal_search_result.dart b/packages/rohd_hierarchy/lib/src/signal_search_result.dart new file mode 100644 index 000000000..970855f74 --- /dev/null +++ b/packages/rohd_hierarchy/lib/src/signal_search_result.dart @@ -0,0 +1,49 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_search_result.dart +// Result of a signal search with enriched metadata. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:meta/meta.dart'; + +import 'package:rohd_hierarchy/src/hierarchy_search_result.dart'; +import 'package:rohd_hierarchy/src/signal_occurrence.dart'; + +/// Result of a signal search with enriched metadata. +/// +/// Contains the signal's full path, parsed path segments, and the full +/// [SignalOccurrence] object if available. This is the hierarchy-only portion +/// of search results; UI layers can use the pre-computed display helpers +/// directly without re-parsing paths. +@immutable +class SignalSearchResult extends HierarchySearchResult { + /// Alias for [id] — the signal's full hierarchical path. + String get signalId => id; + + /// The underlying [SignalOccurrence] from the hierarchy service (if + /// available). Contains width, direction, and other signal metadata. + final SignalOccurrence? signal; + + /// Creates a signal search result. + const SignalSearchResult({ + required String signalId, + required super.path, + this.signal, + }) : super(id: signalId); + + /// Occurrence names that need to be expanded to reveal this signal. + /// + /// These are the intermediate path segments between the top occurrence + /// and the signal name — i.e. everything except the first (top + /// occurrence) and last (signal name) segments. + /// + /// For `Top/sub1/sub2/clk` this returns `["sub1", "sub2"]`. + List get intermediateOccurrenceNames => + path.length > 2 ? path.sublist(1, path.length - 1) : const []; + + @override + String toString() => 'SignalSearchResult($id, width=${signal?.width ?? "?"})'; +} diff --git a/packages/rohd_hierarchy/pubspec.yaml b/packages/rohd_hierarchy/pubspec.yaml new file mode 100644 index 000000000..68c9bc4c8 --- /dev/null +++ b/packages/rohd_hierarchy/pubspec.yaml @@ -0,0 +1,19 @@ +name: rohd_hierarchy +description: "Generic hierarchy data models for hardware module navigation - HierarchyNode, Port, and HierarchyService." +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 + meta: ^1.9.0 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.17.3 diff --git a/packages/rohd_hierarchy/test/adapter_search_parity_test.dart b/packages/rohd_hierarchy/test/adapter_search_parity_test.dart new file mode 100644 index 000000000..cde3a301a --- /dev/null +++ b/packages/rohd_hierarchy/test/adapter_search_parity_test.dart @@ -0,0 +1,285 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// adapter_search_parity_test.dart +// Baseline tests verifying that search produces identical results +// regardless of which adapter populated the HierarchyService. +// +// 2026 April +// Author: Desmond Kirkpatrick + +// This is the key contract: once a HierarchyService is built, callers +// cannot tell whether the data came from VCD (BaseHierarchyAdapter.fromTree), +// netlist JSON (NetlistHierarchyAdapter), or any other source. + +import 'dart:convert'; + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +/// Concrete subclass that does NOT set root, so we can test the +/// StateError thrown by uninitialized access. +class _UnsetAdapter extends BaseHierarchyAdapter {} + +/// Resolve a pathname to a [SignalOccurrence] via +/// [OccurrenceAddress.tryFromPathname]. +SignalOccurrence? _resolve(HierarchyService svc, String path) { + final addr = OccurrenceAddress.tryFromPathname(path, svc.root); + if (addr == null) { + return null; + } + return svc.signalByAddress(addr); +} + +// ────────────────────────────────────────────────────────────────────── +// Build the SAME design via two different adapter paths +// ────────────────────────────────────────────────────────────────────── + +/// VCD-style: HierarchyNode tree with children/signals populated inline. +/// This is what `wellen` produces when loading a VCD/FST file. +BaseHierarchyAdapter _buildVcdAdapter() => BaseHierarchyAdapter.fromTree( + HierarchyOccurrence( + name: 'Abcd', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'resetn', width: 1), + SignalOccurrence(name: 'arvalid_s', width: 1), + ], + children: [ + HierarchyOccurrence( + name: 'lab', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'fromUpstream_request__st', width: 64), + ], + children: [ + HierarchyOccurrence( + name: 'cam', + signals: [ + SignalOccurrence(name: 'hit', width: 1), + SignalOccurrence(name: 'entry', width: 32), + ], + ), + ], + ), + ], + ), + ); + +/// Netlist JSON-style: flat-map adapter (like what DevTools/schematic viewer +/// builds from ROHD inspector JSON or netlist JSON). +/// Children and signals live in the adapter's flat maps, NOT inside +/// the HierarchyNode objects. +NetlistHierarchyAdapter _buildJsonAdapter() => + NetlistHierarchyAdapter.fromJson(jsonEncode({ + 'modules': { + 'Abcd': { + 'attributes': {'top': 1}, + 'ports': { + 'clk': { + 'direction': 'input', + 'bits': [1] + }, + 'resetn': { + 'direction': 'input', + 'bits': [2] + }, + 'arvalid_s': { + 'direction': 'input', + 'bits': [3] + }, + }, + 'netnames': {}, + 'cells': { + 'lab': { + 'type': 'Lab', + 'connections': {}, + }, + }, + }, + 'Lab': { + 'ports': { + 'clk': { + 'direction': 'input', + 'bits': [10] + }, + 'reset': { + 'direction': 'input', + 'bits': [11] + }, + 'fromUpstream_request__st': { + 'direction': 'input', + 'bits': List.generate(64, (i) => 100 + i) + }, + }, + 'netnames': {}, + 'cells': { + 'cam': { + 'type': 'Cam', + 'connections': {}, + }, + }, + }, + 'Cam': { + 'ports': { + 'hit': { + 'direction': 'input', + 'bits': [200] + }, + 'entry': { + 'direction': 'input', + 'bits': List.generate(32, (i) => 300 + i) + }, + }, + 'netnames': {}, + 'cells': {}, + }, + }, + })); + +void main() { + late HierarchyService vcdService; + late HierarchyService jsonService; + + setUp(() { + vcdService = _buildVcdAdapter(); + jsonService = _buildJsonAdapter(); + }); + + // ── The two services must be interchangeable for all search ops ── + // Case-insensitivity, dot separators, controller state, and + // search semantics are covered in address_conversion_test, + // hierarchy_search_controller_test, and regex_search_test. + // This file focuses exclusively on *parity* between adapters. + + group('Adapter search parity — both sources produce same results', () { + test('root name matches', () { + expect(vcdService.root.name, 'Abcd'); + expect(jsonService.root.name, 'Abcd'); + }); + + test('root.children returns same module names', () { + final vcdChildren = vcdService.root.children.map((c) => c.name).toSet(); + final jsonChildren = jsonService.root.children.map((c) => c.name).toSet(); + expect(vcdChildren, jsonChildren); + }); + + test('root.signals returns same signal names at root', () { + final vcdSigs = vcdService.root.signals.map((s) => s.name).toSet(); + final jsonSigs = jsonService.root.signals.map((s) => s.name).toSet(); + expect(vcdSigs, jsonSigs); + }); + + test('nested node signals() returns same signal names', () { + final vcdLab = vcdService.root.children.first; + final jsonLab = jsonService.root.children.first; + final vcdSigs = vcdLab.signals.map((s) => s.name).toSet(); + final jsonSigs = jsonLab.signals.map((s) => s.name).toSet(); + expect(vcdSigs, jsonSigs); + }); + + test('signalByAddress works on both — top level', () { + final vcdClk = _resolve(vcdService, 'Abcd/clk'); + final jsonClk = _resolve(jsonService, 'Abcd/clk'); + expect(vcdClk, isNotNull, reason: 'VCD: Abcd/clk'); + expect(jsonClk, isNotNull, reason: 'JSON: Abcd/clk'); + expect(vcdClk!.name, 'clk'); + expect(jsonClk!.name, 'clk'); + }); + + test('signalByAddress works on both — nested', () { + final vcdHit = _resolve(vcdService, 'Abcd/lab/cam/hit'); + final jsonHit = _resolve(jsonService, 'Abcd/lab/cam/hit'); + expect(vcdHit, isNotNull, reason: 'VCD: Abcd/lab/cam/hit'); + expect(jsonHit, isNotNull, reason: 'JSON: Abcd/lab/cam/hit'); + expect(vcdHit!.name, 'hit'); + expect(jsonHit!.name, 'hit'); + }); + + test('searchSignals plain query — same result names', () { + final vcdResults = + vcdService.searchSignals('clk').map((r) => r.name).toSet(); + final jsonResults = + jsonService.searchSignals('clk').map((r) => r.name).toSet(); + expect(vcdResults, isNotEmpty); + expect(vcdResults, jsonResults); + }); + + test('searchSignals glob query — same result names', () { + final vcdResults = + vcdService.searchSignals('**/clk').map((r) => r.name).toSet(); + final jsonResults = + jsonService.searchSignals('**/clk').map((r) => r.name).toSet(); + expect(vcdResults, isNotEmpty); + expect(vcdResults, jsonResults); + }); + + test('searchSignals path query — same result names', () { + final vcdResults = + vcdService.searchSignals('lab/clk').map((r) => r.name).toSet(); + final jsonResults = + jsonService.searchSignals('lab/clk').map((r) => r.name).toSet(); + expect(vcdResults, isNotEmpty); + expect(vcdResults, jsonResults); + }); + + test('searchModules — same module names', () { + final vcdNodes = vcdService + .searchOccurrences('lab') + .map((r) => r.occurrence.name) + .toSet(); + final jsonNodes = jsonService + .searchOccurrences('lab') + .map((r) => r.occurrence.name) + .toSet(); + expect(vcdNodes, isNotEmpty); + expect(vcdNodes, jsonNodes); + }); + + test('searchModules nested — same module names', () { + final vcdNodes = vcdService + .searchOccurrences('cam') + .map((r) => r.occurrence.name) + .toSet(); + final jsonNodes = jsonService + .searchOccurrences('cam') + .map((r) => r.occurrence.name) + .toSet(); + expect(vcdNodes, isNotEmpty); + expect(vcdNodes, jsonNodes); + }); + }); + + // ── Verify the external-hierarchy handoff works ── + // Individual search/address semantics are covered elsewhere. + // This group tests the adapter re-wrapping contract. + + group('External hierarchy flow (simulates DevTools → wave viewer)', () { + test('BaseHierarchyAdapter.fromTree produces identical search results', () { + final rewrapped = BaseHierarchyAdapter.fromTree(jsonService.root); + + final results = rewrapped.searchSignals('clk'); + expect(results, isNotEmpty); + expect( + results.map((r) => r.name).toSet(), + jsonService.searchSignals('clk').map((r) => r.name).toSet(), + ); + }); + + test('BaseHierarchyAdapter.fromTree preserves signalByAddress', () { + final rewrapped = BaseHierarchyAdapter.fromTree(jsonService.root); + + final hit = _resolve(rewrapped, 'Abcd/lab/cam/hit'); + expect(hit, isNotNull); + expect(hit!.name, 'hit'); + }); + }); + + group('BaseHierarchyAdapter.root', () { + test('throws StateError when root is not set', () { + final adapter = _UnsetAdapter(); + expect(() => adapter.root, throwsStateError); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/address_conversion_test.dart b/packages/rohd_hierarchy/test/address_conversion_test.dart new file mode 100644 index 000000000..2caabdef6 --- /dev/null +++ b/packages/rohd_hierarchy/test/address_conversion_test.dart @@ -0,0 +1,286 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// address_conversion_test.dart +// Tests for HierarchyService address ↔ pathname conversion methods. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('Address ↔ pathname conversion', () { + late HierarchyService service; + late HierarchyOccurrence root; + + // Build a test hierarchy: + // Top + // ├─ cpu (child 0) + // │ ├─ signals: clk, rst + // │ └─ alu (child 0 of cpu) + // │ └─ signals: a, b, out + // └─ mem (child 1) + // └─ signals: addr, data + + setUpAll(() { + final alu = HierarchyOccurrence( + name: 'alu', + signals: [ + SignalOccurrence( + name: 'a', + width: 1, + ), + SignalOccurrence( + name: 'b', + width: 1, + ), + SignalOccurrence( + name: 'out', + width: 1, + ), + ], + ); + + final cpu = HierarchyOccurrence( + name: 'cpu', + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + ), + SignalOccurrence( + name: 'rst', + width: 1, + ), + ], + children: [alu], + ); + + final mem = HierarchyOccurrence( + name: 'mem', + signals: [ + SignalOccurrence( + name: 'addr', + width: 1, + ), + SignalOccurrence( + name: 'data', + width: 1, + ), + ], + ); + + root = HierarchyOccurrence( + name: 'Top', + children: [cpu, mem], + )..buildAddresses(); + + service = BaseHierarchyAdapter.fromTree(root); + }); + + group('pathnameToAddress', () { + test('root name resolves to root address', () { + final addr = service.pathnameToAddress('Top'); + expect(addr, isNotNull); + expect(addr!.path, equals([])); + }); + + test('module path resolves correctly', () { + final addr = service.pathnameToAddress('Top/cpu'); + expect(addr, isNotNull); + expect(addr!.path, equals([0])); + }); + + test('nested module path resolves correctly', () { + final addr = service.pathnameToAddress('Top/cpu/alu'); + expect(addr, isNotNull); + expect(addr!.path, equals([0, 0])); + }); + + test('second child module resolves correctly', () { + final addr = service.pathnameToAddress('Top/mem'); + expect(addr, isNotNull); + expect(addr!.path, equals([1])); + }); + + test('signal path resolves correctly', () { + final addr = service.pathnameToAddress('Top/cpu/clk'); + expect(addr, isNotNull); + expect(addr!.path, equals([0, 0])); // cpu[0], signal clk[0] + }); + + test('second signal resolves correctly', () { + final addr = service.pathnameToAddress('Top/cpu/rst'); + expect(addr, isNotNull); + expect(addr!.path, equals([0, 1])); // cpu[0], signal rst[1] + }); + + test('nested signal resolves correctly', () { + final addr = service.pathnameToAddress('Top/cpu/alu/out'); + expect(addr, isNotNull); + expect(addr!.path, equals([0, 0, 2])); // cpu[0], alu[0], out[2] + }); + + test('dot-separated paths work too', () { + final addr = service.pathnameToAddress('Top.cpu.alu.b'); + expect(addr, isNotNull); + expect(addr!.path, equals([0, 0, 1])); // cpu[0], alu[0], b[1] + }); + + test('non-existent path returns null', () { + expect(service.pathnameToAddress('Top/nonexistent'), isNull); + }); + + test('non-existent signal returns null', () { + expect(service.pathnameToAddress('Top/cpu/nonexistent'), isNull); + }); + + test('empty string returns root', () { + final addr = service.pathnameToAddress(''); + expect(addr, isNotNull); + expect(addr!.path, isEmpty); + }); + }); + + group('addressToPathname', () { + test('root address returns root name', () { + expect( + service.addressToPathname(OccurrenceAddress.root), + equals('Top'), + ); + }); + + test('module address resolves correctly', () { + expect( + service.addressToPathname(const OccurrenceAddress([0])), + equals('Top/cpu'), + ); + }); + + test('nested module address resolves correctly', () { + expect( + service.addressToPathname(const OccurrenceAddress([0, 0])), + equals('Top/cpu/alu'), + ); + }); + + test('signal address resolves with asSignal flag', () { + expect( + service.addressToPathname( + const OccurrenceAddress([0, 0]), + asSignal: true, + ), + equals('Top/cpu/clk'), + ); + }); + + test('nested signal address resolves with asSignal flag', () { + expect( + service.addressToPathname( + const OccurrenceAddress([0, 0, 2]), + asSignal: true, + ), + equals('Top/cpu/alu/out'), + ); + }); + + test('out-of-bounds child returns null', () { + expect( + service.addressToPathname(const OccurrenceAddress([5])), + isNull, + ); + }); + + test('out-of-bounds signal returns null', () { + expect( + service.addressToPathname( + const OccurrenceAddress([0, 99]), + asSignal: true, + ), + isNull, + ); + }); + }); + + group('nodeByAddress', () { + test('root address returns root', () { + final node = service.occurrenceByAddress(OccurrenceAddress.root); + expect(node?.name, equals('Top')); + }); + + test('child address returns correct child', () { + final node = service.occurrenceByAddress(const OccurrenceAddress([0])); + expect(node?.name, equals('cpu')); + }); + + test('nested address returns correct node', () { + final node = + service.occurrenceByAddress(const OccurrenceAddress([0, 0])); + expect(node?.name, equals('alu')); + }); + + test('out-of-bounds returns null', () { + expect( + service.occurrenceByAddress(const OccurrenceAddress([99])), + isNull, + ); + }); + }); + + group('signalByAddress', () { + test('signal address returns correct signal', () { + // cpu's first signal (clk) has address [0, 0] + final clkAddr = root.children[0].signals[0].address!; + final sig = service.signalByAddress(clkAddr); + expect(sig?.name, equals('clk')); + }); + + test('nested signal address returns correct signal', () { + // alu's third signal (out) has address [0, 0, 2] + final outAddr = root.children[0].children[0].signals[2].address!; + final sig = service.signalByAddress(outAddr); + expect(sig?.name, equals('out')); + }); + + test('root address returns null (not a signal)', () { + expect(service.signalByAddress(OccurrenceAddress.root), isNull); + }); + }); + + group('waveformIdToAddress', () { + test('dot-separated waveform ID resolves', () { + final addr = service.waveformIdToAddress('Top.cpu.alu.a'); + expect(addr, isNotNull); + expect(addr!.path, equals([0, 0, 0])); // cpu[0], alu[0], a[0] + }); + }); + + group('round-trip', () { + test('pathname → address → pathname preserves module path', () { + const path = 'Top/cpu/alu'; + final addr = service.pathnameToAddress(path); + expect(addr, isNotNull); + final roundTripped = service.addressToPathname(addr!); + expect(roundTripped, equals(path)); + }); + + test('pathname → address → pathname preserves signal path', () { + const path = 'Top/cpu/alu/out'; + final addr = service.pathnameToAddress(path); + expect(addr, isNotNull); + final roundTripped = service.addressToPathname(addr!, asSignal: true); + expect(roundTripped, equals(path)); + }); + + test('address → pathname → address preserves module address', () { + const addr = OccurrenceAddress([0, 0]); + final path = service.addressToPathname(addr); + expect(path, isNotNull); + final roundTripped = service.pathnameToAddress(path!); + expect(roundTripped?.path, equals(addr.path)); + }); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/coverage_gaps_test.dart b/packages/rohd_hierarchy/test/coverage_gaps_test.dart new file mode 100644 index 000000000..1e29bcfe5 --- /dev/null +++ b/packages/rohd_hierarchy/test/coverage_gaps_test.dart @@ -0,0 +1,120 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// coverage_gaps_test.dart +// Tests for API surface not covered by other test files: +// - BaseHierarchyAdapter.root StateError on uninitialized access +// - SignalOccurrence as port +// - HierarchyOccurrence.parent +// - SignalOccurrence.value +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +/// Concrete subclass that does NOT set root, so we can test the +/// StateError thrown by uninitialized access. +class _UnsetAdapter extends BaseHierarchyAdapter {} + +void main() { + group('BaseHierarchyAdapter.root', () { + test('throws StateError when root is not set', () { + final adapter = _UnsetAdapter(); + expect(() => adapter.root, throwsStateError); + }); + }); + + group('SignalOccurrence as port', () { + test('creates a port signal with defaults', () { + final p = SignalOccurrence(name: 'clk', width: 1, direction: 'input'); + expect(p.name, 'clk'); + expect(p.direction, 'input'); + expect(p.width, 1); + expect(p.isPort, isTrue); + expect(p.isInput, isTrue); + }); + + test('creates a port signal with explicit overrides', () { + final p = SignalOccurrence( + name: 'data', + direction: 'output', + width: 32, + isComputed: true, + ); + HierarchyOccurrence(name: 'Top', signals: [p]).buildAddresses(); + expect(p.name, 'data'); + expect(p.width, 32); + expect(p.direction, 'output'); + expect(p.path(), 'Top/data'); + expect(p.parent!.path(), 'Top'); + expect(p.isComputed, isTrue); + expect(p.isOutput, isTrue); + }); + }); + + group('HierarchyOccurrence.parent', () { + test('parent is null for root', () { + final root = HierarchyOccurrence(name: 'Top')..buildAddresses(); + expect(root.parent, isNull); + }); + + test('parent is set for child nodes after buildAddresses', () { + final child = HierarchyOccurrence(name: 'sub'); + final root = HierarchyOccurrence(name: 'Top', children: [child]) + ..buildAddresses(); + expect(child.parent, same(root)); + expect(child.path(), 'Top/sub'); + }); + }); + + group('SignalOccurrence.value', () { + test('value is null by default', () { + final s = SignalOccurrence(name: 'a', width: 1); + expect(s.value, isNull); + }); + + test('value stores the provided runtime value', () { + final s = SignalOccurrence(name: 'a', width: 8, value: 'ff'); + expect(s.value, 'ff'); + }); + }); + + group('HierarchyOccurrence.definition', () { + test('type is null when not provided', () { + final n = HierarchyOccurrence(name: 'a'); + expect(n.definition, isNull); + }); + + test('type is stored when provided', () { + final n = HierarchyOccurrence(name: 'a', definition: 'Counter'); + expect(n.definition, 'Counter'); + }); + }); + + group('SignalOccurrence.parent', () { + test('parent is null before buildAddresses', () { + final s = SignalOccurrence(name: 'a', width: 1); + expect(s.parent, isNull); + }); + + test('parent is set after buildAddresses', () { + final s = SignalOccurrence(name: 'a', width: 1); + HierarchyOccurrence( + name: 'Top', + children: [ + HierarchyOccurrence(name: 'sub', signals: [s]) + ], + ).buildAddresses(); + expect(s.parent!.path(), 'Top/sub'); + }); + }); + + group('isPrimitive on nodes', () { + test('default isPrimitive is false', () { + final n = HierarchyOccurrence(name: 'sub'); + expect(n.isPrimitive, isFalse); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/devtools_search_flow_test.dart b/packages/rohd_hierarchy/test/devtools_search_flow_test.dart new file mode 100644 index 000000000..ac5f11ad9 --- /dev/null +++ b/packages/rohd_hierarchy/test/devtools_search_flow_test.dart @@ -0,0 +1,214 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// devtools_search_flow_test.dart +// Tests that simulate the DevTools embedding flow with local signal IDs: +// HierarchyNode tree → BaseHierarchyAdapter.fromTree → search +// +// 2026 April +// Author: Desmond Kirkpatrick + +// The test verifies that search works correctly with local signal IDs +// (as opposed to VCD-style full-path IDs), catching any assumption +// mismatches in the search engine. + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +/// Build the test hierarchy tree directly, matching the structure +/// that would be produced from ROHD inspector JSON. +/// Signals have local IDs and full qualified paths. +HierarchyOccurrence _buildTestHierarchy() { + final cam = HierarchyOccurrence( + name: 'cam', + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'hit', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'entry', + width: 32, + direction: 'input', + ), + SignalOccurrence( + name: 'match_out', + width: 1, + direction: 'output', + ), + ], + ); + + final lab = HierarchyOccurrence( + name: 'lab', + children: [cam], + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'reset', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'fromUpstream_request__st', + width: 64, + direction: 'input', + ), + SignalOccurrence( + name: 'toUpstream_response__st', + width: 64, + direction: 'output', + ), + ], + ); + + final dmaEngine = HierarchyOccurrence( + name: 'engine', + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'enable', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'data_in', + width: 64, + direction: 'input', + ), + SignalOccurrence( + name: 'data_out', + width: 64, + direction: 'output', + ), + SignalOccurrence( + name: 'done', + width: 1, + direction: 'output', + ), + ], + ); + + return HierarchyOccurrence( + name: 'Abcd', + children: [lab, dmaEngine], + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'resetn', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'araddr_s', + width: 32, + direction: 'input', + ), + SignalOccurrence( + name: 'rdata_s', + width: 32, + direction: 'output', + ), + ], + ); +} + +void main() { + late BaseHierarchyAdapter service; + + setUp(() { + final root = _buildTestHierarchy()..buildAddresses(); + service = BaseHierarchyAdapter.fromTree(root); + }); + + group( + 'DevTools flow — local signal IDs ' + '→ BaseHierarchyAdapter.fromTree → search', () { + // Basic search, address, glob, and controller behavior is covered by + // hierarchy_search_controller_test, regex_search_test, + // address_conversion_test, and module_search_test. + // + // This group focuses on what is unique to the DevTools local-ID flow: + // search correctness when SignalOccurrence.name is a local name (not a full + // path). + + test('search works with local signal IDs', () { + // Plain prefix search still finds signals by name + final results = service.searchSignals('clk'); + expect(results, isNotEmpty); + expect(results.map((r) => r.name), everyElement('clk')); + // Glob still works + final globResults = service.searchSignals('**/entry'); + expect(globResults, isNotEmpty); + expect(globResults.first.name, 'entry'); + }); + + test('signalByAddress resolves despite local IDs', () { + final addr = + OccurrenceAddress.tryFromPathname('Abcd/lab/cam/hit', service.root); + expect(addr, isNotNull); + final hit = service.signalByAddress(addr!); + expect(hit, isNotNull); + expect(hit!.name, 'hit'); + expect(hit.name, 'hit'); // local, not full path + expect(hit.path(), 'Abcd/lab/cam/hit'); + }); + + test('searchModules works with local-ID tree', () { + final results = service.searchOccurrences('cam'); + expect(results, isNotEmpty); + expect(results.first.occurrence.name, 'cam'); + }); + }); + + // ── SignalOccurrence ID format verification ── + + group('local signal ID format', () { + test('signals have local IDs (not full paths)', () { + final sigs = service.root.signals; + final clk = sigs.firstWhere((s) => s.name == 'clk'); + // The signal id is the local name, not the full path + expect(clk.name, 'clk'); + // But fullPath is the full qualified path + expect(clk.path(), 'Abcd/clk'); + }); + + test('local signal IDs do not break address resolution', () { + final addr = OccurrenceAddress.tryFromPathname( + 'Abcd/lab/cam/match_out', service.root); + expect(addr, isNotNull); + final result = service.signalByAddress(addr!); + expect(result, isNotNull); + expect(result!.name, 'match_out'); + expect(result.name, 'match_out'); // local name + }); + + test('search results carry the correct signal object', () { + final results = service.searchSignals('Abcd/rdata_s'); + expect(results, isNotEmpty); + final r = results.first; + expect(r.signal, isNotNull); + expect(r.signal!.name, 'rdata_s'); // local name + expect(r.signal!.path(), 'Abcd/rdata_s'); // full path + expect(r.signal!.width, 32); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/filter_bank_integration_test.dart b/packages/rohd_hierarchy/test/filter_bank_integration_test.dart new file mode 100644 index 000000000..9d7bc2a80 --- /dev/null +++ b/packages/rohd_hierarchy/test/filter_bank_integration_test.dart @@ -0,0 +1,630 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// filter_bank_integration_test.dart +// Integration tests using a real ROHD FilterBank netlist JSON fixture. +// Covers model getters, service methods, and adapter edge cases. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'dart:io'; + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +/// Load the slim FilterBank fixture and build a NetlistHierarchyAdapter. +NetlistHierarchyAdapter _loadFixture() { + final json = File('test/fixtures/filter_bank.json').readAsStringSync(); + return NetlistHierarchyAdapter.fromJson(json); +} + +void main() { + late NetlistHierarchyAdapter adapter; + late HierarchyService service; + + setUpAll(() { + adapter = _loadFixture(); + service = adapter; + service.root.buildAddresses(); + }); + + // ─────────────── NetlistHierarchyAdapter parsing ─────────────── + + group('NetlistHierarchyAdapter — FilterBank fixture', () { + test('top module is FilterBank', () { + expect(service.root.name, 'FilterBank'); + }); + + test('rootNameOverride replaces root node name', () { + final json = File('test/fixtures/filter_bank.json').readAsStringSync(); + final custom = + NetlistHierarchyAdapter.fromJson(json, rootNameOverride: 'MyDesign'); + expect(custom.root.name, 'MyDesign'); + }); + + test('root has expected ports as signals', () { + final portNames = service.root.signals.map((s) => s.name).toSet(); + expect(portNames, containsAll(['clk', 'reset', 'start', 'done'])); + }); + + test('has hierarchical children (ch0, ch1, controller)', () { + final childNames = service.root.children.map((c) => c.name).toSet(); + // ch0_1 and ch1_1 are FilterChannel instances; controller_1 is + // FilterController + expect(childNames, containsAll(['ch0_1', 'ch1_1', 'controller_1'])); + }); + + test('primitive cells are marked isPrimitive', () { + // array_slice cells in FilterBank are $slice — primitive + final sliceCells = service.root.children + .where((c) => c.definition != null && c.definition!.startsWith(r'$')); + expect(sliceCells, isNotEmpty); + for (final cell in sliceCells) { + expect(cell.isPrimitive, isTrue, + reason: '${cell.name} (${cell.definition}) should be primitive'); + } + }); + + test('primitive cells have port signals from port_directions', () { + final primitives = service.root.children.where((c) => c.isPrimitive); + for (final prim in primitives) { + expect(prim.signals, isNotEmpty, + reason: '${prim.name} should have port signals'); + // All signals on primitive cells should have a direction + for (final s in prim.signals) { + expect(s.isPort, isTrue, + reason: '${prim.name}/${s.name} should be a port'); + expect(s.direction, isNotEmpty); + } + } + }); + + test('netnames with hide_name=1 are excluded', () { + // FilterBank has controller_1_loadingPhase with hide_name=1 + final allSignalNames = + service.root.depthFirstSignals().map((s) => s.name); + expect(allSignalNames, isNot(contains('controller_1_loadingPhase'))); + }); + + test('netnames with computed attribute are included with isComputed', () { + // CoeffBank has const_0_2_h0 with computed=1 + // Navigate: FilterBank → ch0_1 → one of its children should have + // a CoeffBank with computed signals + bool foundComputed(HierarchyOccurrence node) { + for (final s in node.signals) { + if (s.isComputed) { + return true; + } + } + return node.children.any(foundComputed); + } + + expect(foundComputed(service.root), isTrue, + reason: 'Should have at least one computed signal'); + }); + + test(r'$-prefixed netnames are excluded', () { + // Any netname starting with $ should be filtered out + final allNames = service.root.depthFirstSignals().map((s) => s.name); + final dollarNames = allNames.where((n) => n.startsWith(r'$')); + expect(dollarNames, isEmpty, + reason: r'No $-prefixed netnames should appear'); + }); + }); + + // ─────────────── HierarchyNode model getters ─────────────── + + group('HierarchyNode model getters', () { + test('ports returns only signals with direction', () { + final ports = service.root.ports; + expect(ports, isNotEmpty); + for (final p in ports) { + expect(p.isPort, isTrue); + expect(p.direction, isNotEmpty); + } + }); + + test('inputs returns only input ports', () { + final inputs = service.root.inputs; + expect(inputs, isNotEmpty); + for (final s in inputs) { + expect(s.direction, 'input'); + } + expect(inputs.map((s) => s.name), contains('clk')); + }); + + test('outputs returns only output ports', () { + final outputs = service.root.outputs; + expect(outputs, isNotEmpty); + for (final s in outputs) { + expect(s.direction, 'output'); + } + expect(outputs.map((s) => s.name), contains('done')); + }); + + test(r'isPrimitiveType is true for $-prefixed types', () { + expect(HierarchyOccurrence.isPrimitiveType(r'$mux'), isTrue); + expect(HierarchyOccurrence.isPrimitiveType(r'$and'), isTrue); + }); + + test(r'isPrimitiveType is false for non-$-prefixed types', () { + expect(HierarchyOccurrence.isPrimitiveType('FilterBank'), isFalse); + }); + + test('isPrimitiveType is false for empty string', () { + expect(HierarchyOccurrence.isPrimitiveType(''), isFalse); + }); + + test('isPrimitiveCell reflects isPrimitive field and type', () { + // A node marked isPrimitive=true + final primCell = service.root.children.firstWhere((c) => c.isPrimitive); + expect(primCell.isPrimitiveCell, isTrue); + + // The root module is not primitive + expect(service.root.isPrimitiveCell, isFalse); + }); + + test('depthFirstSignals places root signals first', () { + final all = service.root.depthFirstSignals(); + expect(all, isNotEmpty); + + final rootSigs = service.root.signals; + for (var i = 0; i < rootSigs.length; i++) { + expect(all[i].name, rootSigs[i].name); + } + }); + + test('depthFirstSignals count equals recursive signal total', () { + final all = service.root.depthFirstSignals(); + int countSignals(HierarchyOccurrence n) => + n.signals.length + + n.children.fold(0, (sum, c) => sum + countSignals(c)); + expect(all.length, countSignals(service.root)); + }); + }); + + // ─────────────── SignalOccurrence model getters ─────────────── + + group('SignalOccurrence model getters', () { + test('isPort is true for Port instances', () { + final port = service.root.signals.first; + expect(port.isPort, isTrue); + }); + + test('input port has isInput true and isOutput/isInout false', () { + final clk = service.root.signals.firstWhere((s) => s.name == 'clk'); + expect(clk.isPort, isTrue); + expect(clk.isInput, isTrue); + expect(clk.isOutput, isFalse); + expect(clk.isInout, isFalse); + }); + + test('output port has isOutput true and isInput false', () { + final done = service.root.signals.firstWhere((s) => s.name == 'done'); + expect(done.isOutput, isTrue); + expect(done.isInput, isFalse); + }); + + test('isPort is false for non-Port signals (internal wires)', () { + // Internal signals (from netnames) are SignalOccurrence, not Port. + // The fixture includes visible non-port netnames like tapMatch0. + final allSigs = service.root.depthFirstSignals(); + final nonPorts = allSigs.where((s) => !s.isPort).toList(); + expect(nonPorts, isNotEmpty, + reason: 'Should have non-Port internal signals from netnames'); + }); + + test('SignalOccurrence.toString includes name and width', () { + final clk = service.root.signals.firstWhere((s) => s.name == 'clk'); + final str = clk.toString(); + expect(str, contains('clk')); + }); + }); + + // ─────────────── HierarchyService methods ─────────────── + + group('HierarchyService — search coverage', () { + test('searchNodes returns HierarchyNode objects', () { + final nodes = service.matchOccurrences('controller'); + expect(nodes, isNotEmpty); + for (final n in nodes) { + expect(n, isA()); + } + }); + + test('autocompletePaths returns children for partial path', () { + final suggestions = service.autocompletePaths('FilterBank/'); + expect(suggestions, isNotEmpty); + for (final s in suggestions) { + expect(s, startsWith('FilterBank/')); + } + }); + + test('autocompletePaths filters by prefix', () { + final suggestions = service.autocompletePaths('FilterBank/ch'); + expect(suggestions, isNotEmpty); + for (final s in suggestions) { + expect(s.toLowerCase(), contains('/ch')); + } + }); + + test('autocompletePaths with empty string returns root', () { + final suggestions = service.autocompletePaths(''); + // Should suggest root-level completions + expect(suggestions, isNotEmpty); + }); + + test('autocompletePaths appends / for nodes with children', () { + final suggestions = service.autocompletePaths('FilterBank/'); + final withSlash = suggestions.where((s) => s.endsWith('/')); + // At least ch0_1 and ch1_1 have children + expect(withSlash, isNotEmpty); + }); + + test('hasRegexChars is false for plain text', () { + expect(HierarchyService.hasRegexChars('clk'), isFalse); + }); + + test('hasRegexChars detects * glob', () { + expect(HierarchyService.hasRegexChars('c*'), isTrue); + }); + + test('hasRegexChars detects ? glob', () { + expect(HierarchyService.hasRegexChars('cl?'), isTrue); + }); + + test('hasRegexChars detects character class', () { + expect(HierarchyService.hasRegexChars('[a-z]'), isTrue); + }); + + test('hasRegexChars detects group alternation', () { + expect(HierarchyService.hasRegexChars('(a|b)'), isTrue); + }); + + test('hasRegexChars detects + quantifier', () { + expect(HierarchyService.hasRegexChars('a+'), isTrue); + }); + + test('longestCommonPrefix finds shared prefix', () { + expect( + HierarchyService.longestCommonPrefix( + ['FilterBank/ch0', 'FilterBank/ch1']), + 'FilterBank/ch', + ); + }); + + test('longestCommonPrefix returns null for empty list', () { + expect(HierarchyService.longestCommonPrefix([]), isNull); + }); + + test('longestCommonPrefix returns null for no common prefix', () { + expect(HierarchyService.longestCommonPrefix(['abc', 'xyz']), isNull); + }); + + test('longestCommonPrefix is case-sensitive', () { + final prefix = + HierarchyService.longestCommonPrefix(['Filter/abc', 'Filter/abd']); + expect(prefix, 'Filter/ab'); + }); + }); + + // ─────────────── HierarchySearchController ─────────────── + + group('HierarchySearchController — additional coverage', () { + test('selectAt selects valid index', () { + final ctrl = + HierarchySearchController.forSignals(service) + ..updateQuery('clk'); + expect(ctrl.hasResults, isTrue); + + ctrl.selectAt(0); + expect(ctrl.selectedIndex, 0); + }); + + test('selectAt clamps high index to last result', () { + final ctrl = + HierarchySearchController.forSignals(service) + ..updateQuery('clk'); + expect(ctrl.hasResults, isTrue); + + ctrl.selectAt(999); + expect(ctrl.selectedIndex, ctrl.results.length - 1); + }); + + test('selectAt clamps negative index to zero', () { + final ctrl = + HierarchySearchController.forSignals(service) + ..updateQuery('clk'); + expect(ctrl.hasResults, isTrue); + + ctrl.selectAt(-5); + expect(ctrl.selectedIndex, 0); + }); + + test('selectAt on empty results is no-op', () { + final ctrl = + HierarchySearchController.forSignals(service) + ..selectAt(3); + expect(ctrl.selectedIndex, 0); + expect(ctrl.hasResults, isFalse); + }); + + test('tabComplete expands to longest common prefix', () { + final ctrl = + HierarchySearchController.forSignals(service) + ..updateQuery('clk'); + if (ctrl.results.length > 1) { + final expansion = ctrl.tabComplete('clk'); + // Expansion should be longer than the query if results share a + // common prefix beyond 'clk' + if (expansion != null) { + expect(expansion.length, greaterThan(3)); + } + } + }); + + test('tabComplete returns null when no results', () { + final ctrl = + HierarchySearchController.forSignals(service) + ..updateQuery('zzz_nonexistent'); + expect(ctrl.tabComplete('zzz_nonexistent'), isNull); + }); + + test('tabComplete returns null when prefix is not longer', () { + final ctrl = + HierarchySearchController.forSignals(service) + ..updateQuery('clk'); + // If there's a single result whose displayPath equals normalized + // query, tabComplete should return null or the path itself. + // With multiple results from different modules, the common prefix + // may not be longer. + final result = ctrl.tabComplete(ctrl.results.first.displayPath); + // Either null or the same length — shouldn't crash + expect(result, anyOf(isNull, isA())); + }); + }); + + // ─────────────── ModuleSearchResult getters ─────────────── + + group('ModuleSearchResult — additional getters', () { + test('isModule reflects non-primitive node', () { + final results = service.searchOccurrences('ch0'); + expect(results, isNotEmpty); + final r = results.first; + expect(r.isModule, isNotNull); + }); + + test('childCount reflects node.children.length', () { + final results = service.searchOccurrences('FilterBank'); + final fbResult = results.firstWhere((r) => r.path.length == 1, + orElse: () => results.first); + expect(fbResult.childCount, greaterThan(0)); + }); + + test('toString includes module name', () { + final results = service.searchOccurrences('ch0'); + expect(results.first.toString(), contains('ch0')); + }); + }); + + // ─────────────── SignalSearchResult toString ─────────────── + + group('SignalSearchResult.toString', () { + test('toString includes signal name', () { + final results = service.searchSignals('clk'); + expect(results, isNotEmpty); + expect(results.first.toString(), contains('clk')); + }); + }); + + // ─────────────── BaseHierarchyAdapter edge case ─────────────── + // The real uninitialized-root StateError test lives in + // coverage_gaps_test.dart. Here we just verify fromTree works. + + group('BaseHierarchyAdapter — fromTree produces usable root', () { + test('fromTree immediately sets root', () { + final tree = HierarchyOccurrence(name: 'r'); + final svc = BaseHierarchyAdapter.fromTree(tree); + expect(svc.root.name, 'r'); + }); + }); + + // ─────────────── Multiple instantiation (dedup) ─────────────── + + group('Multiple instantiation — FilterChannel dedup', () { + test('ch0 and ch1 are separate node instances', () { + final ch0 = service.root.children.firstWhere((c) => c.name == 'ch0_1'); + final ch1 = service.root.children.firstWhere((c) => c.name == 'ch1_1'); + expect(identical(ch0, ch1), isFalse); + }); + + test('ch0 and ch1 have identical signal structure', () { + final ch0 = service.root.children.firstWhere((c) => c.name == 'ch0_1'); + final ch1 = service.root.children.firstWhere((c) => c.name == 'ch1_1'); + + expect(ch0.signals.length, ch1.signals.length); + + final ch0PortNames = ch0.signals.map((s) => s.name).toSet(); + final ch1PortNames = ch1.signals.map((s) => s.name).toSet(); + expect(ch0PortNames, ch1PortNames); + }); + + test('search finds signals in both channel instances', () { + // Both channels should have a clk port + final results = service.searchSignals('clk'); + final channelClks = results + .where((r) => + r.signalId.contains('ch0_1') || r.signalId.contains('ch1_1')) + .toList(); + // Should find clk in both ch0_1 and ch1_1 + expect( + channelClks.where((r) => r.signalId.contains('ch0_1')), isNotEmpty); + expect( + channelClks.where((r) => r.signalId.contains('ch1_1')), isNotEmpty); + }); + + test('addresses resolve independently for each instance', () { + final ch0Addr = OccurrenceAddress.tryFromPathname( + 'FilterBank/ch0_1/clk', service.root); + final ch1Addr = OccurrenceAddress.tryFromPathname( + 'FilterBank/ch1_1/clk', service.root); + + expect(ch0Addr, isNotNull); + expect(ch1Addr, isNotNull); + expect(ch0Addr, isNot(equals(ch1Addr))); + + final ch0Sig = service.signalByAddress(ch0Addr!); + final ch1Sig = service.signalByAddress(ch1Addr!); + expect(ch0Sig, isNotNull); + expect(ch1Sig, isNotNull); + expect(ch0Sig!.name, 'clk'); + expect(ch1Sig!.name, 'clk'); + }); + + test('both instances have internal (non-port) signals', () { + final ch0 = service.root.children.firstWhere((c) => c.name == 'ch0_1'); + final ch1 = service.root.children.firstWhere((c) => c.name == 'ch1_1'); + + final ch0Internal = ch0.signals.where((s) => !s.isPort).toList(); + final ch1Internal = ch1.signals.where((s) => !s.isPort).toList(); + + expect(ch0Internal, isNotEmpty, + reason: 'ch0_1 should have internal signals from netnames'); + expect(ch1Internal, isNotEmpty, + reason: 'ch1_1 should have internal signals from netnames'); + }); + + test('both instances share the same internal signal names', () { + final ch0 = service.root.children.firstWhere((c) => c.name == 'ch0_1'); + final ch1 = service.root.children.firstWhere((c) => c.name == 'ch1_1'); + + final ch0Names = + ch0.signals.where((s) => !s.isPort).map((s) => s.name).toSet(); + final ch1Names = + ch1.signals.where((s) => !s.isPort).map((s) => s.name).toSet(); + expect(ch0Names, ch1Names); + }); + + test('internal signals are addressable per-instance', () { + // validPipe exists as a netname in both FilterChannel definitions + final ch0Addr = OccurrenceAddress.tryFromPathname( + 'FilterBank/ch0_1/validPipe', service.root); + final ch1Addr = OccurrenceAddress.tryFromPathname( + 'FilterBank/ch1_1/validPipe', service.root); + + expect(ch0Addr, isNotNull, reason: 'ch0_1/validPipe should resolve'); + expect(ch1Addr, isNotNull, reason: 'ch1_1/validPipe should resolve'); + expect(ch0Addr, isNot(equals(ch1Addr))); + + final ch0Sig = service.signalByAddress(ch0Addr!); + final ch1Sig = service.signalByAddress(ch1Addr!); + expect(ch0Sig, isNotNull); + expect(ch1Sig, isNotNull); + expect(ch0Sig!.name, 'validPipe'); + expect(ch1Sig!.name, 'validPipe'); + expect(ch0Sig.isPort, isFalse); + }); + + test('search finds internal signals in both instances', () { + final results = service.searchSignals('validPipe'); + final inCh0 = results.where((r) => r.signalId.contains('ch0_1')); + final inCh1 = results.where((r) => r.signalId.contains('ch1_1')); + expect(inCh0, isNotEmpty, reason: 'validPipe should be found in ch0_1'); + expect(inCh1, isNotEmpty, reason: 'validPipe should be found in ch1_1'); + }); + + test('depthFirstSignals includes internal signals from both instances', () { + final all = service.root.depthFirstSignals(); + final vpSigs = all.where((s) => s.name == 'validPipe').toList(); + expect(vpSigs.length, greaterThanOrEqualTo(2), + reason: 'validPipe should appear in at least ch0 and ch1'); + }); + }); + + // ─────────────── InOut (bidirectional) port tests ─────────────── + + group('InOut port — dataBus', () { + test('root has dataBus as inout port', () { + final dataBus = service.root.signals + .where((s) => s.isPort) + .where((p) => p.name == 'dataBus') + .firstOrNull; + expect(dataBus, isNotNull, reason: 'FilterBank should have dataBus'); + expect(dataBus!.direction, 'inout'); + expect(dataBus.isInout, isTrue); + expect(dataBus.isInput, isFalse); + expect(dataBus.isOutput, isFalse); + }); + + test('inputs getter excludes inout ports', () { + final inputs = service.root.inputs; + final inoutInInputs = inputs.where((s) => s.direction == 'inout'); + expect(inoutInInputs, isEmpty, + reason: 'inputs should not include inout ports'); + }); + + test('outputs getter excludes inout ports', () { + final outputs = service.root.outputs; + final inoutInOutputs = outputs.where((s) => s.direction == 'inout'); + expect(inoutInOutputs, isEmpty, + reason: 'outputs should not include inout ports'); + }); + + test('ports getter includes inout ports', () { + final allPorts = service.root.ports; + final inouts = allPorts.where((p) => p.direction == 'inout').toList(); + expect(inouts, isNotEmpty, reason: 'ports should include inout ports'); + expect(inouts.first.name, 'dataBus'); + }); + + test('dataBus is addressable and resolvable', () { + final addr = + OccurrenceAddress.tryFromPathname('FilterBank/dataBus', service.root); + expect(addr, isNotNull, reason: 'dataBus should be addressable'); + + final sig = service.signalByAddress(addr!); + expect(sig, isNotNull); + expect(sig!.name, 'dataBus'); + expect(sig.isInout, isTrue); + }); + + test('search finds dataBus inout port', () { + final results = service.searchSignals('dataBus'); + expect(results, isNotEmpty); + final dataBusResults = + results.where((r) => r.signalId.contains('dataBus')); + expect(dataBusResults, isNotEmpty); + }); + + test('SharedDataBus child also has dataBus inout', () { + final sharedBus = service.root.children + .where((c) => c.name == 'sharedBus_1') + .firstOrNull; + expect(sharedBus, isNotNull, + reason: 'sharedBus_1 cell should be present'); + final childDataBus = sharedBus!.signals + .where((s) => s.isPort) + .where((p) => p.name == 'dataBus') + .firstOrNull; + expect(childDataBus, isNotNull, + reason: 'SharedDataBus should have dataBus inout'); + expect(childDataBus!.isInout, isTrue); + }); + + test('depthFirstSignals includes inout ports', () { + final all = service.root.depthFirstSignals(); + final inouts = all.where((s) => s.isInout); + expect(inouts, isNotEmpty, + reason: 'depthFirstSignals should include inout ports'); + }); + + test('addressToPathname round-trips for inout signal', () { + final addr = + OccurrenceAddress.tryFromPathname('FilterBank/dataBus', service.root); + expect(addr, isNotNull); + final pathname = service.addressToPathname(addr!, asSignal: true); + expect(pathname, 'FilterBank/dataBus'); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/fixtures/filter_bank.json b/packages/rohd_hierarchy/test/fixtures/filter_bank.json new file mode 100644 index 000000000..494318612 --- /dev/null +++ b/packages/rohd_hierarchy/test/fixtures/filter_bank.json @@ -0,0 +1,1183 @@ +{ + "modules": { + "CoeffBank_T3_W16": { + "attributes": { + "src": "generated" + }, + "ports": { + "tapIndex": { + "direction": "input", + "bits": [ + 2, + 3 + ] + }, + "coeffArray": { + "direction": "input", + "bits": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51 + ] + }, + "coeffOut": { + "direction": "output", + "bits": [ + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67 + ] + } + }, + "netnames": { + "tapMatch0": { + "bits": [ + 68 + ], + "attributes": {} + }, + "tapMatch1": { + "bits": [ + 69 + ], + "attributes": {} + }, + "const_0_2_h0": { + "bits": [ + 103, + 104 + ], + "attributes": { + "computed": 1 + } + } + }, + "cells": { + "mux_3": { + "type": "$mux", + "port_directions": { + "S": "input", + "A": "input", + "B": "input", + "Y": "output" + } + }, + "equals_3": { + "type": "$eq", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + }, + "mux_0_1": { + "type": "$mux", + "port_directions": { + "S": "input", + "A": "input", + "B": "input", + "Y": "output" + } + }, + "equals_0_1": { + "type": "$eq", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + }, + "mux_1_1": { + "type": "$mux", + "port_directions": { + "S": "input", + "A": "input", + "B": "input", + "Y": "output" + } + } + } + }, + "MacUnit_W16": { + "attributes": { + "src": "generated" + }, + "ports": { + "sampleIn": { + "direction": "input", + "bits": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ] + }, + "coeffIn": { + "direction": "input", + "bits": [ + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33 + ] + }, + "accumIn": { + "direction": "input", + "bits": [ + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49 + ] + }, + "clk": { + "direction": "input", + "bits": [ + 50 + ] + }, + "reset": { + "direction": "input", + "bits": [ + 51 + ] + }, + "enable": { + "direction": "input", + "bits": [ + 52 + ] + }, + "result": { + "direction": "output", + "bits": [ + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68 + ] + } + }, + "netnames": { + "sampleIn_stage2_i": { + "bits": [ + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84 + ], + "attributes": {} + }, + "sampleIn_stage0_o": { + "bits": [ + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100 + ], + "attributes": {} + } + }, + "cells": { + "comb_stage2_1": { + "type": "Combinational", + "port_directions": { + "_in0_sampleIn_stage2_i": "input", + "_in2_coeffIn_stage2_i": "input", + "_in4_accumIn_stage2_i": "input", + "_out1_sampleIn_stage2": "output", + "_out3_coeffIn_stage2": "output", + "_out5_accumIn_stage2": "output" + } + }, + "ff_sampleIn_1": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in4_sampleIn_stage0_o": "input", + "_in5_sampleIn_stage1_o": "input", + "_trigger0_clk": "input", + "_out6_sampleIn_stage1_i": "output", + "_out7_sampleIn_stage2_i": "output" + } + }, + "comb_stage0_1": { + "type": "Combinational", + "port_directions": { + "_in0_sampleIn_stage0_i": "input", + "_in2_coeffIn_stage0_i": "input", + "_in4_accumIn_stage0_i": "input", + "_in6_product": "input", + "_out1_sampleIn_stage0": "output", + "_out3_coeffIn_stage0": "output", + "_out5_accumIn_stage0": "output", + "_out7_sampleIn_stage0": "output" + } + }, + "multiply_1": { + "type": "$mul", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + }, + "ff_coeffIn_1": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in4_coeffIn_stage0_o": "input", + "_in5_coeffIn_stage1_o": "input", + "_trigger0_clk": "input", + "_out6_coeffIn_stage1_i": "output", + "_out7_coeffIn_stage2_i": "output" + } + } + } + }, + "FilterChannel_T3_W16": { + "attributes": { + "src": "generated" + }, + "ports": { + "sampleIn": { + "direction": "input", + "bits": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ] + }, + "validIn": { + "direction": "input", + "bits": [ + 18 + ] + }, + "clk": { + "direction": "input", + "bits": [ + 19 + ] + }, + "reset": { + "direction": "input", + "bits": [ + 20 + ] + }, + "enable": { + "direction": "input", + "bits": [ + 21 + ] + }, + "dataOut": { + "direction": "output", + "bits": [ + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37 + ] + }, + "validOut": { + "direction": "output", + "bits": [ + 38 + ] + } + }, + "netnames": { + "validPipe": { + "bits": [ + 39 + ], + "attributes": {} + }, + "outputReady": { + "bits": [ + 40 + ], + "attributes": {} + }, + "const_0_2_h0": { + "bits": [ + 420, + 421 + ], + "attributes": { + "computed": 1 + } + } + }, + "cells": { + "combinational_2": { + "type": "Combinational", + "port_directions": { + "_in0_validPipe": "input", + "_in1_outputReg": "input", + "_out4_dataOut": "output", + "_out5_validOut": "output" + } + }, + "sequential_3": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in3_enable": "input", + "_in4_outputReady": "input", + "_trigger0_clk": "input", + "_out5_validPipe": "output" + } + }, + "and__3": { + "type": "$and", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + }, + "sequential_0_1": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in5_lastTap": "input", + "_in6_lastTapD1": "input", + "_in7_lastTapD2": "input", + "_in8_accumReg": "input", + "_trigger0_clk": "input", + "_out9_lastTapD1": "output", + "_out10_lastTapD2": "output", + "_out11_outputReg": "output" + } + }, + "sequential_1_1": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in3_enable": "input", + "_in4_lastTap": "input", + "_in7__tapCounter_add_const_1": "input", + "_trigger0_clk": "input", + "_out10_tapCounter": "output" + } + } + } + }, + "FilterController": { + "attributes": { + "src": "generated" + }, + "ports": { + "clk": { + "direction": "input", + "bits": [ + 2 + ] + }, + "reset": { + "direction": "input", + "bits": [ + 3 + ] + }, + "start": { + "direction": "input", + "bits": [ + 4 + ] + }, + "inputValid": { + "direction": "input", + "bits": [ + 5 + ] + }, + "inputDone": { + "direction": "input", + "bits": [ + 6 + ] + }, + "filterEnable": { + "direction": "output", + "bits": [ + 7 + ] + }, + "loadingPhase": { + "direction": "output", + "bits": [ + 8 + ] + }, + "doneFlag": { + "direction": "output", + "bits": [ + 9 + ] + }, + "state": { + "direction": "output", + "bits": [ + 10, + 11, + 12 + ] + } + }, + "netnames": { + "currentState": { + "bits": [ + 13, + 14, + 15 + ], + "attributes": {} + }, + "isDraining": { + "bits": [ + 16 + ], + "attributes": {} + }, + "const_0_3_h3": { + "bits": [ + 94, + 95, + 96 + ], + "attributes": { + "computed": 1 + } + } + }, + "cells": { + "combinational_1": { + "type": "Combinational", + "port_directions": { + "_in0_currentState": "input", + "_in1_FilterState_idle": "input", + "_in6_start": "input", + "_in9_FilterState_loading": "input", + "_in14_inputValid": "input", + "_in17_FilterState_running": "input", + "_in22_inputDone": "input", + "_in25_FilterState_draining": "input", + "_in30_drainDone": "input", + "_in33_FilterState_done": "input", + "_out42_filterEnable": "output", + "_out43_loadingPhase": "output", + "_out44_doneFlag": "output", + "_out45_nextState": "output" + } + }, + "swizzle_1": { + "type": "$buf", + "port_directions": { + "A": "input", + "Y": "output" + } + }, + "equals_2": { + "type": "$eq", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + }, + "sequential_2": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in3_isDraining": "input", + "_in4__drainCount_add_const_1": "input", + "_trigger0_clk": "input", + "_out7_drainCount": "output" + } + }, + "equals_0_1": { + "type": "$eq", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + } + } + }, + "FilterChannel_T3_W16_0": { + "attributes": { + "src": "generated" + }, + "ports": { + "sampleIn": { + "direction": "input", + "bits": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ] + }, + "validIn": { + "direction": "input", + "bits": [ + 18 + ] + }, + "clk": { + "direction": "input", + "bits": [ + 19 + ] + }, + "reset": { + "direction": "input", + "bits": [ + 20 + ] + }, + "enable": { + "direction": "input", + "bits": [ + 21 + ] + }, + "dataOut": { + "direction": "output", + "bits": [ + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37 + ] + }, + "validOut": { + "direction": "output", + "bits": [ + 38 + ] + } + }, + "netnames": { + "validPipe": { + "bits": [ + 39 + ], + "attributes": {} + }, + "outputReady": { + "bits": [ + 40 + ], + "attributes": {} + }, + "const_0_2_h0": { + "bits": [ + 420, + 421 + ], + "attributes": { + "computed": 1 + } + } + }, + "cells": { + "combinational_2": { + "type": "Combinational", + "port_directions": { + "_in0_validPipe": "input", + "_in1_outputReg": "input", + "_out4_dataOut": "output", + "_out5_validOut": "output" + } + }, + "sequential_3": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in3_enable": "input", + "_in4_outputReady": "input", + "_trigger0_clk": "input", + "_out5_validPipe": "output" + } + }, + "and__3": { + "type": "$and", + "port_directions": { + "A": "input", + "B": "input", + "Y": "output" + } + }, + "sequential_0_1": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in5_lastTap": "input", + "_in6_lastTapD1": "input", + "_in7_lastTapD2": "input", + "_in8_accumReg": "input", + "_trigger0_clk": "input", + "_out9_lastTapD1": "output", + "_out10_lastTapD2": "output", + "_out11_outputReg": "output" + } + }, + "sequential_1_1": { + "type": "Sequential", + "port_directions": { + "_in0_reset": "input", + "_in3_enable": "input", + "_in4_lastTap": "input", + "_in7__tapCounter_add_const_1": "input", + "_trigger0_clk": "input", + "_out10_tapCounter": "output" + } + } + } + }, + "SharedDataBus": { + "attributes": { + "src": "generated" + }, + "ports": { + "writeEnable": { + "direction": "input", + "bits": [ + 502 + ] + }, + "clk": { + "direction": "input", + "bits": [ + 503 + ] + }, + "reset": { + "direction": "input", + "bits": [ + 504 + ] + }, + "storedValue": { + "direction": "output", + "bits": [ + 505, + 506, + 507, + 508, + 509, + 510, + 511, + 512, + 513, + 514, + 515, + 516, + 517, + 518, + 519, + 520 + ] + }, + "dataBus": { + "direction": "inout", + "bits": [ + 521, + 522, + 523, + 524, + 525, + 526, + 527, + 528, + 529, + 530, + 531, + 532, + 533, + 534, + 535, + 536 + ] + } + }, + "netnames": { + "latch": { + "bits": [ + 537, + 538, + 539, + 540, + 541, + 542, + 543, + 544, + 545, + 546, + 547, + 548, + 549, + 550, + 551, + 552 + ], + "attributes": {} + } + }, + "cells": {} + }, + "FilterBank": { + "attributes": { + "src": "generated", + "top": 1 + }, + "ports": { + "clk": { + "direction": "input", + "bits": [ + 2 + ] + }, + "reset": { + "direction": "input", + "bits": [ + 3 + ] + }, + "start": { + "direction": "input", + "bits": [ + 4 + ] + }, + "samplesIn": { + "direction": "input", + "bits": [ + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36 + ] + }, + "validIn": { + "direction": "input", + "bits": [ + 37 + ] + }, + "inputDone": { + "direction": "input", + "bits": [ + 38 + ] + }, + "channelOut": { + "direction": "output", + "bits": [ + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70 + ] + }, + "validOut": { + "direction": "output", + "bits": [ + 71 + ] + }, + "done": { + "direction": "output", + "bits": [ + 72 + ] + }, + "state": { + "direction": "output", + "bits": [ + 73, + 74, + 75 + ] + }, + "dataBus": { + "direction": "inout", + "bits": [ + 600, + 601, + 602, + 603, + 604, + 605, + 606, + 607, + 608, + 609, + 610, + 611, + 612, + 613, + 614, + 615 + ] + } + }, + "netnames": { + "channelOut_0_": { + "bits": [ + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54 + ], + "attributes": {} + }, + "sample0_data": { + "bits": [ + 147, + 148, + 149, + 150, + 151, + 152, + 153, + 154, + 155, + 156, + 157, + 158, + 159, + 160, + 161, + 162 + ], + "attributes": {} + }, + "controller_1_loadingPhase": { + "bits": [ + 146 + ], + "hide_name": 1, + "attributes": {} + } + }, + "cells": { + "ch0_1": { + "type": "FilterChannel_T3_W16_0", + "port_directions": { + "sampleIn": "input", + "validIn": "input", + "clk": "input", + "reset": "input", + "enable": "input", + "dataOut": "output", + "validOut": "output" + } + }, + "controller_1": { + "type": "FilterController", + "port_directions": { + "clk": "input", + "reset": "input", + "start": "input", + "inputValid": "input", + "inputDone": "input", + "filterEnable": "output", + "loadingPhase": "output", + "doneFlag": "output", + "state": "output" + } + }, + "ch1_1": { + "type": "FilterChannel_T3_W16", + "port_directions": { + "sampleIn": "input", + "validIn": "input", + "clk": "input", + "reset": "input", + "enable": "input", + "dataOut": "output", + "validOut": "output" + } + }, + "array_slice_3": { + "type": "$slice", + "port_directions": { + "A": "input", + "Y": "output" + } + }, + "array_slice_4": { + "type": "$slice", + "port_directions": { + "A": "input", + "Y": "output" + } + }, + "sharedBus_1": { + "type": "SharedDataBus", + "port_directions": { + "writeEnable": "input", + "clk": "input", + "reset": "input", + "storedValue": "output", + "dataBus": "inout" + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/rohd_hierarchy/test/hierarchy_path_vs_signal_id_test.dart b/packages/rohd_hierarchy/test/hierarchy_path_vs_signal_id_test.dart new file mode 100644 index 000000000..9c574df79 --- /dev/null +++ b/packages/rohd_hierarchy/test/hierarchy_path_vs_signal_id_test.dart @@ -0,0 +1,194 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_path_vs_signal_id_test.dart +// Verifies that signal.path(separator:) and search result signalId +// work correctly with different separators. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('signal.path() with separator', () { + late BaseHierarchyAdapter adapter; + + setUp(() { + final root = HierarchyOccurrence( + name: 'abcd', + signals: [ + SignalOccurrence(name: 'clk', width: 1, direction: 'input'), + SignalOccurrence(name: 'arvalid_s', width: 1, direction: 'input'), + ], + children: [ + HierarchyOccurrence( + name: 'lab', + signals: [ + SignalOccurrence(name: 'clk', width: 1, direction: 'input'), + SignalOccurrence(name: 'data', width: 8, direction: 'output'), + ], + ), + ], + )..buildAddresses(); + adapter = BaseHierarchyAdapter.fromTree(root); + }); + + SignalOccurrence? resolve(String path) { + final addr = OccurrenceAddress.tryFromPathname(path, adapter.root); + return addr != null ? adapter.signalByAddress(addr) : null; + } + + test('resolves dot-separated IDs', () { + final s = resolve('abcd.clk'); + expect(s, isNotNull); + expect(s!.path(separator: '.'), 'abcd.clk'); + }); + + test('resolves slash-separated IDs', () { + final s = resolve('abcd/clk'); + expect(s, isNotNull); + expect(s!.path(), 'abcd/clk'); + }); + + test('resolves with exact case', () { + final s = resolve('abcd.clk'); + expect(s, isNotNull); + expect(s!.path(), 'abcd/clk'); + }); + + test('resolves nested dot-separated IDs', () { + final s = resolve('abcd.lab.data'); + expect(s, isNotNull); + expect(s!.path(separator: '.'), 'abcd.lab.data'); + }); + + test('resolves nested slash-separated IDs', () { + final s = resolve('abcd/lab/data'); + expect(s, isNotNull); + expect(s!.path(), 'abcd/lab/data'); + }); + + test('searchSignals returns result with resolved signal', () { + final results = adapter.searchSignals('clk'); + expect(results, isNotEmpty); + for (final r in results) { + expect(r.signal, isNotNull, + reason: 'SignalOccurrence should be resolved for "${r.signalId}"'); + } + final clkResult = + results.firstWhere((r) => r.path.last == 'clk' && r.path.length == 2); + expect(clkResult.signal!.path(), 'abcd/clk'); + expect(clkResult.signal!.path(separator: '.'), 'abcd.clk'); + }); + + test('searchSignals signalId is walker-built (slash) path', () { + final results = adapter.searchSignals('clk'); + expect(results, isNotEmpty); + final clkResult = + results.firstWhere((r) => r.path.last == 'clk' && r.path.length == 2); + expect(clkResult.signalId, 'abcd/clk'); + }); + + test('signal.path() matches signalId with default separator', () { + final results = adapter.searchSignals('clk'); + final result = results.first; + expect(result.signal, isNotNull); + expect(result.signal!.path(), result.signalId); + }); + + test('searchSignalsRegex returns result with signal resolved', () { + final results = adapter.searchSignalsRegex('**/clk'); + expect(results.length, greaterThanOrEqualTo(2)); + for (final r in results) { + expect(r.signal, isNotNull, + reason: 'SignalOccurrence should be resolved for "${r.signalId}"'); + expect(r.signal!.path(), contains('/')); + } + }); + }); + + group('ROHD slash-separated hierarchy', () { + late BaseHierarchyAdapter adapter; + + setUp(() { + final root = HierarchyOccurrence( + name: 'Top', + signals: [SignalOccurrence(name: 'clk', width: 1)], + children: [ + HierarchyOccurrence( + name: 'cpu', + signals: [SignalOccurrence(name: 'data_out', width: 8)], + ), + ], + )..buildAddresses(); + adapter = BaseHierarchyAdapter.fromTree(root); + }); + + test('ROHD signals: path() matches signalId (both slash)', () { + final results = adapter.searchSignals('clk'); + expect(results, isNotEmpty); + final r = results.first; + expect(r.signal!.path(), r.signalId); + }); + }); + + group('SignalOccurrence as port', () { + test('creates a port signal with defaults', () { + final p = SignalOccurrence(name: 'clk', width: 1, direction: 'input'); + expect(p.name, 'clk'); + expect(p.direction, 'input'); + expect(p.width, 1); + expect(p.isPort, isTrue); + expect(p.isInput, isTrue); + }); + + test('creates a port signal with explicit overrides', () { + final p = SignalOccurrence( + name: 'data', + direction: 'output', + width: 32, + isComputed: true, + ); + HierarchyOccurrence(name: 'Top', signals: [p]).buildAddresses(); + expect(p.name, 'data'); + expect(p.width, 32); + expect(p.direction, 'output'); + expect(p.path(), 'Top/data'); + expect(p.parent!.path(), 'Top'); + expect(p.isComputed, isTrue); + expect(p.isOutput, isTrue); + }); + }); + + group('SignalOccurrence.value', () { + test('value is null by default', () { + final s = SignalOccurrence(name: 'a', width: 1); + expect(s.value, isNull); + }); + + test('value stores the provided runtime value', () { + final s = SignalOccurrence(name: 'a', width: 8, value: 'ff'); + expect(s.value, 'ff'); + }); + }); + + group('SignalOccurrence.parent', () { + test('parent is null before buildAddresses', () { + final s = SignalOccurrence(name: 'a', width: 1); + expect(s.parent, isNull); + }); + + test('parent is set after buildAddresses', () { + final s = SignalOccurrence(name: 'a', width: 1); + HierarchyOccurrence( + name: 'Top', + children: [ + HierarchyOccurrence(name: 'sub', signals: [s]) + ], + ).buildAddresses(); + expect(s.parent!.path(), 'Top/sub'); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/hierarchy_query_test.dart b/packages/rohd_hierarchy/test/hierarchy_query_test.dart new file mode 100644 index 000000000..7425aaa43 --- /dev/null +++ b/packages/rohd_hierarchy/test/hierarchy_query_test.dart @@ -0,0 +1,655 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_query_test.dart +// Tests for PrefixQuery and RegexQuery matching logic. +// +// 2026 May +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +/// Build a test hierarchy: +/// +/// ```text +/// SoC +/// ├─ signals: [clk, reset, irq0, irq1] +/// ├─ cpu0 +/// │ ├─ signals: [clk, reset, pc] +/// │ ├─ alu +/// │ │ └─ signals: [a, b, result, carry_out, overflow] +/// │ ├─ regfile +/// │ │ └─ signals: [clk, reset, d0, d1, d2, d15, wr_en] +/// │ └─ decoder +/// │ └─ signals: [opcode, enable, mode] +/// ├─ cpu1 +/// │ ├─ signals: [clk, reset, pc] +/// │ ├─ alu +/// │ │ └─ signals: [a, b, result, carry_out, overflow] +/// │ └─ regfile +/// │ └─ signals: [clk, reset, d0, d1, d2, d15, wr_en] +/// ├─ mem_ctrl +/// │ ├─ signals: [clk, reset, addr, data_in, data_out, valid] +/// │ ├─ ch0 +/// │ │ └─ signals: [clk, addr, data, hit, miss] +/// │ ├─ ch1 +/// │ │ └─ signals: [clk, addr, data, hit, miss] +/// │ └─ ch2 +/// │ └─ signals: [clk, addr, data, hit, miss] +/// └─ io_mux +/// ├─ signals: [clk, sel, data_muxed, valid_muxed] +/// ├─ uart0 +/// │ └─ signals: [clk, tx, rx, baud_sel] +/// └─ uart1 +/// └─ signals: [clk, tx, rx, baud_sel] +/// ``` +HierarchyService buildTestHierarchy() { + HierarchyOccurrence mkAlu() => HierarchyOccurrence( + name: 'alu', + definition: 'ALU', + signals: [ + SignalOccurrence(name: 'a', width: 8), + SignalOccurrence(name: 'b', width: 8), + SignalOccurrence(name: 'result', width: 8), + SignalOccurrence(name: 'carry_out', width: 1), + SignalOccurrence(name: 'overflow', width: 1), + ], + ); + + HierarchyOccurrence mkRegfile() => HierarchyOccurrence( + name: 'regfile', + definition: 'RegFile', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'd0', width: 8), + SignalOccurrence(name: 'd1', width: 8), + SignalOccurrence(name: 'd2', width: 8), + SignalOccurrence(name: 'd15', width: 8), + SignalOccurrence(name: 'wr_en', width: 1), + ], + ); + + final decoder = HierarchyOccurrence( + name: 'decoder', + definition: 'Decoder', + signals: [ + SignalOccurrence(name: 'opcode', width: 4), + SignalOccurrence(name: 'enable', width: 1), + SignalOccurrence(name: 'mode', width: 2), + ], + ); + + final cpu0 = HierarchyOccurrence( + name: 'cpu0', + definition: 'CPU', + children: [mkAlu(), mkRegfile(), decoder], + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'pc', width: 32), + ], + ); + + final cpu1 = HierarchyOccurrence( + name: 'cpu1', + definition: 'CPU', + children: [mkAlu(), mkRegfile()], + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'pc', width: 32), + ], + ); + + HierarchyOccurrence mkCacheChannel(String name) => HierarchyOccurrence( + name: name, + definition: 'CacheChannel', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'addr', width: 16), + SignalOccurrence(name: 'data', width: 32), + SignalOccurrence(name: 'hit', width: 1), + SignalOccurrence(name: 'miss', width: 1), + ], + ); + + final memCtrl = HierarchyOccurrence( + name: 'mem_ctrl', + definition: 'MemController', + children: [ + mkCacheChannel('ch0'), + mkCacheChannel('ch1'), + mkCacheChannel('ch2') + ], + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'addr', width: 16), + SignalOccurrence(name: 'data_in', width: 32), + SignalOccurrence(name: 'data_out', width: 32), + SignalOccurrence(name: 'valid', width: 1), + ], + ); + + HierarchyOccurrence mkUart(String name) => HierarchyOccurrence( + name: name, + definition: 'UART', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'tx', width: 1), + SignalOccurrence(name: 'rx', width: 1), + SignalOccurrence(name: 'baud_sel', width: 3), + ], + ); + + final ioMux = HierarchyOccurrence( + name: 'io_mux', + definition: 'IOMux', + children: [mkUart('uart0'), mkUart('uart1')], + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'sel', width: 2), + SignalOccurrence(name: 'data_muxed', width: 8), + SignalOccurrence(name: 'valid_muxed', width: 1), + ], + ); + + final root = HierarchyOccurrence( + name: 'SoC', + definition: 'SoC', + children: [cpu0, cpu1, memCtrl, ioMux], + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'irq0', width: 1), + SignalOccurrence(name: 'irq1', width: 1), + ], + ); + + return BaseHierarchyAdapter.fromTree(root); +} + +void main() { + late HierarchyService svc; + + setUpAll(() { + svc = buildTestHierarchy(); + }); + + // ═══════════════════════════════════════════════════════════════ + // PrefixQuery + // ═══════════════════════════════════════════════════════════════ + + group('PrefixQuery', () { + group('matchOccurrence', () { + test('matches occurrence name containing segment', () { + final q = PrefixQuery('cpu'); + // 'cpu0' contains 'cpu' + expect(q.matchOccurrence('cpu0', 0), equals({1})); + }); + + test('returns empty set when no match', () { + final q = PrefixQuery('mem'); + expect(q.matchOccurrence('cpu0', 0), isEmpty); + }); + + test('past end of segments returns current state', () { + final q = PrefixQuery('cpu'); + // stateIndex == segmentCount → already consumed + expect(q.matchOccurrence('anything', 1), equals({1})); + }); + + test('multi-segment: advances one segment at a time', () { + final q = PrefixQuery('cpu/alu'); + expect(q.matchOccurrence('cpu0', 0), equals({1})); + expect(q.matchOccurrence('alu', 1), equals({2})); + // 'regfile' doesn't match 'alu' + expect(q.matchOccurrence('regfile', 1), isEmpty); + }); + + test('dot separator treated as slash', () { + final q = PrefixQuery('cpu.alu'); + expect(q.segmentCount, equals(2)); + expect(q.matchOccurrence('cpu0', 0), equals({1})); + }); + }); + + group('matchSignal', () { + test('matches signal name with startsWith', () { + final q = PrefixQuery('cpu/clk'); + // At state 1 (after matching 'cpu'), 'clk' starts with 'clk' + expect(q.matchSignal('clk', 1), isTrue); + expect(q.matchSignal('clk_gated', 1), isTrue); + }); + + test('does not match signal for non-last segment', () { + final q = PrefixQuery('cpu/alu/res'); + // At state 1, there are still 2 segments left → only last matches + expect(q.matchSignal('result', 1), isFalse); + // At state 2, this is the last segment + expect(q.matchSignal('result', 2), isTrue); + }); + + test('past end matches any signal', () { + final q = PrefixQuery('cpu'); + expect(q.matchSignal('anything', 1), isTrue); + }); + }); + + group('isComplete', () { + test('complete when stateIndex >= segmentCount', () { + final q = PrefixQuery('cpu/alu'); + expect(q.isComplete(0), isFalse); + expect(q.isComplete(1), isFalse); + expect(q.isComplete(2), isTrue); + expect(q.isComplete(3), isTrue); + }); + }); + + group('isEmpty', () { + test('empty for blank query', () { + expect(PrefixQuery('').isEmpty, isTrue); + expect(PrefixQuery(' ').isEmpty, isTrue); + }); + + test('not empty for real query', () { + expect(PrefixQuery('clk').isEmpty, isFalse); + }); + }); + + group('target property', () { + test('defaults to signals', () { + expect(PrefixQuery('x').target, equals(SearchTarget.signals)); + }); + + test('can be set to occurrences', () { + final q = PrefixQuery('x', target: SearchTarget.occurrences); + expect(q.target, equals(SearchTarget.occurrences)); + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // RegexQuery + // ═══════════════════════════════════════════════════════════════ + + group('RegexQuery', () { + group('exact name matching', () { + test('matches exact occurrence name', () { + final q = RegexQuery('SoC/cpu0/alu'); + expect(q.matchOccurrence('SoC', 0), equals({1})); + expect(q.matchOccurrence('cpu0', 1), equals({2})); + expect(q.matchOccurrence('alu', 2), equals({3})); + }); + + test('does not match wrong name', () { + final q = RegexQuery('SoC/cpu0'); + expect(q.matchOccurrence('cpu1', 1), isEmpty); + }); + + test('case sensitive', () { + final q = RegexQuery('SoC/cpu0'); + expect(q.matchOccurrence('SoC', 0), equals({1})); + expect(q.matchOccurrence('cpu0', 1), equals({2})); + }); + }); + + group('glob wildcard *', () { + test('star matches any characters', () { + final q = RegexQuery('SoC/cpu*'); + expect(q.matchOccurrence('cpu0', 1), equals({2})); + expect(q.matchOccurrence('cpu1', 1), equals({2})); + expect(q.matchOccurrence('mem_ctrl', 1), isEmpty); + }); + + test('star at start', () { + final q = RegexQuery('SoC/*_ctrl'); + expect(q.matchOccurrence('mem_ctrl', 1), equals({2})); + expect(q.matchOccurrence('cpu0', 1), isEmpty); + }); + + test('star in middle', () { + final q = RegexQuery('SoC/io_*'); + expect(q.matchOccurrence('io_mux', 1), equals({2})); + expect(q.matchOccurrence('io_ctrl', 1), equals({2})); + expect(q.matchOccurrence('cpu0', 1), isEmpty); + }); + + test('standalone star matches anything', () { + final q = RegexQuery('SoC/*'); + expect(q.matchOccurrence('cpu0', 1), equals({2})); + expect(q.matchOccurrence('mem_ctrl', 1), equals({2})); + expect(q.matchOccurrence('io_mux', 1), equals({2})); + }); + }); + + group('glob wildcard ?', () { + test('question mark matches one character', () { + final q = RegexQuery('SoC/cpu?'); + expect(q.matchOccurrence('cpu0', 1), equals({2})); + expect(q.matchOccurrence('cpu1', 1), equals({2})); + // 'cpuXY' is two chars after 'cpu' → no match + expect(q.matchOccurrence('cpuXY', 1), isEmpty); + }); + }); + + group('glob-star ** (cross hierarchy boundaries)', () { + test('** matches zero levels', () { + final q = RegexQuery('SoC/**/alu'); + // ** at index 1 can match zero levels → try index 2 ('alu') + // directly against children of SoC + final states = q.matchOccurrence('alu', 1); + // Should include state 1 (stay at **) and possibly skip to 2 + expect(states, contains(1)); + }); + + test('** matches one or more levels', () { + final q = RegexQuery('SoC/**/clk'); + // ** stays at ** when consuming a node + expect(q.matchOccurrence('cpu0', 1), contains(1)); + expect(q.matchOccurrence('alu', 1), contains(1)); + }); + + test('** followed by exact segment', () { + final q = RegexQuery('SoC/**/alu'); + // At state 1 (**), 'alu' should match both staying and advancing + final states = q.matchOccurrence('alu', 1); + expect(states, contains(1)); // stay at ** + expect(states, contains(3)); // skip ** + match 'alu' → index 3 + }); + + test('isComplete with trailing **', () { + final q = RegexQuery('SoC/**'); + expect(q.isComplete(1), isTrue); // ** can match zero + expect(q.isComplete(2), isTrue); // past end + }); + }); + + group('character classes [...]', () { + test('matches character range', () { + final q = RegexQuery('SoC/**/d[0-9]+'); + // Signal matching + expect(q.matchSignal('d0', 2), isTrue); + expect(q.matchSignal('d1', 2), isTrue); + expect(q.matchSignal('d15', 2), isTrue); + expect(q.matchSignal('clk', 2), isFalse); + }); + + test('fixed character set', () { + final q = RegexQuery('SoC/mem_ctrl/ch[012]'); + expect(q.matchOccurrence('ch0', 2), equals({3})); + expect(q.matchOccurrence('ch1', 2), equals({3})); + expect(q.matchOccurrence('ch2', 2), equals({3})); + expect(q.matchOccurrence('ch3', 2), isEmpty); + }); + }); + + group('alternation (...|...)', () { + test('matches either alternative', () { + final q = RegexQuery('SoC/**/(clk|reset)'); + expect(q.matchSignal('clk', 2), isTrue); + expect(q.matchSignal('reset', 2), isTrue); + expect(q.matchSignal('data', 2), isFalse); + }); + + test('alternation on occurrences', () { + final q = RegexQuery('SoC/(cpu0|cpu1)'); + expect(q.matchOccurrence('cpu0', 1), equals({2})); + expect(q.matchOccurrence('cpu1', 1), equals({2})); + expect(q.matchOccurrence('mem_ctrl', 1), isEmpty); + }); + }); + + group('regex quantifiers', () { + test('{n,m} repetition', () { + final q = RegexQuery('SoC/**/d[0-9]{1,2}'); + expect(q.matchSignal('d0', 2), isTrue); + expect(q.matchSignal('d15', 2), isTrue); + // 'd123' has 3 digits → no match (anchored) + expect(q.matchSignal('d123', 2), isFalse); + }); + + test('+ one or more', () { + final q = RegexQuery('SoC/**/irq[0-9]+'); + expect(q.matchSignal('irq0', 2), isTrue); + expect(q.matchSignal('irq1', 2), isTrue); + expect(q.matchSignal('irq', 2), isFalse); + }); + }); + + group('matchSignal', () { + test('exact signal name', () { + final q = RegexQuery('SoC/cpu0/clk'); + expect(q.matchSignal('clk', 2), isTrue); + expect(q.matchSignal('reset', 2), isFalse); + }); + + test('glob * on signal', () { + final q = RegexQuery('SoC/cpu0/alu/*'); + expect(q.matchSignal('a', 3), isTrue); + expect(q.matchSignal('result', 3), isTrue); + }); + + test('glob * prefix on signal', () { + final q = RegexQuery('SoC/**/carry_*'); + expect(q.matchSignal('carry_out', 2), isTrue); + expect(q.matchSignal('overflow', 2), isFalse); + }); + + test('** then signal matches all signals when past segments', () { + final q = RegexQuery('SoC/**'); + // At state 1 (**), isComplete is true → match all signals + expect(q.matchSignal('clk', 1), isTrue); + expect(q.matchSignal('anything', 1), isTrue); + }); + + test('signal does not match non-terminal segment', () { + // SoC/cpu0/alu/result — 'result' is segment index 3, last segment + final q = RegexQuery('SoC/cpu0/alu/result'); + // At state 2, there's still 'result' to match → not last-terminal + expect(q.matchSignal('result', 2), isFalse); + // At state 3 it is the last segment + expect(q.matchSignal('result', 3), isTrue); + }); + }); + + group('isComplete', () { + test('complete when past all segments', () { + final q = RegexQuery('SoC/cpu0'); + expect(q.isComplete(0), isFalse); + expect(q.isComplete(1), isFalse); + expect(q.isComplete(2), isTrue); + }); + + test('complete with trailing glob-stars', () { + final q = RegexQuery('SoC/**'); + expect(q.isComplete(0), isFalse); + expect(q.isComplete(1), isTrue); // ** matches zero + }); + + test('not complete with remaining regex segments', () { + final q = RegexQuery('SoC/**/alu'); + expect(q.isComplete(1), isFalse); // ** then 'alu' remains + }); + }); + + group('target property', () { + test('defaults to signals', () { + expect(RegexQuery('x').target, equals(SearchTarget.signals)); + }); + + test('can be set to both', () { + final q = RegexQuery('x', target: SearchTarget.both); + expect(q.target, equals(SearchTarget.both)); + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // Factory constructors on HierarchyQuery + // ═══════════════════════════════════════════════════════════════ + + group('HierarchyQuery factories', () { + test('.prefix creates PrefixQuery', () { + final q = HierarchyQuery.prefix('cpu/clk'); + expect(q, isA()); + expect(q.segmentCount, equals(2)); + }); + + test('.regex creates RegexQuery', () { + final q = HierarchyQuery.regex('SoC/**/clk'); + expect(q, isA()); + expect(q.segmentCount, equals(3)); + }); + + test('.prefix with target', () { + final q = HierarchyQuery.prefix('x', target: SearchTarget.occurrences); + expect(q.target, equals(SearchTarget.occurrences)); + }); + + test('.regex with target', () { + final q = HierarchyQuery.regex('x', target: SearchTarget.both); + expect(q.target, equals(SearchTarget.both)); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // Edge cases + // ═══════════════════════════════════════════════════════════════ + + group('Edge cases', () { + test('empty query is isEmpty', () { + expect(HierarchyQuery.prefix('').isEmpty, isTrue); + expect(HierarchyQuery.regex('').isEmpty, isTrue); + expect(HierarchyQuery.prefix(' ').isEmpty, isTrue); + expect(HierarchyQuery.regex(' ').isEmpty, isTrue); + }); + + test('PrefixQuery with only separators', () { + final q = PrefixQuery('///'); + expect(q.segmentCount, equals(0)); + expect(q.isEmpty, isFalse); // raw string isn't blank + expect(q.isComplete(0), isTrue); // no segments to match + }); + + test('RegexQuery single segment', () { + final q = RegexQuery('clk'); + expect(q.segmentCount, equals(1)); + expect(q.matchSignal('clk', 0), isTrue); + expect(q.matchSignal('reset', 0), isFalse); + }); + + test('RegexQuery multiple consecutive glob-stars', () { + final q = RegexQuery('SoC/**/**/clk'); + // Should still work — multiple **'s just redundantly match zero+ + expect(q.isComplete(1), isFalse); // **/** then clk + final states = q.matchOccurrence('cpu0', 1); + expect(states, contains(1)); // stay at first ** + }); + + test('RegexQuery with .* explicit regex', () { + final q = RegexQuery('SoC/**/.*mux.*'); + expect(q.matchOccurrence('io_mux', 2), equals({3})); + expect(q.matchSignal('data_muxed', 2), isTrue); + expect(q.matchSignal('valid_muxed', 2), isTrue); + expect(q.matchSignal('clk', 2), isFalse); + }); + + test('PrefixQuery crossesBoundaries is false', () { + expect(PrefixQuery('x').crossesBoundaries, isFalse); + }); + + test('RegexQuery crossesBoundaries is false (uses ** explicitly)', () { + expect(RegexQuery('SoC/**/clk').crossesBoundaries, isFalse); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // Integration: queries against the real hierarchy via + // HierarchyService (to verify the contract makes sense) + // ═══════════════════════════════════════════════════════════════ + + group('Integration with hierarchy (signal path search)', () { + test('PrefixQuery segments match existing search', () { + // Verify PrefixQuery produces the same segments as + // the existing searchSignalPaths logic. + final q = PrefixQuery('cpu/alu/res'); + final paths = svc.searchSignalPaths('cpu/alu/res'); + // Both cpu0 and cpu1 have ALU with 'result' + expect(paths.length, equals(2)); + for (final p in paths) { + expect(p, contains('result')); + } + // Verify the query matches the same way + expect(q.matchOccurrence('cpu0', 0), isNotEmpty); + expect(q.matchOccurrence('alu', 1), isNotEmpty); + expect(q.matchSignal('result', 2), isTrue); + }); + + test('RegexQuery glob matches existing regex search', () { + // SoC/**/clk should find clk at many levels + final paths = svc.searchSignalPathsRegex('SoC/**/clk'); + // clk exists at: SoC, cpu0, cpu1, cpu0/regfile, cpu1/regfile, + // mem_ctrl, ch0, ch1, ch2, io_mux, uart0, uart1 = 12 total + expect(paths.length, equals(12)); + for (final p in paths) { + expect(p, endsWith('/clk')); + } + }); + + test('RegexQuery character class matches indexed signals', () { + final paths = svc.searchSignalPathsRegex('SoC/**/d[0-9]+'); + // d0, d1, d2, d15 in cpu0/regfile and cpu1/regfile = 8 total + expect(paths.length, equals(8)); + for (final p in paths) { + expect(p, matches(RegExp(r'/d\d+$'))); + } + }); + + test('RegexQuery alternation matches specific signals', () { + final paths = svc.searchSignalPathsRegex('SoC/**/(tx|rx)'); + // tx and rx in uart0 and uart1 = 4 total + expect(paths.length, equals(4)); + }); + + test('RegexQuery ch[0-2] matches channel occurrences', () { + final paths = svc.searchOccurrencePathsRegex('SoC/mem_ctrl/ch[0-2]'); + expect(paths.length, equals(3)); + expect( + paths, + containsAll([ + 'SoC/mem_ctrl/ch0', + 'SoC/mem_ctrl/ch1', + 'SoC/mem_ctrl/ch2', + ])); + }); + + test('RegexQuery *_mux matches occurrence by suffix', () { + final paths = svc.searchOccurrencePathsRegex('SoC/*_mux'); + expect(paths.length, equals(1)); + expect(paths.first, equals('SoC/io_mux')); + }); + + test('RegexQuery .*mux.* matches signals containing mux', () { + final paths = svc.searchSignalPathsRegex('SoC/**/.*mux.*'); + expect(paths, contains('SoC/io_mux/data_muxed')); + expect(paths, contains('SoC/io_mux/valid_muxed')); + }); + + test('PrefixQuery finds irq signals at root', () { + final paths = svc.searchSignalPaths('SoC/irq'); + expect(paths.length, equals(2)); + expect(paths, contains('SoC/irq0')); + expect(paths, contains('SoC/irq1')); + }); + + test('RegexQuery baud_sel across both UARTs', () { + final paths = svc.searchSignalPathsRegex('SoC/**/baud_sel'); + expect(paths.length, equals(2)); + expect(paths, contains('SoC/io_mux/uart0/baud_sel')); + expect(paths, contains('SoC/io_mux/uart1/baud_sel')); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/hierarchy_search_controller_test.dart b/packages/rohd_hierarchy/test/hierarchy_search_controller_test.dart new file mode 100644 index 000000000..bd7801f2a --- /dev/null +++ b/packages/rohd_hierarchy/test/hierarchy_search_controller_test.dart @@ -0,0 +1,505 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// hierarchy_search_controller_test.dart +// Tests for HierarchySearchController. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +/// Minimal hierarchy for testing the controller with a real +/// HierarchyService. +HierarchyOccurrence _buildTestTree() => HierarchyOccurrence( + name: 'Top', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'rst', width: 1), + ], + children: [ + HierarchyOccurrence( + name: 'cpu', + signals: [ + SignalOccurrence(name: 'data_in', width: 8), + SignalOccurrence(name: 'data_out', width: 8), + ], + children: [ + HierarchyOccurrence( + name: 'alu', + signals: [ + SignalOccurrence(name: 'a', width: 16), + SignalOccurrence(name: 'b', width: 16), + SignalOccurrence(name: 'result', width: 16), + ], + ), + ], + ), + HierarchyOccurrence( + name: 'mem', + signals: [ + SignalOccurrence(name: 'addr', width: 32), + ], + ), + ], + ); + +void main() { + late BaseHierarchyAdapter hierarchy; + late HierarchySearchController signalCtrl; + late HierarchySearchController moduleCtrl; + + setUp(() { + hierarchy = BaseHierarchyAdapter.fromTree(_buildTestTree()); + signalCtrl = HierarchySearchController.forSignals(hierarchy); + moduleCtrl = HierarchySearchController.forOccurrences(hierarchy); + }); + + group('HierarchySearchController — signal search', () { + test('starts with empty state', () { + expect(signalCtrl.results, isEmpty); + expect(signalCtrl.selectedIndex, 0); + expect(signalCtrl.hasResults, isFalse); + expect(signalCtrl.counterText, isEmpty); + expect(signalCtrl.currentSelection, isNull); + }); + + test('updateQuery populates results', () { + signalCtrl.updateQuery('clk'); + expect(signalCtrl.hasResults, isTrue); + expect(signalCtrl.results.first.name, 'clk'); + expect(signalCtrl.selectedIndex, 0); + }); + + test('updateQuery with empty string clears results', () { + signalCtrl.updateQuery('clk'); + expect(signalCtrl.hasResults, isTrue); + + signalCtrl.updateQuery(''); + expect(signalCtrl.hasResults, isFalse); + expect(signalCtrl.selectedIndex, 0); + }); + + test('updateQuery resets selectedIndex', () { + signalCtrl + ..updateQuery('data') + ..selectNext(); // index 1 + expect(signalCtrl.selectedIndex, 1); + + signalCtrl.updateQuery('data'); // re-search + expect(signalCtrl.selectedIndex, 0); // reset + }); + + test('normalise converts dots to slashes', () { + signalCtrl.updateQuery('cpu.alu.a'); + expect(signalCtrl.hasResults, isTrue); + expect(signalCtrl.results.first.name, 'a'); + }); + + test('counterText is correct', () { + signalCtrl.updateQuery('data'); + expect(signalCtrl.counterText, '1/${signalCtrl.results.length}'); + + signalCtrl.selectNext(); + expect(signalCtrl.counterText, '2/${signalCtrl.results.length}'); + }); + + test('currentSelection returns the highlighted result', () { + signalCtrl.updateQuery('data'); + final first = signalCtrl.currentSelection; + expect(first, isNotNull); + expect(first!.name, 'data_in'); + + signalCtrl.selectNext(); + expect(signalCtrl.currentSelection!.name, 'data_out'); + }); + + test('selectNext wraps around', () { + signalCtrl.updateQuery('data'); + final count = signalCtrl.results.length; + expect(count, greaterThan(1)); + + for (var i = 0; i < count; i++) { + signalCtrl.selectNext(); + } + expect(signalCtrl.selectedIndex, 0); // wrapped + }); + + test('selectPrevious wraps around', () { + signalCtrl + ..updateQuery('data') + ..selectPrevious(); // wraps from 0 → last + expect(signalCtrl.selectedIndex, signalCtrl.results.length - 1); + }); + + test('selectNext/selectPrevious no-op when empty', () { + signalCtrl.selectNext(); + expect(signalCtrl.selectedIndex, 0); + signalCtrl.selectPrevious(); + expect(signalCtrl.selectedIndex, 0); + }); + + test('clear resets everything', () { + signalCtrl + ..updateQuery('data') + ..selectNext(); + expect(signalCtrl.hasResults, isTrue); + expect(signalCtrl.selectedIndex, greaterThan(0)); + + signalCtrl.clear(); + expect(signalCtrl.results, isEmpty); + expect(signalCtrl.selectedIndex, 0); + expect(signalCtrl.currentSelection, isNull); + }); + + test('no results for non-matching query', () { + signalCtrl.updateQuery('xyz_no_match'); + expect(signalCtrl.hasResults, isFalse); + expect(signalCtrl.counterText, isEmpty); + }); + + test('plain query uses prefix match, not substring', () { + // 'a' should match signals starting with 'a' (addr, a), + // but NOT signals that merely contain 'a' (data_in, data_out). + signalCtrl.updateQuery('a'); + final names = signalCtrl.results.map((r) => r.name).toList(); + expect(names, contains('a')); // Top/cpu/alu/a + expect(names, contains('addr')); // Top/mem/addr + expect(names, isNot(contains('data_in'))); // 'a' is not a prefix + expect(names, isNot(contains('data_out'))); + }); + + test('glob * pattern routes to regex search', () { + // 'cpu/*_out' should match signals ending in '_out' under cpu + // (single-segment globs like '*_out' only search root-level; + // use a path segment to target a child module). + signalCtrl.updateQuery('cpu/*_out'); + expect(signalCtrl.hasResults, isTrue); + final names = signalCtrl.results.map((r) => r.name).toList(); + expect(names, contains('data_out')); + expect(names, isNot(contains('data_in'))); + }); + + test('glob * at end matches prefix', () { + signalCtrl.updateQuery('cpu/data*'); + final names = signalCtrl.results.map((r) => r.name).toList(); + expect(names, containsAll(['data_in', 'data_out'])); + }); + + test('glob * at root matches top-level signals', () { + // Single-segment glob only searches root module signals. + signalCtrl.updateQuery('*st'); + expect(signalCtrl.hasResults, isTrue); + final names = signalCtrl.results.map((r) => r.name).toList(); + expect(names, contains('rst')); + }); + }); + + group('HierarchySearchController — module search', () { + test('finds modules by name', () { + moduleCtrl.updateQuery('cpu'); + expect(moduleCtrl.hasResults, isTrue); + expect(moduleCtrl.results.first.occurrence.name, 'cpu'); + }); + + test('finds nested modules', () { + moduleCtrl.updateQuery('alu'); + expect(moduleCtrl.hasResults, isTrue); + expect(moduleCtrl.results.first.occurrence.name, 'alu'); + }); + + test('counterText and selection work for modules', () { + moduleCtrl.updateQuery('m'); // matches 'mem', possibly others + expect(moduleCtrl.hasResults, isTrue); + expect(moduleCtrl.counterText, isNotEmpty); + expect(moduleCtrl.currentSelection, isNotNull); + }); + }); + + group('scrollOffsetToReveal', () { + test('returns null when item is visible', () { + final offset = HierarchySearchController.scrollOffsetToReveal( + selectedIndex: 2, + itemHeight: 48, + viewportHeight: 300, + currentOffset: 0, + ); + // item at 96..144, viewport 0..300 → visible + expect(offset, isNull); + }); + + test('scrolls up when item is above viewport', () { + final offset = HierarchySearchController.scrollOffsetToReveal( + selectedIndex: 0, + itemHeight: 48, + viewportHeight: 300, + currentOffset: 100, + ); + // item at 0..48, viewport starts at 100 → need to scroll to 0 + expect(offset, 0.0); + }); + + test('scrolls down when item is below viewport', () { + final offset = HierarchySearchController.scrollOffsetToReveal( + selectedIndex: 10, + itemHeight: 48, + viewportHeight: 200, + currentOffset: 0, + ); + // item at 480..528, viewport 0..200 → scroll to 528-200 = 328 + expect(offset, 328.0); + }); + + test('returns null when item is at bottom edge', () { + final offset = HierarchySearchController.scrollOffsetToReveal( + selectedIndex: 4, + itemHeight: 50, + viewportHeight: 250, + currentOffset: 0, + ); + // item at 200..250, viewport 0..250 → exactly visible + expect(offset, isNull); + }); + }); + + // ------------------------------------------------------------------ + // VCD-style dot-separated paths + // ------------------------------------------------------------------ + group('VCD dot-separated paths', () { + late BaseHierarchyAdapter vcdHierarchy; + + setUp(() { + // VCD/FST files produce dot-separated IDs like "testbench.childA.clk" + vcdHierarchy = BaseHierarchyAdapter.fromTree( + HierarchyOccurrence( + name: 'testbench', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'rst', width: 1), + ], + children: [ + HierarchyOccurrence( + name: 'childA', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'data', width: 8), + ], + children: [ + HierarchyOccurrence( + name: 'sub', + signals: [ + SignalOccurrence(name: 'out', width: 4), + ], + ), + ], + ), + HierarchyOccurrence( + name: 'childB', + signals: [ + SignalOccurrence(name: 'enable', width: 1), + ], + ), + ], + ), + ); + }); + + test('searchSignalPaths with slash query finds dot-separated signal', () { + // User types "childA/clk" — walker normalises to hierarchySeparator + final results = vcdHierarchy.searchSignalPaths('childA/clk'); + expect(results, isNotEmpty); + expect(results, contains('testbench/childA/clk')); + }); + + test('searchSignalPaths with slash query finds deep signal', () { + final results = vcdHierarchy.searchSignalPaths('childA/sub/out'); + expect(results, isNotEmpty); + expect(results, contains('testbench/childA/sub/out')); + }); + + test('searchSignals with slash query finds dot-separated signal', () { + final results = vcdHierarchy.searchSignals('childA/clk'); + expect(results, isNotEmpty); + expect(results.first.signal!.path(), 'testbench/childA/clk'); + }); + + test('searchModules with slash query finds dot-separated module', () { + final results = vcdHierarchy.searchOccurrences('childA'); + expect(results, isNotEmpty); + expect(results.first.occurrence.path(), 'testbench/childA'); + }); + + test('searchSignalPaths with dot query still works', () { + // Dots in query are treated as separators too + final results = vcdHierarchy.searchSignalPaths('childA.clk'); + expect(results, isNotEmpty); + expect(results, contains('testbench/childA/clk')); + }); + + test('searchSignals with glob on dot-separated paths', () { + // Glob wildcard should work across dot-separated IDs + final results = vcdHierarchy.searchSignals('**/clk'); + expect(results.length, greaterThanOrEqualTo(2)); + final ids = results.map((r) => r.signal!.path()).toSet(); + expect(ids, contains('testbench/clk')); + expect(ids, contains('testbench/childA/clk')); + }); + + test('searchSignals with single segment on dot-separated paths', () { + // Single segment search should use startsWith + final results = vcdHierarchy.searchSignals('ena'); + expect(results, isNotEmpty); + expect(results.first.signal!.path(), 'testbench/childB/enable'); + }); + + test('controller forSignals works with dot-separated hierarchy', () { + final ctrl = + HierarchySearchController.forSignals(vcdHierarchy) + ..updateQuery('childA/clk'); + expect(ctrl.results, isNotEmpty); + expect(ctrl.results.first.signal!.path(), 'testbench/childA/clk'); + }); + }); + + // ------------------------------------------------------------------ + // DevTools flow — hierarchy with local signal IDs + // ------------------------------------------------------------------ + group('DevTools flow — local signal IDs → BaseHierarchyAdapter.fromTree', () { + late BaseHierarchyAdapter rohdHierarchy; + late HierarchySearchController rohdSignalCtrl; + late HierarchySearchController rohdModuleCtrl; + + setUp(() { + // Build a tree with local signal IDs (not full paths) — this is the key + // difference from the VCD path where IDs are full paths. + final alu = HierarchyOccurrence( + name: 'alu', + signals: [ + SignalOccurrence( + name: 'a', + width: 16, + direction: 'input', + ), + SignalOccurrence( + name: 'b', + width: 16, + direction: 'input', + ), + SignalOccurrence( + name: 'result', + width: 16, + direction: 'output', + ), + ], + ); + final cpu = HierarchyOccurrence( + name: 'cpu', + children: [alu], + signals: [ + SignalOccurrence( + name: 'data_in', + width: 8, + direction: 'input', + ), + SignalOccurrence( + name: 'data_out', + width: 8, + direction: 'output', + ), + ], + ); + final mem = HierarchyOccurrence( + name: 'mem', + signals: [ + SignalOccurrence( + name: 'addr', + width: 32, + direction: 'input', + ), + ], + ); + final root = HierarchyOccurrence( + name: 'Top', + children: [cpu, mem], + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + direction: 'input', + ), + SignalOccurrence( + name: 'rst', + width: 1, + direction: 'input', + ), + ], + )..buildAddresses(); + rohdHierarchy = BaseHierarchyAdapter.fromTree(root); + rohdSignalCtrl = HierarchySearchController.forSignals(rohdHierarchy); + rohdModuleCtrl = HierarchySearchController.forOccurrences(rohdHierarchy); + }); + + test('signal IDs are local (not full paths)', () { + final rootSigs = rohdHierarchy.root.signals; + final clk = rootSigs.firstWhere((s) => s.name == 'clk'); + expect(clk.name, 'clk'); + expect(clk.path(), 'Top/clk'); + }); + + test('updateQuery finds signals despite local IDs', () { + rohdSignalCtrl.updateQuery('clk'); + expect(rohdSignalCtrl.hasResults, isTrue); + expect(rohdSignalCtrl.results.first.name, 'clk'); + }); + + test('signalByAddress works with full path', () { + final addr = + OccurrenceAddress.tryFromPathname('Top/clk', rohdHierarchy.root); + final result = rohdHierarchy.signalByAddress(addr!); + expect(result, isNotNull); + expect(result!.name, 'clk'); + }); + + test('signalByAddress works with nested path', () { + final addr = OccurrenceAddress.tryFromPathname( + 'Top/cpu/alu/a', rohdHierarchy.root); + final result = rohdHierarchy.signalByAddress(addr!); + expect(result, isNotNull); + expect(result!.name, 'a'); + }); + + test('path-based search narrows to module', () { + rohdSignalCtrl.updateQuery('cpu/data'); + expect(rohdSignalCtrl.hasResults, isTrue); + final names = rohdSignalCtrl.results.map((r) => r.name).toSet(); + expect(names, containsAll(['data_in', 'data_out'])); + }); + + test('glob search works', () { + rohdSignalCtrl.updateQuery('**/a'); + expect(rohdSignalCtrl.hasResults, isTrue); + final names = rohdSignalCtrl.results.map((r) => r.name).toSet(); + expect(names, contains('a')); + }); + + test('module search works', () { + rohdModuleCtrl.updateQuery('alu'); + expect(rohdModuleCtrl.hasResults, isTrue); + expect(rohdModuleCtrl.results.first.occurrence.name, 'alu'); + }); + + test('search results match VCD-style tree results', () { + // The SAME queries should produce the same signal NAMES as + // the manually-built tree (VCD path), even though signal IDs differ. + rohdSignalCtrl.updateQuery('data'); + final rohdNames = rohdSignalCtrl.results.map((r) => r.name).toSet(); + + signalCtrl.updateQuery('data'); + final vcdNames = signalCtrl.results.map((r) => r.name).toSet(); + + expect(rohdNames, vcdNames, + reason: 'Same query should find same signals regardless of source'); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/module_search_test.dart b/packages/rohd_hierarchy/test/module_search_test.dart new file mode 100644 index 000000000..9339492cf --- /dev/null +++ b/packages/rohd_hierarchy/test/module_search_test.dart @@ -0,0 +1,325 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// module_search_test.dart +// Tests for module tree search functionality using hierarchy API. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('Module Tree Search - HierarchyService', () { + late HierarchyOccurrence root; + late HierarchyService hierarchy; + + setUpAll(() { + // Create a test hierarchy + // Top + // CPU (2 children) + // ALU + // Decoder + // Memory + // ControlUnit + + final alu = HierarchyOccurrence( + name: 'ALU', + ); + + final decoder = HierarchyOccurrence( + name: 'Decoder', + ); + + final cpu = HierarchyOccurrence( + name: 'CPU', + children: [alu, decoder], + ); + + final memory = HierarchyOccurrence( + name: 'Memory', + ); + + final controlUnit = HierarchyOccurrence( + name: 'ControlUnit', + ); + + root = HierarchyOccurrence( + name: 'Top', + children: [cpu, memory, controlUnit], + ); + + // Use BaseHierarchyAdapter.fromTree to convert to HierarchyService + hierarchy = BaseHierarchyAdapter.fromTree(root); + }); + + test('root node is accessible', () { + expect(hierarchy.root.name, equals('Top')); + expect(hierarchy.root.isPrimitive, isFalse); + }); + + test('children of root are accessible', () { + final children = hierarchy.root.children; + expect(children, isNotEmpty); + expect(children.length, equals(3)); + expect(children.any((c) => c.name == 'CPU'), isTrue); + }); + + test('searchNodePaths finds CPU module', () { + final results = hierarchy.searchOccurrencePaths('CPU'); + expect(results, isNotEmpty, + reason: 'Should find CPU module by simple name'); + expect(results.any((path) => path.contains('CPU')), isTrue); + }); + + test('searchNodePaths finds ALU with hierarchical query', () { + final results = hierarchy.searchOccurrencePaths('CPU/ALU'); + expect(results, isNotEmpty, + reason: 'Should find ALU with hierarchical path'); + expect(results.any((path) => path.contains('ALU')), isTrue); + }); + + test('searchNodePaths works with dot notation', () { + final results = hierarchy.searchOccurrencePaths('Top.CPU.ALU'); + expect(results, isNotEmpty, reason: 'Should find ALU with dot notation'); + expect(results.any((path) => path.contains('Top/CPU/ALU')), isTrue); + }); + + test('searchNodePaths limits results', () { + final results = hierarchy.searchOccurrencePaths('', limit: 2); + expect(results.length, lessThanOrEqualTo(2), + reason: 'Should respect limit parameter'); + }); + + test('searchModules returns ModuleSearchResult objects', () { + final results = hierarchy.searchOccurrences('Memory'); + expect(results, isNotEmpty); + expect(results.first, isA()); + expect(results.first.name, equals('Memory')); + expect(results.first.isModule, isTrue); + }); + + test('searchModules result contains full metadata', () { + final results = hierarchy.searchOccurrences('Decoder'); + expect(results, isNotEmpty); + final result = results.first; + expect(result.occurrenceId, contains('Decoder')); + expect(result.path, isNotEmpty); + expect(result.path.last, equals('Decoder')); + expect(result.occurrence, isNotNull); + }); + + test('searchNodePaths returns empty for non-matching query', () { + final results = hierarchy.searchOccurrencePaths('nonexistent'); + expect(results, isEmpty, + reason: 'Should return empty list for non-matching query'); + }); + + test('searchNodePaths returns empty for empty query', () { + final results = hierarchy.searchOccurrencePaths(''); + expect(results, isEmpty, + reason: 'Should return empty list for empty query'); + }); + + test('searchModules finds modules at different depths', () { + // Should find both Top and Top/CPU + final results = hierarchy.searchOccurrences('Top'); + expect(results.length, greaterThanOrEqualTo(1)); + expect(results.any((r) => r.name == 'Top'), isTrue); + }); + }); + + group('Module Search - Hierarchical Matching', () { + late HierarchyService hierarchy; + + setUpAll(() { + // Create a deeper hierarchy to test matching + // Design + // ProcessingUnit + // DataPath + // Multiplier + // Adder + // Controller + // Memory + // RAM + // Cache + + final multiplier = HierarchyOccurrence( + name: 'Multiplier', + ); + + final adder = HierarchyOccurrence( + name: 'Adder', + ); + + final dataPath = HierarchyOccurrence( + name: 'DataPath', + children: [multiplier, adder], + ); + + final controller = HierarchyOccurrence( + name: 'Controller', + ); + + final processingUnit = HierarchyOccurrence( + name: 'ProcessingUnit', + children: [dataPath, controller], + ); + + final ram = HierarchyOccurrence( + name: 'RAM', + ); + + final cache = HierarchyOccurrence( + name: 'Cache', + ); + + final memory = HierarchyOccurrence( + name: 'Memory', + children: [ram, cache], + ); + + final root = HierarchyOccurrence( + name: 'Design', + children: [processingUnit, memory], + ); + + hierarchy = BaseHierarchyAdapter.fromTree(root); + }); + + test('single segment matches at any level', () { + final results = hierarchy.searchOccurrencePaths('Multiplier'); + expect(results, isNotEmpty, + reason: 'Should find Multiplier even without full path'); + expect(results.any((r) => r.endsWith('Multiplier')), isTrue); + }); + + test('two segment path matches correctly', () { + final results = hierarchy.searchOccurrencePaths('DataPath/Multiplier'); + expect(results.any((r) => r.contains('DataPath/Multiplier')), isTrue, + reason: 'Should find Multiplier under DataPath'); + }); + + test('full hierarchical path matches precisely', () { + final results = + hierarchy.searchOccurrencePaths('ProcessingUnit/DataPath/Adder'); + expect(results.any((r) => r.contains('ProcessingUnit/DataPath/Adder')), + isTrue, + reason: 'Should find Adder with full hierarchical path'); + }); + + test('partial name matching works', () { + final results1 = hierarchy.searchOccurrencePaths('Path'); + expect(results1.any((r) => r.contains('DataPath')), isTrue, + reason: 'Should match partial "path" in DataPath'); + + final results2 = hierarchy.searchOccurrencePaths('Unit'); + expect(results2.any((r) => r.contains('ProcessingUnit')), isTrue, + reason: 'Should match partial "unit" in ProcessingUnit'); + }); + }); + + group('Module Search - Integration with Tree Filtering', () { + late HierarchyOccurrence root; + + setUpAll(() { + final alu = HierarchyOccurrence( + name: 'ALU', + ); + + final cpu = HierarchyOccurrence( + name: 'CPU', + children: [alu], + ); + + final memory = HierarchyOccurrence( + name: 'Memory', + ); + + root = HierarchyOccurrence( + name: 'Top', + children: [cpu, memory], + ); + }); + + test('hierarchical filtering shows root when descendant matches', () { + final matchesSearch = _filterNodeRecursive(root, 'alu'); + expect(matchesSearch, isTrue, + reason: 'Root should be shown because descendant matches'); + }); + + test('hierarchical filtering shows parent of matching child', () { + final cpuNode = root.children.first; + final cpuMatches = _filterNodeRecursive(cpuNode, 'alu'); + expect(cpuMatches, isTrue, + reason: 'CPU should be shown because child ALU matches'); + }); + + test('hierarchical filtering hides node without matching descendants', () { + final memoryNode = root.children.last; + final memoryMatches = _filterNodeRecursive(memoryNode, 'alu'); + expect(memoryMatches, isFalse, + reason: 'Memory should be hidden because no ALU descendant'); + }); + + test('path separator search shows root for hierarchical match', () { + final matchesSearch = _filterNodeRecursive(root, 'cpu/alu'); + expect(matchesSearch, isTrue, + reason: 'Root should be shown for hierarchical search'); + }); + + test('path separator search shows matching parent', () { + final cpuNode = root.children.first; + final cpuMatches = _filterNodeRecursive(cpuNode, 'cpu/alu'); + expect(cpuMatches, isTrue, + reason: 'CPU should be shown for hierarchical search'); + }); + + test('path separator search hides non-matching subtree', () { + final memoryNode = root.children.last; + final memoryMatches = _filterNodeRecursive(memoryNode, 'cpu/alu'); + expect(memoryMatches, isFalse, + reason: 'Memory should be hidden for non-matching path'); + }); + }); +} + +/// Helper function to simulate tree filtering with hierarchical search. +/// Matches query against node name using hierarchical logic. +bool _filterNodeRecursive(HierarchyOccurrence node, String query) { + final queryParts = query + .replaceAll('.', '/') + .toLowerCase() + .split('/') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + + return _matchesHierarchicalQuery(node, queryParts, 0); +} + +bool _matchesHierarchicalQuery( + HierarchyOccurrence node, List queryParts, int queryIdx) { + if (queryIdx >= queryParts.length) { + return true; + } + + final currentQueryPart = queryParts[queryIdx].toLowerCase(); + final nodeName = node.name.toLowerCase(); + + final matched = nodeName.contains(currentQueryPart); + final nextQueryIdx = matched ? queryIdx + 1 : queryIdx; + + if (nextQueryIdx >= queryParts.length) { + return true; + } + + for (final child in node.children) { + if (_matchesHierarchicalQuery(child, queryParts, nextQueryIdx)) { + return true; + } + } + + return false; +} diff --git a/packages/rohd_hierarchy/test/occurrence_address_test.dart b/packages/rohd_hierarchy/test/occurrence_address_test.dart new file mode 100644 index 000000000..aafde9050 --- /dev/null +++ b/packages/rohd_hierarchy/test/occurrence_address_test.dart @@ -0,0 +1,335 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// occurrence_address_test.dart +// Unit tests for OccurrenceAddress class. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('OccurrenceAddress', () { + test('child() appends module index', () { + final addr = OccurrenceAddress.root.child(0).child(2).child(4); + expect(addr.path, equals([0, 2, 4])); + }); + + test('signal() appends signal index', () { + final addr = const OccurrenceAddress([0, 1]).signal(5); + expect(addr.path, equals([0, 1, 5])); + }); + + test('equality and hashcode work correctly', () { + const addr1 = OccurrenceAddress([0, 2, 4]); + const addr2 = OccurrenceAddress([0, 2, 4]); + const addr3 = OccurrenceAddress([0, 2, 5]); + + expect(addr1, equals(addr2)); + expect(addr1.hashCode, equals(addr2.hashCode)); + expect(addr1, isNot(equals(addr3))); + expect(addr1.hashCode, isNot(equals(addr3.hashCode))); + }); + + test('toString() returns debug string', () { + expect(OccurrenceAddress.root.toString(), equals('[ROOT]')); + expect(const OccurrenceAddress([0, 2, 4]).toString(), equals('[0.2.4]')); + }); + + test('toDotString() returns dot-separated path', () { + expect(OccurrenceAddress.root.toDotString(), equals('')); + expect(const OccurrenceAddress([0]).toDotString(), equals('0')); + expect(const OccurrenceAddress([0, 2, 4]).toDotString(), equals('0.2.4')); + expect( + const OccurrenceAddress([10, 200]).toDotString(), equals('10.200')); + }); + + test('fromDotString() parses dot-separated path', () { + expect( + OccurrenceAddress.fromDotString(''), equals(OccurrenceAddress.root)); + expect(OccurrenceAddress.fromDotString('0'), + equals(const OccurrenceAddress([0]))); + expect(OccurrenceAddress.fromDotString('0.2.4'), + equals(const OccurrenceAddress([0, 2, 4]))); + expect(OccurrenceAddress.fromDotString('10.200'), + equals(const OccurrenceAddress([10, 200]))); + }); + + test('toDotString/fromDotString round-trip', () { + final testCases = [ + OccurrenceAddress.root, + const OccurrenceAddress([0]), + const OccurrenceAddress([5, 10, 15]), + const OccurrenceAddress([0, 0, 0]), + const OccurrenceAddress([255]), + const OccurrenceAddress([0, 1, 2, 3, 4, 5]), + ]; + for (final original in testCases) { + final dot = original.toDotString(); + final restored = OccurrenceAddress.fromDotString(dot); + expect(restored, equals(original), reason: 'Failed for $original'); + } + }); + }); + + group('OccurrenceAddress with HierarchyNode integration', () { + late HierarchyOccurrence root; + + setUp(() { + // Build a simple tree structure + final child0 = HierarchyOccurrence( + name: 'child_0', + signals: [ + SignalOccurrence( + name: 'sig0', + width: 1, + ), + SignalOccurrence( + name: 'sig1', + width: 8, + ), + ], + ); + + final grandchild = HierarchyOccurrence( + name: 'grandchild_0', + signals: [ + SignalOccurrence( + name: 'sig0', + width: 1, + ), + ], + ); + + final child1 = HierarchyOccurrence( + name: 'child_1', + signals: [ + SignalOccurrence( + name: 'sig0', + width: 4, + ), + ], + ); + + child0.children.add(grandchild); + + root = HierarchyOccurrence( + name: 'root', + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + ), + ], + children: [child0, child1], + ) + // Build addresses for all nodes + ..buildAddresses(); + }); + + test('buildAddresses assigns address to root', () { + expect(root.address, equals(OccurrenceAddress.root)); + }); + + test('buildAddresses assigns addresses to all nodes', () { + expect(root.children[0].address, equals(const OccurrenceAddress([0]))); + expect(root.children[1].address, equals(const OccurrenceAddress([1]))); + expect(root.children[0].children[0].address, + equals(const OccurrenceAddress([0, 0]))); + }); + + test('buildAddresses assigns addresses to all signals', () { + // Root signals + expect(root.signals[0].address, equals(const OccurrenceAddress([0]))); + + // Child signals + expect(root.children[0].signals[0].address, + equals(const OccurrenceAddress([0, 0]))); + expect(root.children[0].signals[1].address, + equals(const OccurrenceAddress([0, 1]))); + + // Grandchild signals + expect(root.children[0].children[0].signals[0].address, + equals(const OccurrenceAddress([0, 0, 0]))); + }); + }); + + group('HierarchyOccurrence.parent', () { + test('parent is null for root', () { + final root = HierarchyOccurrence(name: 'Top')..buildAddresses(); + expect(root.parent, isNull); + }); + + test('parent is set for child nodes after buildAddresses', () { + final child = HierarchyOccurrence(name: 'sub'); + final root = HierarchyOccurrence(name: 'Top', children: [child]) + ..buildAddresses(); + expect(child.parent, same(root)); + expect(child.path(), 'Top/sub'); + }); + }); + + group('HierarchyOccurrence.definition', () { + test('type is null when not provided', () { + final n = HierarchyOccurrence(name: 'a'); + expect(n.definition, isNull); + }); + + test('type is stored when provided', () { + final n = HierarchyOccurrence(name: 'a', definition: 'Counter'); + expect(n.definition, 'Counter'); + }); + }); + + group('isPrimitive on nodes', () { + test('default isPrimitive is false', () { + final n = HierarchyOccurrence(name: 'sub'); + expect(n.isPrimitive, isFalse); + }); + }); + + group('buildAddresses ports-first ordering', () { + test('ports get lower signal indices than internal signals', () { + final root = HierarchyOccurrence( + name: 'Top', + signals: [ + SignalOccurrence(name: 'internal_a', width: 8), + SignalOccurrence( + name: 'clk', width: 1, direction: 'input', portIndex: 0), + SignalOccurrence(name: 'internal_b', width: 4), + SignalOccurrence( + name: 'out', width: 8, direction: 'output', portIndex: 1), + ], + )..buildAddresses(); + + final byName = {for (final s in root.signals) s.name: s}; + + // Ports should get indices 0 and 1 + expect(byName['clk']!.address, equals(const OccurrenceAddress([0]))); + expect(byName['out']!.address, equals(const OccurrenceAddress([1]))); + + // Internal signals get indices 2 and 3 + expect( + byName['internal_a']!.address, equals(const OccurrenceAddress([2]))); + expect( + byName['internal_b']!.address, equals(const OccurrenceAddress([3]))); + }); + + test('portIndex matches signal address index', () { + final root = HierarchyOccurrence( + name: 'Mod', + signals: [ + SignalOccurrence( + name: 'a', width: 1, direction: 'input', portIndex: 0), + SignalOccurrence( + name: 'b', width: 1, direction: 'input', portIndex: 1), + SignalOccurrence( + name: 'y', width: 1, direction: 'output', portIndex: 2), + SignalOccurrence(name: 'net0', width: 1), + ], + )..buildAddresses(); + + for (final s in root.signals) { + if (s.isPort) { + // portIndex should equal the last element of the address path + expect(s.address!.path.last, equals(s.portIndex), + reason: '${s.name}: portIndex=${s.portIndex} ' + 'but address index=${s.address!.path.last}'); + } + } + }); + + test('portCount returns correct count', () { + final occ = HierarchyOccurrence( + name: 'X', + signals: [ + SignalOccurrence( + name: 'a', width: 1, direction: 'input', portIndex: 0), + SignalOccurrence(name: 'b', width: 1), + SignalOccurrence( + name: 'c', width: 1, direction: 'output', portIndex: 1), + ], + ); + expect(occ.portCount, equals(2)); + }); + + test('all-ports occurrence: indices match list order', () { + final occ = HierarchyOccurrence( + name: 'Buf', + signals: [ + SignalOccurrence( + name: 'in', width: 8, direction: 'input', portIndex: 0), + SignalOccurrence( + name: 'out', width: 8, direction: 'output', portIndex: 1), + ], + )..buildAddresses(); + + expect(occ.signals[0].address, equals(const OccurrenceAddress([0]))); + expect(occ.signals[1].address, equals(const OccurrenceAddress([1]))); + }); + + test('all-internal occurrence: indices unchanged', () { + final occ = HierarchyOccurrence( + name: 'Internal', + signals: [ + SignalOccurrence(name: 'x', width: 1), + SignalOccurrence(name: 'y', width: 1), + ], + )..buildAddresses(); + + expect(occ.signals[0].address, equals(const OccurrenceAddress([0]))); + expect(occ.signals[1].address, equals(const OccurrenceAddress([1]))); + }); + + test('nested: ports-first ordering applies at every level', () { + final child = HierarchyOccurrence( + name: 'sub', + signals: [ + SignalOccurrence(name: 'net', width: 1), + SignalOccurrence( + name: 'p', width: 1, direction: 'input', portIndex: 0), + ], + ); + final root = HierarchyOccurrence( + name: 'Top', + children: [child], + signals: [ + SignalOccurrence(name: 'net_top', width: 1), + SignalOccurrence( + name: 'clk', width: 1, direction: 'input', portIndex: 0), + ], + )..buildAddresses(); + + // Root: clk (port) at 0, net_top (internal) at 1 + final rootByName = {for (final s in root.signals) s.name: s}; + expect(rootByName['clk']!.address!.path.last, equals(0)); + expect(rootByName['net_top']!.address!.path.last, equals(1)); + + // Child: p (port) at 0, net (internal) at 1 + final childByName = {for (final s in child.signals) s.name: s}; + expect(childByName['p']!.address!.path.last, equals(0)); + expect(childByName['net']!.address!.path.last, equals(1)); + }); + }); + + group('SignalOccurrence.portIndex', () { + test('portIndex is null for internal signals', () { + final s = SignalOccurrence(name: 'net', width: 1); + expect(s.portIndex, isNull); + expect(s.isPort, isFalse); + }); + + test('portIndex is set for port signals', () { + final s = SignalOccurrence( + name: 'clk', + width: 1, + direction: 'input', + portIndex: 3, + ); + expect(s.portIndex, equals(3)); + expect(s.isPort, isTrue); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/regex_search_test.dart b/packages/rohd_hierarchy/test/regex_search_test.dart new file mode 100644 index 000000000..51f134f21 --- /dev/null +++ b/packages/rohd_hierarchy/test/regex_search_test.dart @@ -0,0 +1,546 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// regex_search_test.dart +// Tests for regex-based hierarchy search. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('Regex search - HierarchyService', () { + late HierarchyService hierarchy; + + setUpAll(() { + // Build a test hierarchy: + // + // Top + // CPU + // ALU signals: [a, b, result, carry_out] + // Decoder signals: [opcode, enable] + // RegFile signals: [clk, reset, d0, d1, d2, d15] + // Memory + // Cache signals: [clk, addr, data, hit] + // DRAM signals: [clk, cas, ras] + // IO + // UART signals: [clk, tx, rx] + // signals (Top): [clk, reset] + + final alu = HierarchyOccurrence( + name: 'ALU', + signals: [ + SignalOccurrence(name: 'a', width: 8), + SignalOccurrence(name: 'b', width: 8), + SignalOccurrence(name: 'result', width: 8), + SignalOccurrence(name: 'carry_out', width: 1), + ], + ); + + final decoder = HierarchyOccurrence( + name: 'Decoder', + signals: [ + SignalOccurrence(name: 'opcode', width: 4), + SignalOccurrence(name: 'enable', width: 1), + ], + ); + + final regFile = HierarchyOccurrence( + name: 'RegFile', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'd0', width: 8), + SignalOccurrence(name: 'd1', width: 8), + SignalOccurrence(name: 'd2', width: 8), + SignalOccurrence(name: 'd15', width: 8), + ], + ); + + final cpu = HierarchyOccurrence( + name: 'CPU', + children: [alu, decoder, regFile], + ); + + final cache = HierarchyOccurrence( + name: 'Cache', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'addr', width: 16), + SignalOccurrence(name: 'data', width: 32), + SignalOccurrence(name: 'hit', width: 1), + ], + ); + + final dram = HierarchyOccurrence( + name: 'DRAM', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'cas', width: 1), + SignalOccurrence(name: 'ras', width: 1), + ], + ); + + final memory = HierarchyOccurrence( + name: 'Memory', + children: [cache, dram], + ); + + final uart = HierarchyOccurrence( + name: 'UART', + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'tx', width: 1), + SignalOccurrence(name: 'rx', width: 1), + ], + ); + + final io = HierarchyOccurrence( + name: 'IO', + children: [uart], + ); + + final root = HierarchyOccurrence( + name: 'Top', + children: [cpu, memory, io], + signals: [ + SignalOccurrence(name: 'clk', width: 1), + SignalOccurrence(name: 'reset', width: 1), + SignalOccurrence(name: 'data_m', width: 8), + SignalOccurrence(name: 'addr_m', width: 16), + SignalOccurrence(name: 'flag_m', width: 1), + ], + ); + + hierarchy = BaseHierarchyAdapter.fromTree(root); + }); + + // ── Exact match ── + + test('exact path matches single signal', () { + final results = hierarchy.searchSignalPathsRegex('Top/CPU/ALU/result'); + expect(results, contains('Top/CPU/ALU/result')); + expect(results.length, 1); + }); + + test('dot in regex pattern is treated as regex metachar, not separator', + () { + // In regex mode, `.` is NOT a hierarchy separator — only `/` is. + // `Top.CPU` is a single segment meaning "Top" + any char + "CPU". + final results = hierarchy.searchSignalPathsRegex('Top.CPU.ALU.result'); + // No match because the hierarchy root is "Top", not "Top.CPU.ALU" + expect(results, isEmpty); + }); + + // ── Wildcard at one level ── + + test('.* matches all signals in a module', () { + final results = hierarchy.searchSignalPathsRegex('Top/CPU/ALU/.*'); + expect( + results, + containsAll([ + 'Top/CPU/ALU/a', + 'Top/CPU/ALU/b', + 'Top/CPU/ALU/result', + 'Top/CPU/ALU/carry_out', + ])); + expect(results.length, 4); + }); + + test('.* matches all children at a module level', () { + final results = hierarchy.searchSignalPathsRegex('Top/.*/clk'); + // Should match CPU/RegFile/clk but not deeper (** would be needed + // for that). .* represents any single-level child of Top. + // Top has children CPU, Memory, IO — none of them have clk directly + // (Top's own signals aren't "children"). Actually let's check: + // Top/.*/clk means: Top / (any child) / clk as signal + // That doesn't match because clk is in deeper modules. + // This should return empty for signals one level below Top. + expect(results, isEmpty); + }); + + test('.* matches modules at one level for signal search', () { + // Top/CPU/.*/clk — matches ALU, Decoder, RegFile; only RegFile has clk + final results = hierarchy.searchSignalPathsRegex('Top/CPU/.*/clk'); + expect(results, contains('Top/CPU/RegFile/clk')); + expect(results.length, 1); + }); + + // ── Glob-star ** ── + + test('** matches signals at any depth', () { + final results = hierarchy.searchSignalPathsRegex('Top/**/clk'); + expect( + results, + containsAll([ + 'Top/CPU/RegFile/clk', + 'Top/Memory/Cache/clk', + 'Top/Memory/DRAM/clk', + 'Top/IO/UART/clk', + ])); + // Top's own clk is also accessible through ** matching zero levels + expect(results, contains('Top/clk')); + }); + + test('** at beginning matches everything', () { + final results = hierarchy.searchSignalPathsRegex('**/clk'); + // All clk signals anywhere + expect(results.length, greaterThanOrEqualTo(5)); + expect( + results, + containsAll([ + 'Top/clk', + 'Top/CPU/RegFile/clk', + 'Top/Memory/Cache/clk', + 'Top/Memory/DRAM/clk', + 'Top/IO/UART/clk', + ])); + }); + + test('** between levels matches across boundaries', () { + final results = hierarchy.searchSignalPathsRegex('Top/CPU/**/d0'); + expect(results, contains('Top/CPU/RegFile/d0')); + expect(results.length, 1); + }); + + test('** with regex signal pattern', () { + final results = hierarchy.searchSignalPathsRegex('Top/**/d[0-9]+'); + expect( + results, + containsAll([ + 'Top/CPU/RegFile/d0', + 'Top/CPU/RegFile/d1', + 'Top/CPU/RegFile/d2', + 'Top/CPU/RegFile/d15', + ])); + expect(results.length, 4); + }); + + // ── Regex character classes ── + + test('character class in signal name', () { + final results = + hierarchy.searchSignalPathsRegex('Top/CPU/RegFile/d[0-2]'); + expect( + results, + containsAll([ + 'Top/CPU/RegFile/d0', + 'Top/CPU/RegFile/d1', + 'Top/CPU/RegFile/d2', + ])); + expect(results, isNot(contains('Top/CPU/RegFile/d15'))); + }); + + // ── Alternation ── + + test('alternation in signal name', () { + final results = hierarchy.searchSignalPathsRegex('Top/**/(?:clk|reset)'); + expect( + results, + containsAll([ + 'Top/clk', + 'Top/reset', + 'Top/CPU/RegFile/clk', + 'Top/CPU/RegFile/reset', + 'Top/Memory/Cache/clk', + 'Top/Memory/DRAM/clk', + 'Top/IO/UART/clk', + ])); + expect(results.length, 7); + }); + + test('alternation in module name', () { + final results = hierarchy.searchSignalPathsRegex('Top/(CPU|IO)/.*/clk'); + expect( + results, + containsAll([ + 'Top/CPU/RegFile/clk', + 'Top/IO/UART/clk', + ])); + }); + + // ── Module search ── + + test('searchOccurrencePathsRegex finds modules', () { + final results = hierarchy.searchOccurrencePathsRegex('Top/CPU/.*'); + expect( + results, + containsAll([ + 'Top/CPU/ALU', + 'Top/CPU/Decoder', + 'Top/CPU/RegFile', + ])); + }); + + test('searchOccurrencePathsRegex with **', () { + final results = hierarchy.searchOccurrencePathsRegex('Top/**/DRAM'); + expect(results, contains('Top/Memory/DRAM')); + }); + + // ── Enriched results ── + + test('searchSignalsRegex returns SignalSearchResult objects', () { + final results = hierarchy.searchSignalsRegex('Top/CPU/ALU/result'); + expect(results.length, 1); + // signalId uses the normalised hierarchySeparator ('/') format + // from the tree walker — findSignalById normalises both '.' and '/'. + expect(results.first.signalId, 'Top/CPU/ALU/result'); + expect(results.first.signal, isNotNull); + expect(results.first.signal!.name, 'result'); + }); + + test('searchSignalsRegex returns results with SignalOccurrence objects', + () { + final results = hierarchy.searchSignalsRegex('Top/**/carry_out'); + expect(results.length, 1); + expect(results.first.signal, isNotNull); + expect(results.first.signal!.name, 'carry_out'); + expect(results.first.signal!.width, 1); + }); + + test('searchOccurrencesRegex returns OccurrenceSearchResult objects', () { + final results = hierarchy.searchOccurrencesRegex('Top/**/Cache'); + expect(results.length, 1); + expect(results.first.occurrenceId, 'Top/Memory/Cache'); + }); + + // ── Limit ── + + test('limit controls maximum results', () { + final results = hierarchy.searchSignalPathsRegex('Top/**/.+', limit: 3); + expect(results.length, 3); + }); + + // ── Glob-style wildcards ── + + test('glob * at start matches suffix pattern', () { + // User's scenario: "*m" should match signals ending in "m". + final results = hierarchy.searchSignalPathsRegex('Top/*_m'); + expect( + results, + containsAll([ + 'Top/data_m', + 'Top/addr_m', + 'Top/flag_m', + ])); + expect(results.length, 3); + }); + + test('glob * at end matches prefix pattern', () { + final results = hierarchy.searchSignalPathsRegex('Top/CPU/RegFile/d*'); + expect( + results, + containsAll([ + 'Top/CPU/RegFile/d0', + 'Top/CPU/RegFile/d1', + 'Top/CPU/RegFile/d2', + 'Top/CPU/RegFile/d15', + ])); + expect(results.length, 4); + }); + + test('glob * in the middle matches infix pattern', () { + // *d*a* should match names containing 'd' followed eventually by 'a' + final results = + hierarchy.searchSignalPathsRegex('Top/Memory/Cache/*d*a*'); + expect(results, contains('Top/Memory/Cache/data')); + }); + + test('glob * matches all signals (like .*)', () { + final results = hierarchy.searchSignalPathsRegex('Top/CPU/ALU/*'); + expect( + results, + containsAll([ + 'Top/CPU/ALU/a', + 'Top/CPU/ALU/b', + 'Top/CPU/ALU/result', + 'Top/CPU/ALU/carry_out', + ])); + expect(results.length, 4); + }); + + test('glob * in module level matches any child', () { + final results = hierarchy.searchSignalPathsRegex('Top/*/clk'); + // Top's immediate module-children are CPU, Memory, IO — none of + // them have a direct clk signal, so this is empty. + expect(results, isEmpty); + }); + + test('glob * combined with ** for deep search', () { + final results = hierarchy.searchSignalPathsRegex('Top/**/*_m'); + expect( + results, + containsAll([ + 'Top/data_m', + 'Top/addr_m', + 'Top/flag_m', + ])); + expect(results.length, 3); + }); + + // ── Empty / no match ── + + test('empty pattern returns nothing', () { + expect(hierarchy.searchSignalPathsRegex(''), isEmpty); + expect(hierarchy.searchOccurrencePathsRegex(''), isEmpty); + }); + + test('non-matching pattern returns nothing', () { + expect(hierarchy.searchSignalPathsRegex('Top/NonExistent/foo'), isEmpty); + }); + + // ── ** at various positions ── + + test('trailing ** collects all signals below', () { + final results = hierarchy.searchSignalPathsRegex('Top/Memory/**'); + // Should collect all signals in Memory subtree + expect( + results, + containsAll([ + 'Top/Memory/Cache/clk', + 'Top/Memory/Cache/addr', + 'Top/Memory/Cache/data', + 'Top/Memory/Cache/hit', + 'Top/Memory/DRAM/clk', + 'Top/Memory/DRAM/cas', + 'Top/Memory/DRAM/ras', + ])); + expect(results.length, 7); + }); + + test('multiple ** segments work', () { + final results = + hierarchy.searchSignalPathsRegex('**/(CPU|Memory)/**/clk'); + expect( + results, + containsAll([ + 'Top/CPU/RegFile/clk', + 'Top/Memory/Cache/clk', + 'Top/Memory/DRAM/clk', + ])); + }); + }); + + group('searchOccurrences dispatches to regex', () { + late HierarchyService hierarchy; + + setUpAll(() { + // Build hierarchy: + // Top + // CPU + // ALU + // Decoder + // MuxUnit + // Memory + // Cache + // DRAM + // IO + // UART + + final alu = HierarchyOccurrence( + name: 'ALU', + ); + + final decoder = HierarchyOccurrence( + name: 'Decoder', + ); + + final muxUnit = HierarchyOccurrence( + name: 'MuxUnit', + ); + + final cpu = HierarchyOccurrence( + name: 'CPU', + children: [alu, decoder, muxUnit], + ); + + final cache = HierarchyOccurrence( + name: 'Cache', + ); + + final dram = HierarchyOccurrence( + name: 'DRAM', + ); + + final memory = HierarchyOccurrence( + name: 'Memory', + children: [cache, dram], + ); + + final uart = HierarchyOccurrence( + name: 'UART', + ); + + final io = HierarchyOccurrence( + name: 'IO', + children: [uart], + ); + + final root = HierarchyOccurrence( + name: 'Top', + children: [cpu, memory, io], + ); + + hierarchy = BaseHierarchyAdapter.fromTree(root); + }); + + test('searchOccurrences with glob pattern finds modules', () { + // Pattern: *Mux* should find MuxUnit (auto-prepended with **/) + final results = hierarchy.searchOccurrences('*Mux*'); + expect(results, isNotEmpty, + reason: + 'searchOccurrences should dispatch to regex for glob patterns'); + expect(results.any((r) => r.name == 'MuxUnit'), isTrue); + }); + + test('searchOccurrences with ** finds deep modules', () { + final results = hierarchy.searchOccurrences('**/*Mux*'); + expect(results, isNotEmpty); + expect(results.any((r) => r.name == 'MuxUnit'), isTrue); + }); + + test('searchOccurrences with .* matches at one level', () { + // */.* matches any child one level below root + final results = hierarchy.searchOccurrences('*/.*/.*'); + expect(results.length, greaterThanOrEqualTo(3), + reason: 'Should match ALU, Decoder, MuxUnit, Cache, DRAM, UART'); + }); + + test('searchOccurrences with explicit path pattern', () { + // */CPU/.* matches children of CPU + final results = hierarchy.searchOccurrences('*/CPU/.*'); + expect(results.length, 3); + expect(results.any((r) => r.name == 'ALU'), isTrue); + expect(results.any((r) => r.name == 'Decoder'), isTrue); + expect(results.any((r) => r.name == 'MuxUnit'), isTrue); + }); + + test('searchOccurrences with alternation', () { + final results = hierarchy.searchOccurrences('**/(ALU|DRAM)'); + expect(results.length, 2); + expect(results.any((r) => r.name == 'ALU'), isTrue); + expect(results.any((r) => r.name == 'DRAM'), isTrue); + }); + + test('searchOccurrences without regex uses plain matching', () { + // Plain query without glob chars uses substring matching + final results = hierarchy.searchOccurrences('Mux'); + expect(results, isNotEmpty); + expect(results.any((r) => r.name == 'MuxUnit'), isTrue); + }); + + test('searchOccurrences with leading **/ is not double-prepended', () { + final results = hierarchy.searchOccurrences('**/UART'); + expect(results.length, 1); + expect(results.first.name, 'UART'); + }); + + test('searchOccurrences with leading */ is not double-prepended', () { + final results = hierarchy.searchOccurrences('*/CPU'); + expect(results.length, 1); + expect(results.first.name, 'CPU'); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/rohd_signal_resolve_test.dart b/packages/rohd_hierarchy/test/rohd_signal_resolve_test.dart new file mode 100644 index 000000000..fa22a21b4 --- /dev/null +++ b/packages/rohd_hierarchy/test/rohd_signal_resolve_test.dart @@ -0,0 +1,72 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// rohd_signal_resolve_test.dart +// Tests for resolving ROHD dot-separated signal IDs. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + late HierarchyOccurrence root; + late BaseHierarchyAdapter adapter; + + setUpAll(() { + root = HierarchyOccurrence( + name: 'abcd', + signals: [ + SignalOccurrence(name: 'clk', width: 1, direction: 'input'), + SignalOccurrence(name: 'resetn', width: 1, direction: 'input'), + SignalOccurrence(name: 'arvalid_s', width: 1, direction: 'input'), + ], + children: [ + HierarchyOccurrence( + name: 'sub', + signals: [ + SignalOccurrence(name: 'data', width: 8, direction: 'output'), + ], + ), + ], + ); + + adapter = BaseHierarchyAdapter.fromTree(root); + root.buildAddresses(); + }); + + SignalOccurrence? resolve(String dotPath) { + final addr = OccurrenceAddress.tryFromPathname(dotPath, root); + if (addr == null) { + return null; + } + return adapter.signalByAddress(addr); + } + + group('findSignalById resolves ROHD dot-separated signal IDs', () { + test('resolves top-level clk', () { + final sig = resolve('abcd.clk'); + expect(sig, isNotNull); + expect(sig!.path(), 'abcd/clk'); + }); + + test('resolves top-level resetn', () { + final sig = resolve('abcd.resetn'); + expect(sig, isNotNull); + expect(sig!.path(), 'abcd/resetn'); + }); + + test('resolves top-level arvalid_s', () { + final sig = resolve('abcd.arvalid_s'); + expect(sig, isNotNull); + expect(sig!.path(), 'abcd/arvalid_s'); + }); + + test('resolves nested sub.data', () { + final sig = resolve('abcd.sub.data'); + expect(sig, isNotNull); + expect(sig!.path(), 'abcd/sub/data'); + }); + }); +} diff --git a/packages/rohd_hierarchy/test/signal_search_result_test.dart b/packages/rohd_hierarchy/test/signal_search_result_test.dart new file mode 100644 index 000000000..2669c859f --- /dev/null +++ b/packages/rohd_hierarchy/test/signal_search_result_test.dart @@ -0,0 +1,236 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause +// +// signal_search_result_test.dart +// Tests for SignalSearchResult and ModuleSearchResult display helpers. +// +// 2026 April +// Author: Desmond Kirkpatrick + +import 'package:rohd_hierarchy/rohd_hierarchy.dart'; +import 'package:test/test.dart'; + +void main() { + group('SignalSearchResult display helpers', () { + test('displayPath strips top module', () { + const result = SignalSearchResult( + signalId: 'Top/counter/clk', + path: ['Top', 'counter', 'clk'], + ); + expect(result.displayPath, equals('counter/clk')); + }); + + test('displayPath for top-level signal', () { + const result = SignalSearchResult( + signalId: 'Top/clk', + path: ['Top', 'clk'], + ); + expect(result.displayPath, equals('clk')); + }); + + test('displayPath for single-segment path', () { + const result = SignalSearchResult( + signalId: 'clk', + path: ['clk'], + ); + expect(result.displayPath, equals('clk')); + }); + + test('displaySegments strips top module', () { + const result = SignalSearchResult( + signalId: 'Top/sub1/sub2/clk', + path: ['Top', 'sub1', 'sub2', 'clk'], + ); + expect(result.displaySegments, equals(['sub1', 'sub2', 'clk'])); + }); + + test('intermediateOccurrenceNames extracts middle segments', () { + const result = SignalSearchResult( + signalId: 'Top/sub1/sub2/clk', + path: ['Top', 'sub1', 'sub2', 'clk'], + ); + expect(result.intermediateOccurrenceNames, equals(['sub1', 'sub2'])); + }); + + test('intermediateOccurrenceNames empty for top-level signal', () { + const result = SignalSearchResult( + signalId: 'Top/clk', + path: ['Top', 'clk'], + ); + expect(result.intermediateOccurrenceNames, isEmpty); + }); + + test('intermediateOccurrenceNames empty for single-level nesting', () { + const result = SignalSearchResult( + signalId: 'Top/sub1/clk', + path: ['Top', 'sub1', 'clk'], + ); + // sub1 is both the containing block and an intermediate instance + expect(result.intermediateOccurrenceNames, equals(['sub1'])); + }); + + test('name returns last path segment', () { + const result = SignalSearchResult( + signalId: 'Top/counter/clk', + path: ['Top', 'counter', 'clk'], + ); + expect(result.name, equals('clk')); + }); + + test('equality based on signalId', () { + const a = SignalSearchResult( + signalId: 'Top/clk', + path: ['Top', 'clk'], + ); + const b = SignalSearchResult( + signalId: 'Top/clk', + path: ['Top', 'clk'], + ); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + }); + + group('HierarchySearchResult.normalizeQuery', () { + test('converts dots to slashes', () { + expect( + HierarchySearchResult.normalizeQuery('top.cpu.clk'), + equals('top/cpu/clk'), + ); + }); + + test('preserves slashes', () { + expect( + HierarchySearchResult.normalizeQuery('top/cpu/clk'), + equals('top/cpu/clk'), + ); + }); + + test('handles mixed separators', () { + expect( + HierarchySearchResult.normalizeQuery('top.cpu/clk'), + equals('top/cpu/clk'), + ); + }); + + test('handles empty query', () { + expect(HierarchySearchResult.normalizeQuery(''), equals('')); + }); + }); + + group('ModuleSearchResult display helpers', () { + late HierarchyOccurrence aluNode; + + setUp(() { + aluNode = HierarchyOccurrence( + name: 'ALU', + ); + }); + + test('displayPath strips top module', () { + final result = OccurrenceSearchResult( + occurrenceId: 'Top/CPU/ALU', + path: const ['Top', 'CPU', 'ALU'], + occurrence: aluNode, + ); + expect(result.displayPath, equals('CPU/ALU')); + }); + + test('displaySegments strips top module', () { + final result = OccurrenceSearchResult( + occurrenceId: 'Top/CPU/ALU', + path: const ['Top', 'CPU', 'ALU'], + occurrence: aluNode, + ); + expect(result.displaySegments, equals(['CPU', 'ALU'])); + }); + + test('displayPath for single-segment path', () { + final topNode = HierarchyOccurrence( + name: 'Top', + ); + final result = OccurrenceSearchResult( + occurrenceId: 'Top', + path: const ['Top'], + occurrence: topNode, + ); + expect(result.displayPath, equals('Top')); + }); + + test('equality based on moduleId', () { + final a = OccurrenceSearchResult( + occurrenceId: 'Top/CPU/ALU', + path: const ['Top', 'CPU', 'ALU'], + occurrence: aluNode, + ); + final b = OccurrenceSearchResult( + occurrenceId: 'Top/CPU/ALU', + path: const ['Top', 'CPU', 'ALU'], + occurrence: aluNode, + ); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + }); + + group('ModuleSearchResult.normalizeQuery', () { + test('converts dots to slashes', () { + expect( + HierarchySearchResult.normalizeQuery('top.cpu'), + equals('top/cpu'), + ); + }); + }); + + group('searchSignals integration with display helpers', () { + late HierarchyService hierarchy; + + setUpAll(() { + // Build: Top -> counter (with clk, data[8] signals) + final counter = HierarchyOccurrence( + name: 'counter', + signals: [ + SignalOccurrence( + name: 'clk', + width: 1, + ), + SignalOccurrence( + name: 'data', + width: 8, + ), + ], + ); + + final root = HierarchyOccurrence( + name: 'Top', + children: [counter], + signals: [ + SignalOccurrence( + name: 'reset', + width: 1, + direction: 'input', + ), + ], + ); + + hierarchy = BaseHierarchyAdapter.fromTree(root); + }); + + test('searchSignals returns enriched results', () { + final results = hierarchy.searchSignals('clk'); + expect(results, isNotEmpty); + final result = results.first; + expect(result.signalId, contains('clk')); + expect(result.displayPath, equals('counter/clk')); + expect(result.intermediateOccurrenceNames, equals(['counter'])); + }); + + test('searchSignals for top-level port', () { + final results = hierarchy.searchSignals('reset'); + expect(results, isNotEmpty); + final result = results.first; + expect(result.displayPath, equals('reset')); + expect(result.intermediateOccurrenceNames, isEmpty); + }); + }); +} diff --git a/tool/gh_actions/analyze_source.sh b/tool/gh_actions/analyze_source.sh index 8fc260b6b..926467b9d 100755 --- a/tool/gh_actions/analyze_source.sh +++ b/tool/gh_actions/analyze_source.sh @@ -12,3 +12,15 @@ set -euo pipefail dart analyze --fatal-infos + +# Analyze sub-packages that have their own pubspec.yaml and are excluded +# from the root analysis_options.yaml. +for pkg in packages/rohd_hierarchy; do + if [ -f "$pkg/pubspec.yaml" ]; then + echo "Analyzing sub-package: $pkg" + pushd "$pkg" > /dev/null + dart pub get + dart analyze --fatal-infos + popd > /dev/null + fi +done