From 75301a018b3a58208cf051171ca0a9b70afdb644 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Mon, 22 Dec 2025 13:33:07 -0600 Subject: [PATCH 1/9] add workflow-inspector --- src/ndev_workflows/_manager.py | 40 + src/ndev_workflows/napari.yaml | 5 + src/ndev_workflows/widgets/__init__.py | 6 + .../widgets/_workflow_inspector.py | 715 ++++++++++++++++++ tests/widgets/test_workflow_inspector.py | 305 ++++++++ 5 files changed, 1071 insertions(+) create mode 100644 src/ndev_workflows/widgets/_workflow_inspector.py create mode 100644 tests/widgets/test_workflow_inspector.py diff --git a/src/ndev_workflows/_manager.py b/src/ndev_workflows/_manager.py index 1f2c4a3..c25a644 100644 --- a/src/ndev_workflows/_manager.py +++ b/src/ndev_workflows/_manager.py @@ -109,6 +109,46 @@ def undo_redo(self) -> UndoRedoController: """The undo/redo controller.""" return self._undo_redo + @property + def pending_updates(self) -> list[str]: + """List of task names pending update (read-only copy).""" + return list(self._pending_updates) + + def is_layer_pending(self, name: str) -> bool: + """Check if a layer/task is pending update. + + Parameters + ---------- + name : str + The task name to check. + + Returns + ------- + bool + True if the task is scheduled for update. + """ + return name in self._pending_updates + + def get_layer_status(self, name: str) -> str: + """Get the status of a layer/task. + + Parameters + ---------- + name : str + The task name to check. + + Returns + ------- + str + One of 'root', 'invalid', or 'valid'. + """ + if name in self._workflow.roots(): + return 'root' + elif name in self._pending_updates: + return 'invalid' + else: + return 'valid' + def update( self, target_layer: str | Layer, diff --git a/src/ndev_workflows/napari.yaml b/src/ndev_workflows/napari.yaml index 495ca3a..a446363 100644 --- a/src/ndev_workflows/napari.yaml +++ b/src/ndev_workflows/napari.yaml @@ -7,6 +7,11 @@ contributions: - id: ndev-workflows.workflow_container title: Workflow Container python_name: ndev_workflows.widgets._workflow_container:WorkflowContainer + - id: ndev-workflows.workflow_inspector + title: Workflow Inspector + python_name: ndev_workflows.widgets._workflow_inspector:WorkflowInspector widgets: - command: ndev-workflows.workflow_container display_name: Workflow Container + - command: ndev-workflows.workflow_inspector + display_name: Workflow Inspector diff --git a/src/ndev_workflows/widgets/__init__.py b/src/ndev_workflows/widgets/__init__.py index e69de29..289db79 100644 --- a/src/ndev_workflows/widgets/__init__.py +++ b/src/ndev_workflows/widgets/__init__.py @@ -0,0 +1,6 @@ +"""ndev-workflows widgets for napari integration.""" + +from ._workflow_container import WorkflowContainer +from ._workflow_inspector import WorkflowInspector + +__all__ = ['WorkflowContainer', 'WorkflowInspector'] diff --git a/src/ndev_workflows/widgets/_workflow_inspector.py b/src/ndev_workflows/widgets/_workflow_inspector.py new file mode 100644 index 0000000..83509b7 --- /dev/null +++ b/src/ndev_workflows/widgets/_workflow_inspector.py @@ -0,0 +1,715 @@ +"""Workflow Inspector widget for visualizing workflow graph structure. + +This module provides a visual inspector for workflow graphs, showing +the dependency structure between processing steps with color-coded +status indicators. + +Based on napari-workflow-inspector by Robert Haase (BSD-3-Clause). +Adapted for ndev-workflows architecture. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from typing import TYPE_CHECKING + +from qtpy.QtCore import Qt, QTimer +from qtpy.QtWidgets import ( + QFileDialog, + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QSizePolicy, + QSpacerItem, + QTabWidget, + QVBoxLayout, + QWidget, +) + +if TYPE_CHECKING: + import napari.viewer + + +class MplCanvas: + """Matplotlib canvas for embedding in Qt widgets. + + Lazily imports matplotlib to avoid import errors when not installed. + """ + + def __init__(self): + from matplotlib.backends.backend_qtagg import ( + FigureCanvasQTAgg as FigureCanvas, + ) + from matplotlib.figure import Figure + + self.fig = Figure() + self.axes = self.fig.add_subplot(111) + self.fig.subplots_adjust( + left=0.04, bottom=0.04, right=0.97, top=0.96 + ) + # Dark theme to match napari + self.fig.patch.set_facecolor('#262930') + self.axes.set_facecolor('#262930') + + self.canvas = FigureCanvas(self.fig) + self.canvas.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + self.canvas.updateGeometry() + + def draw(self): + """Redraw the canvas.""" + self.canvas.draw() + + def clear(self): + """Clear the axes.""" + self.axes.clear() + self.axes.set_facecolor('#262930') + + +class ClickableNodes: + """Interactive nodes in the workflow graph. + + Allows clicking on nodes to select the corresponding layer in napari. + """ + + # Colors for different node states + VALID_COLOR = [0, 1, 0, 1] # Green + INVALID_COLOR = [1, 0, 1, 1] # Magenta + ROOT_COLOR = [0.8, 0.8, 0.8, 1] # Light gray + LEAF_COLOR = [0.3, 0.7, 1.0, 1] # Light blue + + UNSELECTED_EDGE = [0, 0, 0, 1] # Black + SELECTED_EDGE = [1, 1, 1, 1] # White + + def __init__( + self, + canvas: MplCanvas, + positions: dict, + viewer: napari.viewer.Viewer | None = None, + ): + """Initialize clickable nodes. + + Parameters + ---------- + canvas : MplCanvas + The matplotlib canvas to draw on. + positions : dict + Dictionary mapping node names to (x, y) positions. + viewer : napari.Viewer, optional + The napari viewer for layer selection. If None, clicking is disabled. + """ + self.viewer = viewer + self.canvas = canvas + self.positions = positions + + self.x = [positions[key][0] for key in positions] + self.y = [positions[key][1] for key in positions] + + # Create scatter plot of nodes + self.points = self.canvas.axes.scatter( + self.x, + self.y, + picker=True, + s=200, + facecolor=[self.VALID_COLOR] * len(self.x), + edgecolor=[self.UNSELECTED_EDGE] * len(self.x), + ) + + self.edgecolors = self.points.get_edgecolors() + self.canvas.canvas.mpl_connect('pick_event', self.on_pick) + + def on_pick(self, event): + """Handle click on a node.""" + self.toggle(event.ind) + + def toggle(self, index): + """Select a node and its corresponding layer.""" + edgecolors = self.edgecolors.copy() + edgecolors[index] = self.SELECTED_EDGE + self.points.set_edgecolors(edgecolors) + self.canvas.draw() + + if self.viewer is None: + return + + keys = list(self.positions.keys()) + idx = index[0] if hasattr(index, '__len__') else index + + if keys[idx] in self.viewer.layers: + layer = self.viewer.layers[keys[idx]] + self.viewer.layers.selection = {layer} + + def update_node_status(self, node_name: str, status: str): + """Update the visual status of a node. + + Parameters + ---------- + node_name : str + The name of the node to update. + status : str + One of 'valid', 'invalid', 'root', or 'leaf'. + """ + for idx, key in enumerate(self.positions.keys()): + if key == node_name: + facecolors = self.points.get_facecolors() + if status == 'invalid': + facecolors[idx] = self.INVALID_COLOR + elif status == 'root': + facecolors[idx] = self.ROOT_COLOR + elif status == 'leaf': + facecolors[idx] = self.LEAF_COLOR + else: + facecolors[idx] = self.VALID_COLOR + self.points.set_facecolors(facecolors) + break + + +class MatplotlibWidget(QWidget): + """Qt widget containing a matplotlib canvas.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.canvas = MplCanvas() + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.canvas.canvas) + + +class WorkflowInspector(QWidget): + """Widget for inspecting and visualizing workflow structure. + + Supports two modes: + - **File mode**: Load and inspect a workflow YAML file + - **Live mode**: Watch the current viewer's WorkflowManager + + Provides multiple views: + - Graph: Interactive networkx visualization + - From Roots: Tree view from inputs to outputs + - From Leaves: Tree view from outputs to inputs + - Raw: Text representation of the workflow + - Info: Workflow statistics and metadata + + Parameters + ---------- + viewer : napari.Viewer + The napari viewer instance. + + Example + ------- + >>> inspector = WorkflowInspector(viewer) + >>> viewer.window.add_dock_widget(inspector, name='Workflow Inspector') + >>> # Load a workflow file + >>> inspector.load_workflow_file("my_workflow.yaml") + """ + + def __init__(self, viewer: napari.viewer.Viewer): + super().__init__() + self._viewer = viewer + self._graph = None + self._positions = None + self._graph_drawing = None + + # Workflow source: file or live manager + self._workflow_file: Path | None = None + self._loaded_workflow = None # Workflow loaded from file + self._use_live_mode = False # Whether to watch WorkflowManager + + self._init_ui() + self._start_timer() + + def _init_ui(self): + """Initialize the user interface.""" + layout = QVBoxLayout(self) + + # Mode selection section + mode_layout = QHBoxLayout() + + self.load_btn = QPushButton('Load YAML...') + self.load_btn.clicked.connect(self._on_load_clicked) + mode_layout.addWidget(self.load_btn) + + self.live_btn = QPushButton('Watch Live') + self.live_btn.setCheckable(True) + self.live_btn.setToolTip( + 'Watch the viewer WorkflowManager (requires napari-assistant or similar)' + ) + self.live_btn.toggled.connect(self._on_live_toggled) + mode_layout.addWidget(self.live_btn) + + layout.addLayout(mode_layout) + + # Status label + self.status_label = QLabel('No workflow loaded') + self.status_label.setWordWrap(True) + layout.addWidget(self.status_label) + + # Tab widget for different views + self.tabs = QTabWidget() + layout.addWidget(self.tabs) + + # Graph tab + self.graph_widget = MatplotlibWidget() + self.tabs.addTab(self.graph_widget, 'Graph') + + # From Roots tab + self.roots_scroll = QScrollArea() + self.roots_scroll.setWidgetResizable(True) + self.lbl_from_roots = QLabel() + self.lbl_from_roots.setAlignment(Qt.AlignmentFlag.AlignTop) + self.lbl_from_roots.setTextFormat(Qt.TextFormat.RichText) + self.roots_scroll.setWidget(self.lbl_from_roots) + self.tabs.addTab(self.roots_scroll, 'From Roots') + + # From Leaves tab + self.leaves_scroll = QScrollArea() + self.leaves_scroll.setWidgetResizable(True) + self.lbl_from_leaves = QLabel() + self.lbl_from_leaves.setAlignment(Qt.AlignmentFlag.AlignTop) + self.lbl_from_leaves.setTextFormat(Qt.TextFormat.RichText) + self.leaves_scroll.setWidget(self.lbl_from_leaves) + self.tabs.addTab(self.leaves_scroll, 'From Leaves') + + # Raw tab + self.raw_scroll = QScrollArea() + self.raw_scroll.setWidgetResizable(True) + self.lbl_raw = QLabel() + self.lbl_raw.setAlignment(Qt.AlignmentFlag.AlignTop) + self.lbl_raw.setMinimumSize(800, 600) + self.raw_scroll.setWidget(self.lbl_raw) + self.tabs.addTab(self.raw_scroll, 'Raw') + + # Info tab (replaces Undo/Redo for file mode) + self.info_scroll = QScrollArea() + self.info_scroll.setWidgetResizable(True) + self.lbl_info = QLabel() + self.lbl_info.setAlignment(Qt.AlignmentFlag.AlignTop) + self.lbl_info.setMinimumSize(800, 600) + self.info_scroll.setWidget(self.lbl_info) + self.tabs.addTab(self.info_scroll, 'Info') + + # Spacer + layout.addItem( + QSpacerItem( + 20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding + ) + ) + + def _on_load_clicked(self): + """Handle load button click.""" + file_path, _ = QFileDialog.getOpenFileName( + self, + 'Load Workflow', + '', + 'YAML files (*.yaml *.yml);;All files (*)', + ) + if file_path: + self.load_workflow_file(file_path) + + def _on_live_toggled(self, checked: bool): + """Handle live mode toggle.""" + self._use_live_mode = checked + if checked: + self._loaded_workflow = None + self._workflow_file = None + self.status_label.setText('Watching live WorkflowManager...') + else: + if self._workflow_file: + self.status_label.setText(f'File: {self._workflow_file.name}') + else: + self.status_label.setText('No workflow loaded') + self._update() + + def load_workflow_file(self, file_path: str | Path): + """Load a workflow from a YAML file. + + Uses lazy loading since we only need the workflow structure for + visualization, not to execute the functions. + + Parameters + ---------- + file_path : str or Path + Path to the workflow YAML file. + """ + from ndev_workflows import load_workflow + from ndev_workflows._io import WorkflowYAMLError + + file_path = Path(file_path) + try: + # Load in lazy mode - we don't need to import functions to visualize + self._loaded_workflow = load_workflow(file_path, lazy=True) + self._workflow_file = file_path + self.status_label.setText(f'File: {file_path.name}') + self._use_live_mode = False + self.live_btn.setChecked(False) + self._graph = None # Force graph redraw + self._update() + except (OSError, ValueError, TypeError, WorkflowYAMLError) as e: + self.status_label.setText(f'Error: {e}') + + def _start_timer(self): + """Start the update timer.""" + self.timer = QTimer() + self.timer.setInterval(500) # 500ms update interval + self.timer.timeout.connect(self._update) + self.timer.start() + + def _get_workflow(self): + """Get the current workflow to inspect. + + Returns + ------- + Workflow or None + The workflow to inspect, or None if not available. + """ + if self._use_live_mode: + try: + from ndev_workflows._manager import WorkflowManager + + manager = WorkflowManager.install(self._viewer) + return manager.workflow + except (ImportError, AttributeError, RuntimeError): + return None + return self._loaded_workflow + + def _get_manager(self): + """Get the WorkflowManager for the current viewer (live mode only). + + Returns + ------- + WorkflowManager or None + The manager if in live mode, otherwise None. + """ + if not self._use_live_mode: + return None + try: + from ndev_workflows._manager import WorkflowManager + + return WorkflowManager.install(self._viewer) + except (ImportError, AttributeError, RuntimeError): + return None + + def _get_node_status(self, node_name: str, workflow) -> str: + """Determine the status of a node. + + Parameters + ---------- + node_name : str + The name of the node/task. + workflow : Workflow + The workflow being inspected. + + Returns + ------- + str + One of 'root', 'leaf', 'invalid', or 'valid'. + """ + roots = workflow.roots() + leaves = workflow.leaves() + + # Check if it's a root (input) + if node_name in roots: + return 'root' + + # Check if it's a leaf (output) + if node_name in leaves: + return 'leaf' + + # In live mode, check for pending updates + if self._use_live_mode: + manager = self._get_manager() + if manager and node_name in manager._pending_updates: + return 'invalid' + + # Check if layer exists in viewer + if node_name not in self._viewer.layers: + return 'invalid' + + return 'valid' + + def _update(self): + """Update all views with current workflow state.""" + workflow = self._get_workflow() + + if workflow is None or len(workflow) == 0: + self.lbl_from_roots.setText('No workflow loaded or empty workflow') + self.lbl_from_leaves.setText('No workflow loaded or empty workflow') + self.lbl_raw.setText('No workflow loaded or empty workflow') + self.lbl_info.setText('Load a YAML file or enable live mode') + self.graph_widget.canvas.clear() + self.graph_widget.canvas.draw() + return + + # Update tree views + roots = workflow.roots() + + # From Roots view + roots_text = self._build_tree_html( + roots, workflow.followers_of, workflow + ) + self.lbl_from_roots.setText(self._wrap_html(roots_text)) + + # From Leaves view + leaves = workflow.leaves() + leaves_text = self._build_tree_html( + leaves, workflow.sources_of, workflow + ) + self.lbl_from_leaves.setText(self._wrap_html(leaves_text)) + + # Raw view + self.lbl_raw.setText(repr(workflow)) + + # Info view + info_text = self._build_info_text(workflow) + self.lbl_info.setText(info_text) + + # Update graph + new_graph = self._create_nx_graph(workflow) + if self._graph is None or self._graph_changed(new_graph): + self._graph = new_graph + self._draw_graph(workflow) + else: + self._update_graph_colors(workflow) + + def _build_tree_html( + self, items: list[str], get_next: Callable, workflow + ) -> str: + """Build an HTML tree representation. + + Parameters + ---------- + items : list[str] + Starting items for the tree. + get_next : callable + Function to get next items (followers_of or sources_of). + workflow : Workflow + The workflow object. + + Returns + ------- + str + HTML string representing the tree. + """ + visited = set() + + def build(item_list: list[str], level: int = 0) -> str: + output = '' + for item in item_list: + if item in visited: + continue + visited.add(item) + + status = self._get_node_status(item, workflow) + color = { + 'root': '#dddddd', # Light gray + 'leaf': '#5599ff', # Light blue + 'invalid': '#dd00dd', # Magenta + 'valid': '#00dd00', # Green + }.get(status, '#dddddd') + + indent = '   ' * level + output += f'{indent}→ {item}
' + + next_items = get_next(item) + if next_items: + output += build(next_items, level + 1) + + return output + + return build(items) + + def _wrap_html(self, content: str) -> str: + """Wrap content in HTML tags.""" + return f'
{content}
' + + def _build_info_text(self, workflow) -> str: + """Build workflow info text. + + Parameters + ---------- + workflow : Workflow + The workflow object. + + Returns + ------- + str + Text representation of workflow info. + """ + lines = [] + + # Source info + if self._workflow_file: + lines.append(f'Source: {self._workflow_file}') + elif self._use_live_mode: + lines.append('Source: Live WorkflowManager') + lines.append('') + + # Workflow stats + lines.append('─── Workflow Statistics ───') + lines.append(f' Total tasks: {len(workflow)}') + lines.append(f' Roots (inputs): {len(workflow.roots())}') + lines.append(f' Leaves (outputs): {len(workflow.leaves())}') + lines.append('') + + # Root details + lines.append('─── Roots (Inputs) ───') + for root in workflow.roots(): + lines.append(f' • {root}') + if not workflow.roots(): + lines.append(' (none)') + lines.append('') + + # Leaf details + lines.append('─── Leaves (Outputs) ───') + for leaf in workflow.leaves(): + lines.append(f' • {leaf}') + if not workflow.leaves(): + lines.append(' (none)') + lines.append('') + + # Processing steps + processing = workflow.processing_task_names() + lines.append(f'─── Processing Steps ({len(processing)}) ───') + for name in processing: + task = workflow.get_task(name) + if task and len(task) > 0: + func = task[0] + func_name = getattr(func, '__name__', str(func)) + if hasattr(func, 'func'): + func_name = func.func.__name__ + sources = workflow.sources_of(name) + src_str = ', '.join(sources) if sources else '(none)' + lines.append(f' • {name}: {func_name}({src_str})') + lines.append('') + + # Metadata + if hasattr(workflow, 'metadata') and workflow.metadata: + lines.append('─── Metadata ───') + for key, value in workflow.metadata.items(): + lines.append(f' {key}: {value}') + lines.append('') + + # Live mode info + if self._use_live_mode: + manager = self._get_manager() + if manager: + undo_redo = manager.undo_redo + lines.append('─── Live Mode Info ───') + lines.append(f' Can undo: {undo_redo.can_undo}') + lines.append(f' Can redo: {undo_redo.can_redo}') + lines.append(f' Undo stack: {undo_redo.undo_stack_size} states') + lines.append(f' Redo stack: {undo_redo.redo_stack_size} states') + pending = manager.pending_updates + if pending: + lines.append(f' Pending updates: {pending}') + + return '\n'.join(lines) + + def _create_nx_graph(self, workflow): + """Create a networkx graph from the workflow. + + Parameters + ---------- + workflow : Workflow + The workflow to convert. + + Returns + ------- + nx.DiGraph + A directed graph representing the workflow. + """ + import networkx as nx + + graph = nx.DiGraph() + + # Add all tasks as nodes + for name in workflow: + graph.add_node(name) + + # Add edges based on dependencies + for name in workflow: + for follower in workflow.followers_of(name): + graph.add_edge(name, follower) + + return graph + + def _graph_changed(self, new_graph) -> bool: + """Check if the graph structure has changed.""" + if self._graph is None: + return True + return ( + set(self._graph.nodes) != set(new_graph.nodes) + or set(self._graph.edges) != set(new_graph.edges) + ) + + def _draw_graph(self, workflow): + """Draw the workflow graph.""" + import networkx as nx + + if self._graph is None or len(self._graph.nodes) == 0: + self.graph_widget.canvas.clear() + self.graph_widget.canvas.draw() + return + + ax = self.graph_widget.canvas.axes + ax.clear() + ax.set_facecolor('#262930') + + # Calculate positions + try: + self._positions = nx.drawing.layout.kamada_kawai_layout( + self._graph + ) + except (ValueError, TypeError, RuntimeError): + # Fall back to spring layout if kamada_kawai fails + self._positions = nx.spring_layout(self._graph) + + # Draw edges + nx.draw_networkx_edges( + self._graph, + pos=self._positions, + ax=ax, + width=2, + edge_color='white', + arrows=True, + arrowsize=15, + ) + + # Create clickable nodes (viewer only in live mode) + viewer = self._viewer if self._use_live_mode else None + self._graph_drawing = ClickableNodes( + self.graph_widget.canvas, self._positions, viewer + ) + + # Draw labels + props = {'boxstyle': 'round', 'facecolor': 'white', 'alpha': 0.2} + nx.draw_networkx_labels( + self._graph, + pos=self._positions, + ax=ax, + font_color='white', + bbox=props, + verticalalignment='bottom', + ) + + self._update_graph_colors(workflow) + self.graph_widget.canvas.draw() + + def _update_graph_colors(self, workflow): + """Update node colors based on current status.""" + if self._graph is None or self._graph_drawing is None: + return + + for node in self._graph.nodes: + status = self._get_node_status(node, workflow) + self._graph_drawing.update_node_status(node, status) + + self.graph_widget.canvas.draw() + + def closeEvent(self, a0): + """Stop the timer when closing.""" + self.timer.stop() + super().closeEvent(a0) diff --git a/tests/widgets/test_workflow_inspector.py b/tests/widgets/test_workflow_inspector.py new file mode 100644 index 0000000..d65aa25 --- /dev/null +++ b/tests/widgets/test_workflow_inspector.py @@ -0,0 +1,305 @@ +"""Tests for WorkflowInspector widget. + +Tests are organized into: +- Unit tests (basic functionality without Qt event loop) +- Widget tests (with qtbot for async Qt testing) +""" + +from __future__ import annotations + +import pytest + +from ndev_workflows import Workflow +from ndev_workflows._manager import WorkflowManager + + +# ============================================================================= +# Unit tests - Basic functionality +# ============================================================================= + + +class TestWorkflowInspectorBasics: + """Basic tests for WorkflowInspector without full Qt integration.""" + + def test_import(self): + """Test that WorkflowInspector can be imported.""" + from ndev_workflows.widgets._workflow_inspector import ( + WorkflowInspector, + ) + + assert WorkflowInspector is not None + + def test_mpl_canvas_import(self): + """Test MplCanvas can be imported (may fail if matplotlib missing).""" + pytest.importorskip('matplotlib') + + from ndev_workflows.widgets._workflow_inspector import MplCanvas + + assert MplCanvas is not None + + +class TestManagerStatusMethods: + """Test the new status methods added to WorkflowManager.""" + + def test_get_layer_status_root(self, make_napari_viewer): + """Test get_layer_status returns 'root' for root tasks.""" + viewer = make_napari_viewer() + manager = WorkflowManager.install(viewer) + + # Add a simple workflow with a root + def identity(x): + return x + + manager.workflow.set('output', identity, 'input') + + status = manager.get_layer_status('input') + assert status == 'root' + + def test_get_layer_status_valid(self, make_napari_viewer): + """Test get_layer_status returns 'valid' for computed tasks.""" + import numpy as np + + viewer = make_napari_viewer() + manager = WorkflowManager.install(viewer) + + # Add a simple workflow + def identity(x): + return x + + manager.workflow.set('input', np.zeros((10, 10))) + manager.workflow.set('output', identity, 'input') + + # Clear pending updates to simulate completed computation + manager._pending_updates.clear() + + status = manager.get_layer_status('output') + assert status == 'valid' + + def test_is_layer_pending(self, make_napari_viewer): + """Test is_layer_pending method.""" + viewer = make_napari_viewer() + manager = WorkflowManager.install(viewer) + + # Manually add to pending + manager._pending_updates.append('test_layer') + + assert manager.is_layer_pending('test_layer') is True + assert manager.is_layer_pending('other_layer') is False + + def test_pending_updates_property(self, make_napari_viewer): + """Test pending_updates property returns a copy.""" + viewer = make_napari_viewer() + manager = WorkflowManager.install(viewer) + + manager._pending_updates.append('test') + pending = manager.pending_updates + + # Should be a copy, not the original + assert pending == ['test'] + pending.append('modified') + assert manager._pending_updates == ['test'] + + +# ============================================================================= +# Widget tests - Require qtbot +# ============================================================================= + + +class TestWorkflowInspectorWidget: + """Widget tests requiring qtbot for Qt event loop.""" + + @pytest.fixture + def inspector(self, make_napari_viewer, qtbot): + """Create a WorkflowInspector widget.""" + pytest.importorskip('matplotlib') + pytest.importorskip('networkx') + + from ndev_workflows.widgets._workflow_inspector import ( + WorkflowInspector, + ) + + viewer = make_napari_viewer() + widget = WorkflowInspector(viewer) + qtbot.addWidget(widget) + return widget + + def test_inspector_creates(self, inspector): + """Test inspector widget can be created.""" + assert inspector is not None + assert inspector.tabs is not None + + def test_inspector_has_tabs(self, inspector): + """Test inspector has expected tabs.""" + assert inspector.tabs.count() == 5 + assert inspector.tabs.tabText(0) == 'Graph' + assert inspector.tabs.tabText(1) == 'From Roots' + assert inspector.tabs.tabText(2) == 'From Leaves' + assert inspector.tabs.tabText(3) == 'Raw' + assert inspector.tabs.tabText(4) == 'Info' + + def test_timer_starts(self, inspector): + """Test that update timer starts.""" + assert inspector.timer.isActive() + + def test_timer_stops_on_close(self, inspector, qtbot): + """Test timer stops when widget is closed.""" + inspector.close() + assert not inspector.timer.isActive() + + def test_create_nx_graph_empty(self, inspector): + """Test graph creation with empty workflow.""" + import networkx as nx + + workflow = Workflow() + graph = inspector._create_nx_graph(workflow) + + assert isinstance(graph, nx.DiGraph) + assert len(graph.nodes) == 0 + + def test_create_nx_graph_simple(self, inspector): + """Test graph creation with simple workflow.""" + import networkx as nx + + def identity(x): + return x + + workflow = Workflow() + workflow.set('output', identity, 'input') + + graph = inspector._create_nx_graph(workflow) + + assert isinstance(graph, nx.DiGraph) + assert 'output' in graph.nodes + # Note: 'input' is a reference, not a task, so may not be in nodes + # depending on implementation + + def test_build_tree_html(self, inspector, make_napari_viewer): + """Test HTML tree building.""" + + def identity(x): + return x + + workflow = Workflow() + workflow.set('output', identity, 'input') + + html = inspector._build_tree_html( + ['input'], workflow.followers_of, workflow + ) + + # Should contain the input name + assert 'input' in html or '→' in html + + def test_wrap_html(self, inspector): + """Test HTML wrapping.""" + content = 'test content' + wrapped = inspector._wrap_html(content) + + assert '' in wrapped + assert '
' in wrapped
+        assert content in wrapped
+
+
+class TestWorkflowInspectorFileMode:
+    """Tests for file loading functionality."""
+
+    @pytest.fixture
+    def inspector(self, make_napari_viewer, qtbot):
+        """Create a WorkflowInspector widget."""
+        pytest.importorskip('matplotlib')
+        pytest.importorskip('networkx')
+
+        from ndev_workflows.widgets._workflow_inspector import (
+            WorkflowInspector,
+        )
+
+        viewer = make_napari_viewer()
+        widget = WorkflowInspector(viewer)
+        qtbot.addWidget(widget)
+        return widget
+
+    def test_initial_mode_is_file(self, inspector):
+        """Test inspector starts in file mode (not live mode)."""
+        # Default is file mode since live mode requires napari-assistant
+        assert inspector._use_live_mode is False
+        assert inspector._loaded_workflow is None
+
+    def test_load_workflow_file(self, inspector, tmp_path):
+        """Test loading a workflow from YAML file."""
+        from ndev_workflows import Workflow, save_workflow
+
+        # Create a test workflow file
+        def add_one(x):
+            return x + 1
+
+        workflow = Workflow()
+        workflow.set('output', add_one, 'input')
+
+        yaml_file = tmp_path / 'test_workflow.yaml'
+        save_workflow(yaml_file, workflow)
+
+        # Load the file
+        inspector.load_workflow_file(yaml_file)
+
+        # Should stay in file mode
+        assert inspector._use_live_mode is False
+        assert inspector._loaded_workflow is not None
+        assert inspector._workflow_file == yaml_file
+
+    def test_load_sets_status_label(self, inspector, tmp_path):
+        """Test loading updates status label."""
+        from ndev_workflows import Workflow, save_workflow
+
+        def identity(x):
+            return x
+
+        workflow = Workflow()
+        workflow.set('out', identity, 'in')
+
+        yaml_file = tmp_path / 'test.yaml'
+        save_workflow(yaml_file, workflow)
+
+        inspector.load_workflow_file(yaml_file)
+
+        assert 'test.yaml' in inspector.status_label.text()
+
+    def test_toggle_to_live_mode(self, inspector, tmp_path):
+        """Test toggling to live mode."""
+        from ndev_workflows import Workflow, save_workflow
+
+        def identity(x):
+            return x
+
+        workflow = Workflow()
+        workflow.set('out', identity, 'in')
+
+        yaml_file = tmp_path / 'test.yaml'
+        save_workflow(yaml_file, workflow)
+
+        # Load file
+        inspector.load_workflow_file(yaml_file)
+        assert inspector._use_live_mode is False
+        assert inspector._loaded_workflow is not None
+
+        # Toggle to live
+        inspector._on_live_toggled(True)
+        assert inspector._use_live_mode is True
+        assert inspector._loaded_workflow is None
+
+    def test_get_workflow_in_file_mode(self, inspector, tmp_path):
+        """Test _get_workflow returns loaded workflow in file mode."""
+        from ndev_workflows import Workflow, save_workflow
+
+        def identity(x):
+            return x
+
+        workflow = Workflow()
+        workflow.set('out', identity, 'in')
+
+        yaml_file = tmp_path / 'test.yaml'
+        save_workflow(yaml_file, workflow)
+
+        inspector.load_workflow_file(yaml_file)
+
+        result = inspector._get_workflow()
+        assert result is not None
+        assert 'out' in result

From 8a7c2886becac05dd8abd2daa6584a80308a2b08 Mon Sep 17 00:00:00 2001
From: Tim Monko 
Date: Mon, 22 Dec 2025 13:43:37 -0600
Subject: [PATCH 2/9] put labels on top

---
 src/ndev_workflows/_workflow.py               |   4 +-
 .../widgets/_workflow_inspector.py            | 172 +++++++++++++++---
 2 files changed, 152 insertions(+), 24 deletions(-)

diff --git a/src/ndev_workflows/_workflow.py b/src/ndev_workflows/_workflow.py
index bd6e956..a9da9aa 100644
--- a/src/ndev_workflows/_workflow.py
+++ b/src/ndev_workflows/_workflow.py
@@ -447,8 +447,8 @@ def __repr__(self) -> str:
         """Return a string representation of the workflow."""
         n_tasks = len(self._tasks)
         roots = self.roots()
-        leafs = self.leaves()
-        return f'Workflow({n_tasks} tasks, roots={roots}, leafs={leafs})'
+        leaves = self.leaves()
+        return f'Workflow({n_tasks} tasks, roots={roots}, leaves={leaves})'
 
     def copy(self) -> Workflow:
         """Create a deep copy of this workflow.
diff --git a/src/ndev_workflows/widgets/_workflow_inspector.py b/src/ndev_workflows/widgets/_workflow_inspector.py
index 83509b7..e564d6c 100644
--- a/src/ndev_workflows/widgets/_workflow_inspector.py
+++ b/src/ndev_workflows/widgets/_workflow_inspector.py
@@ -69,10 +69,11 @@ def clear(self):
         self.axes.set_facecolor('#262930')
 
 
-class ClickableNodes:
-    """Interactive nodes in the workflow graph.
+class DraggableNodes:
+    """Interactive draggable nodes in the workflow graph.
 
-    Allows clicking on nodes to select the corresponding layer in napari.
+    Allows clicking on nodes to select the corresponding layer in napari,
+    and dragging nodes to rearrange the graph layout.
     """
 
     # Colors for different node states
@@ -89,8 +90,9 @@ def __init__(
         canvas: MplCanvas,
         positions: dict,
         viewer: napari.viewer.Viewer | None = None,
+        on_positions_changed: Callable | None = None,
     ):
-        """Initialize clickable nodes.
+        """Initialize draggable nodes.
 
         Parameters
         ----------
@@ -100,10 +102,17 @@ def __init__(
             Dictionary mapping node names to (x, y) positions.
         viewer : napari.Viewer, optional
             The napari viewer for layer selection. If None, clicking is disabled.
+        on_positions_changed : callable, optional
+            Callback when node positions change (for redrawing edges/labels).
         """
         self.viewer = viewer
         self.canvas = canvas
-        self.positions = positions
+        self.positions = positions.copy()  # Make a mutable copy
+        self.on_positions_changed = on_positions_changed
+
+        self._dragging = False
+        self._drag_index = None
+        self._selected_index = None
 
         self.x = [positions[key][0] for key in positions]
         self.y = [positions[key][1] for key in positions]
@@ -116,30 +125,82 @@ def __init__(
             s=200,
             facecolor=[self.VALID_COLOR] * len(self.x),
             edgecolor=[self.UNSELECTED_EDGE] * len(self.x),
+            zorder=10,  # Draw nodes on top
         )
 
-        self.edgecolors = self.points.get_edgecolors()
-        self.canvas.canvas.mpl_connect('pick_event', self.on_pick)
+        self.edgecolors = self.points.get_edgecolors().copy()
+
+        # Connect mouse events for dragging
+        self.canvas.canvas.mpl_connect('pick_event', self._on_pick)
+        self.canvas.canvas.mpl_connect('button_press_event', self._on_press)
+        self.canvas.canvas.mpl_connect('button_release_event', self._on_release)
+        self.canvas.canvas.mpl_connect('motion_notify_event', self._on_motion)
+
+    def _on_pick(self, event):
+        """Handle pick event on a node."""
+        if event.mouseevent.button == 1:  # Left click
+            ind = event.ind[0] if hasattr(event.ind, '__len__') else event.ind
+            self._drag_index = ind
+            self._select_node(ind)
+
+    def _on_press(self, event):
+        """Handle mouse button press."""
+        if event.button == 1 and self._drag_index is not None:
+            self._dragging = True
+
+    def _on_release(self, event):
+        """Handle mouse button release."""
+        if event.button == 1:
+            self._dragging = False
+            self._drag_index = None
+
+    def _on_motion(self, event):
+        """Handle mouse motion for dragging."""
+        if not self._dragging or self._drag_index is None:
+            return
+        if event.xdata is None or event.ydata is None:
+            return
+
+        # Update the position
+        idx = self._drag_index
+        keys = list(self.positions.keys())
+        node_name = keys[idx]
+
+        # Update stored position
+        self.positions[node_name] = (event.xdata, event.ydata)
+        self.x[idx] = event.xdata
+        self.y[idx] = event.ydata
 
-    def on_pick(self, event):
-        """Handle click on a node."""
-        self.toggle(event.ind)
+        # Update scatter plot
+        offsets = self.points.get_offsets()
+        offsets[idx] = [event.xdata, event.ydata]
+        self.points.set_offsets(offsets)
 
-    def toggle(self, index):
+        # Notify parent to redraw edges and labels
+        if self.on_positions_changed:
+            self.on_positions_changed()
+        else:
+            self.canvas.draw()
+
+    def _select_node(self, index):
         """Select a node and its corresponding layer."""
+        # Reset previous selection
         edgecolors = self.edgecolors.copy()
+
+        # Highlight new selection
         edgecolors[index] = self.SELECTED_EDGE
         self.points.set_edgecolors(edgecolors)
+        self._selected_index = index
         self.canvas.draw()
 
         if self.viewer is None:
             return
 
         keys = list(self.positions.keys())
-        idx = index[0] if hasattr(index, '__len__') else index
+        node_name = keys[index]
 
-        if keys[idx] in self.viewer.layers:
-            layer = self.viewer.layers[keys[idx]]
+        if node_name in self.viewer.layers:
+            layer = self.viewer.layers[node_name]
             self.viewer.layers.selection = {layer}
 
     def update_node_status(self, node_name: str, status: str):
@@ -213,6 +274,11 @@ def __init__(self, viewer: napari.viewer.Viewer):
         self._positions = None
         self._graph_drawing = None
 
+        # Graph drawing elements (for dynamic updates when dragging)
+        self._edge_collection = None
+        self._label_texts = None
+        self._current_workflow = None
+
         # Workflow source: file or live manager
         self._workflow_file: Path | None = None
         self._loaded_workflow = None  # Workflow loaded from file
@@ -667,8 +733,11 @@ def _draw_graph(self, workflow):
             # Fall back to spring layout if kamada_kawai fails
             self._positions = nx.spring_layout(self._graph)
 
-        # Draw edges
-        nx.draw_networkx_edges(
+        # Store workflow reference for redraw callback
+        self._current_workflow = workflow
+
+        # Draw edges (store reference for redrawing)
+        self._edge_collection = nx.draw_networkx_edges(
             self._graph,
             pos=self._positions,
             ax=ax,
@@ -678,15 +747,72 @@ def _draw_graph(self, workflow):
             arrowsize=15,
         )
 
-        # Create clickable nodes (viewer only in live mode)
+        # Draw labels (store reference for redrawing)
+        props = {'boxstyle': 'round', 'facecolor': 'white', 'alpha': 0.2}
+        self._label_texts = nx.draw_networkx_labels(
+            self._graph,
+            pos=self._positions,
+            ax=ax,
+            font_color='white',
+            bbox=props,
+            verticalalignment='bottom',
+        )
+        # Set high z-order for labels so they render on top
+        for text in self._label_texts.values():
+            text.set_zorder(20)
+
+        # Create draggable nodes (viewer only in live mode)
         viewer = self._viewer if self._use_live_mode else None
-        self._graph_drawing = ClickableNodes(
-            self.graph_widget.canvas, self._positions, viewer
+        self._graph_drawing = DraggableNodes(
+            self.graph_widget.canvas,
+            self._positions,
+            viewer,
+            on_positions_changed=self._on_node_positions_changed,
+        )
+
+        self._update_graph_colors(workflow)
+        self.graph_widget.canvas.draw()
+
+    def _on_node_positions_changed(self):
+        """Callback when node positions change due to dragging."""
+        import networkx as nx
+
+        if self._graph is None or self._graph_drawing is None:
+            return
+
+        ax = self.graph_widget.canvas.axes
+
+        # Update positions from draggable nodes
+        self._positions = self._graph_drawing.positions
+
+        # Remove old edges
+        if self._edge_collection is not None:
+            # Handle both FancyArrowPatch list and LineCollection
+            if hasattr(self._edge_collection, '__iter__'):
+                for edge in self._edge_collection:
+                    edge.remove()
+            else:
+                self._edge_collection.remove()
+
+        # Remove old labels
+        if self._label_texts is not None:
+            for text in self._label_texts.values():
+                text.remove()
+
+        # Redraw edges with new positions
+        self._edge_collection = nx.draw_networkx_edges(
+            self._graph,
+            pos=self._positions,
+            ax=ax,
+            width=2,
+            edge_color='white',
+            arrows=True,
+            arrowsize=15,
         )
 
-        # Draw labels
+        # Redraw labels with new positions
         props = {'boxstyle': 'round', 'facecolor': 'white', 'alpha': 0.2}
-        nx.draw_networkx_labels(
+        self._label_texts = nx.draw_networkx_labels(
             self._graph,
             pos=self._positions,
             ax=ax,
@@ -694,8 +820,10 @@ def _draw_graph(self, workflow):
             bbox=props,
             verticalalignment='bottom',
         )
+        # Set high z-order for labels so they render on top
+        for text in self._label_texts.values():
+            text.set_zorder(20)
 
-        self._update_graph_colors(workflow)
         self.graph_widget.canvas.draw()
 
     def _update_graph_colors(self, workflow):

From 5362fd0046f7cabb0ffc5b91f39012fad649cf4f Mon Sep 17 00:00:00 2001
From: Tim Monko 
Date: Mon, 22 Dec 2025 13:55:49 -0600
Subject: [PATCH 3/9] add navigation toolbar to plot

---
 .../widgets/_workflow_inspector.py            | 19 ++++++++++++++++++-
 1 file changed, 18 insertions(+), 1 deletion(-)

diff --git a/src/ndev_workflows/widgets/_workflow_inspector.py b/src/ndev_workflows/widgets/_workflow_inspector.py
index e564d6c..3fb9343 100644
--- a/src/ndev_workflows/widgets/_workflow_inspector.py
+++ b/src/ndev_workflows/widgets/_workflow_inspector.py
@@ -229,14 +229,31 @@ def update_node_status(self, node_name: str, status: str):
 
 
 class MatplotlibWidget(QWidget):
-    """Qt widget containing a matplotlib canvas."""
+    """Qt widget containing a matplotlib canvas with navigation toolbar.
+
+    Includes standard matplotlib navigation tools:
+    - Home: Reset to original view
+    - Back/Forward: Navigate view history
+    - Pan: Pan the view with mouse
+    - Zoom: Zoom to rectangle
+    - Configure: Adjust subplot parameters
+    - Save: Save the figure to file
+    """
 
     def __init__(self, parent=None):
         super().__init__(parent)
+        from matplotlib.backends.backend_qtagg import (
+            NavigationToolbar2QT as NavigationToolbar,
+        )
+
         self.canvas = MplCanvas()
 
+        # Create toolbar with navigation buttons
+        self.toolbar = NavigationToolbar(self.canvas.canvas, self)
+
         layout = QVBoxLayout(self)
         layout.setContentsMargins(0, 0, 0, 0)
+        layout.addWidget(self.toolbar)
         layout.addWidget(self.canvas.canvas)
 
 

From 6eeb03d731f7cbb322158ac722ace737ac7a6021 Mon Sep 17 00:00:00 2001
From: Tim Monko 
Date: Mon, 22 Dec 2025 14:09:47 -0600
Subject: [PATCH 4/9] add more tests

---
 tests/widgets/test_workflow_inspector.py | 625 +++++++++++++++++++++++
 1 file changed, 625 insertions(+)

diff --git a/tests/widgets/test_workflow_inspector.py b/tests/widgets/test_workflow_inspector.py
index d65aa25..3a77eb1 100644
--- a/tests/widgets/test_workflow_inspector.py
+++ b/tests/widgets/test_workflow_inspector.py
@@ -7,6 +7,8 @@
 
 from __future__ import annotations
 
+from unittest.mock import MagicMock
+
 import pytest
 
 from ndev_workflows import Workflow
@@ -303,3 +305,626 @@ def identity(x):
         result = inspector._get_workflow()
         assert result is not None
         assert 'out' in result
+
+
+class TestDraggableNodes:
+    """Tests for the DraggableNodes class."""
+
+    @pytest.fixture
+    def mpl_canvas(self):
+        """Create a real MplCanvas."""
+        pytest.importorskip('matplotlib')
+
+        from ndev_workflows.widgets._workflow_inspector import MplCanvas
+
+        return MplCanvas()
+
+    def test_draggable_nodes_init(self, mpl_canvas):
+        """Test DraggableNodes initialization."""
+        from ndev_workflows.widgets._workflow_inspector import DraggableNodes
+
+        positions = {'node1': (0.0, 0.0), 'node2': (1.0, 1.0)}
+        nodes = DraggableNodes(mpl_canvas, positions, viewer=None)
+
+        assert nodes.positions == {'node1': (0.0, 0.0), 'node2': (1.0, 1.0)}
+        assert nodes.points is not None
+        assert len(nodes.x) == 2
+        assert len(nodes.y) == 2
+
+    def test_update_node_status(self, mpl_canvas):
+        """Test updating node status colors."""
+        from ndev_workflows.widgets._workflow_inspector import DraggableNodes
+
+        positions = {'node1': (0.0, 0.0), 'node2': (1.0, 1.0)}
+        nodes = DraggableNodes(mpl_canvas, positions, viewer=None)
+
+        # Test all status types - should not raise
+        nodes.update_node_status('node1', 'root')
+        nodes.update_node_status('node1', 'valid')
+        nodes.update_node_status('node1', 'invalid')
+        nodes.update_node_status('node1', 'leaf')
+
+        # Verify the scatter colors changed (facecolors is an array)
+        facecolors = nodes.points.get_facecolors()
+        assert len(facecolors) == 2  # Two nodes
+
+    def test_dragging_workflow(self, mpl_canvas):
+        """Test the dragging state machine."""
+        from ndev_workflows.widgets._workflow_inspector import DraggableNodes
+
+        callback = MagicMock()
+        positions = {'node1': (0.0, 0.0), 'node2': (1.0, 1.0)}
+        nodes = DraggableNodes(
+            mpl_canvas, positions, viewer=None, on_positions_changed=callback
+        )
+
+        # Initial state
+        assert nodes._dragging is False
+        assert nodes._drag_index is None
+
+        # Simulate pick event (selecting node 0)
+        pick_event = MagicMock()
+        pick_event.mouseevent = MagicMock()
+        pick_event.mouseevent.button = 1
+        pick_event.ind = [0]
+        nodes._on_pick(pick_event)
+
+        assert nodes._drag_index == 0
+
+        # Simulate press
+        press_event = MagicMock()
+        press_event.button = 1
+        nodes._on_press(press_event)
+        assert nodes._dragging is True
+
+        # Simulate motion
+        motion_event = MagicMock()
+        motion_event.xdata = 0.5
+        motion_event.ydata = 0.6
+        nodes._on_motion(motion_event)
+
+        # Position should update
+        assert nodes.positions['node1'] == (0.5, 0.6)
+        assert nodes.x[0] == 0.5
+        assert nodes.y[0] == 0.6
+        assert callback.called
+
+        # Simulate release
+        release_event = MagicMock()
+        release_event.button = 1
+        nodes._on_release(release_event)
+        assert nodes._dragging is False
+
+    def test_motion_without_drag_does_nothing(self, mpl_canvas):
+        """Test motion event when not dragging does nothing."""
+        from ndev_workflows.widgets._workflow_inspector import DraggableNodes
+
+        callback = MagicMock()
+        positions = {'node1': (0.0, 0.0)}
+        nodes = DraggableNodes(
+            mpl_canvas, positions, viewer=None, on_positions_changed=callback
+        )
+
+        # Motion without drag should do nothing
+        motion_event = MagicMock()
+        motion_event.xdata = 0.5
+        motion_event.ydata = 0.5
+        nodes._on_motion(motion_event)
+
+        # Position should not change
+        assert nodes.positions['node1'] == (0.0, 0.0)
+        assert not callback.called
+
+    def test_select_node_with_viewer(self, mpl_canvas, make_napari_viewer):
+        """Test node selection with viewer."""
+        import numpy as np
+
+        from ndev_workflows.widgets._workflow_inspector import DraggableNodes
+
+        viewer = make_napari_viewer()
+        viewer.add_image(np.array([[1, 2], [3, 4]]), name='test_layer')
+
+        positions = {'test_layer': (0.0, 0.0)}
+        nodes = DraggableNodes(mpl_canvas, positions, viewer=viewer)
+
+        # Select the node (index 0)
+        nodes._select_node(0)
+
+        # Layer should be selected in viewer
+        assert viewer.layers.selection == {viewer.layers['test_layer']}
+
+    def test_select_node_nonexistent_layer(self, mpl_canvas, make_napari_viewer):
+        """Test selecting node for layer that doesn't exist in viewer."""
+        from ndev_workflows.widgets._workflow_inspector import DraggableNodes
+
+        viewer = make_napari_viewer()
+        positions = {'nonexistent': (0.0, 0.0)}
+        nodes = DraggableNodes(mpl_canvas, positions, viewer=viewer)
+
+        # Should not raise - just won't select anything
+        nodes._select_node(0)
+        # Selection should remain empty
+        assert len(viewer.layers.selection) == 0
+
+
+class TestGraphDrawing:
+    """Tests for graph drawing functionality."""
+
+    @pytest.fixture
+    def inspector_with_workflow(self, make_napari_viewer, qtbot, tmp_path):
+        """Create inspector with a loaded workflow."""
+        pytest.importorskip('matplotlib')
+        pytest.importorskip('networkx')
+
+        from ndev_workflows import Workflow, save_workflow
+        from ndev_workflows.widgets._workflow_inspector import (
+            WorkflowInspector,
+        )
+
+        def step1(x):
+            return x + 1
+
+        def step2(x):
+            return x * 2
+
+        workflow = Workflow()
+        workflow.set('middle', step1, 'input')
+        workflow.set('output', step2, 'middle')
+
+        yaml_file = tmp_path / 'test.yaml'
+        save_workflow(yaml_file, workflow)
+
+        viewer = make_napari_viewer()
+        widget = WorkflowInspector(viewer)
+        qtbot.addWidget(widget)
+
+        widget.load_workflow_file(yaml_file)
+        return widget
+
+    def test_draw_graph_creates_positions(self, inspector_with_workflow):
+        """Test that drawing creates node positions."""
+        inspector = inspector_with_workflow
+        assert inspector._positions is not None
+        # 'middle' and 'output' are processing steps - always in graph
+        assert 'middle' in inspector._positions
+        assert 'output' in inspector._positions
+
+    def test_draw_graph_creates_graph_drawing(self, inspector_with_workflow):
+        """Test that drawing creates DraggableNodes."""
+        inspector = inspector_with_workflow
+        assert inspector._graph_drawing is not None
+
+    def test_graph_changed_detection(self, inspector_with_workflow, tmp_path):
+        """Test graph change detection."""
+        import networkx as nx
+
+        inspector = inspector_with_workflow
+
+        # Create a graph that is clearly different
+        different_graph = nx.DiGraph()
+        different_graph.add_node('completely_different_node')
+        different_graph.add_node('another_node')
+        different_graph.add_edge('completely_different_node', 'another_node')
+
+        # The graphs should be different
+        assert inspector._graph_changed(different_graph)
+
+    def test_graph_not_changed_same_structure(self, inspector_with_workflow):
+        """Test that identical graphs are not detected as changed."""
+        inspector = inspector_with_workflow
+
+        # Create a copy of the current graph
+        same_graph = inspector._create_nx_graph(inspector._loaded_workflow)
+
+        # Same graph should not be detected as changed
+        assert not inspector._graph_changed(same_graph)
+
+    def test_update_calls_draw_or_colors(
+        self, inspector_with_workflow, tmp_path
+    ):
+        """Test that update properly refreshes the view."""
+        inspector = inspector_with_workflow
+
+        # Force an update
+        inspector._update()
+
+        # Labels should be populated
+        assert 'input' in inspector.lbl_from_roots.text()
+        assert 'output' in inspector.lbl_from_leaves.text()
+
+    def test_on_node_positions_changed(self, inspector_with_workflow):
+        """Test callback when node positions change."""
+        inspector = inspector_with_workflow
+
+        # Modify positions directly
+        if inspector._graph_drawing:
+            inspector._graph_drawing.positions['input'] = (0.5, 0.5)
+            inspector._on_node_positions_changed()
+
+            # Positions should be updated
+            assert inspector._positions['input'] == (0.5, 0.5)
+
+
+class TestInfoText:
+    """Tests for info text generation."""
+
+    @pytest.fixture
+    def inspector(self, make_napari_viewer, qtbot):
+        """Create a WorkflowInspector widget."""
+        pytest.importorskip('matplotlib')
+        pytest.importorskip('networkx')
+
+        from ndev_workflows.widgets._workflow_inspector import (
+            WorkflowInspector,
+        )
+
+        viewer = make_napari_viewer()
+        widget = WorkflowInspector(viewer)
+        qtbot.addWidget(widget)
+        return widget
+
+    def test_build_info_text_file_mode(self, inspector, tmp_path):
+        """Test info text in file mode."""
+        from ndev_workflows import Workflow, save_workflow
+
+        def process(x):
+            return x * 2
+
+        workflow = Workflow()
+        workflow.set('out', process, 'in')
+
+        yaml_file = tmp_path / 'info_test.yaml'
+        save_workflow(yaml_file, workflow)
+
+        inspector.load_workflow_file(yaml_file)
+        info = inspector._build_info_text(inspector._loaded_workflow)
+
+        assert 'Source:' in info
+        assert 'info_test.yaml' in info
+        assert 'Total tasks:' in info
+        assert 'Roots (inputs):' in info
+        assert 'Leaves (outputs):' in info
+
+    def test_build_info_text_processing_steps(self, inspector, tmp_path):
+        """Test info text shows processing steps."""
+        from ndev_workflows import Workflow, save_workflow
+
+        def my_function(x):
+            return x
+
+        workflow = Workflow()
+        workflow.set('output', my_function, 'input')
+
+        yaml_file = tmp_path / 'steps_test.yaml'
+        save_workflow(yaml_file, workflow)
+
+        inspector.load_workflow_file(yaml_file)
+        info = inspector._build_info_text(inspector._loaded_workflow)
+
+        assert 'Processing Steps' in info
+
+
+class TestErrorHandling:
+    """Tests for error handling paths."""
+
+    @pytest.fixture
+    def inspector(self, make_napari_viewer, qtbot):
+        """Create a WorkflowInspector widget."""
+        pytest.importorskip('matplotlib')
+        pytest.importorskip('networkx')
+
+        from ndev_workflows.widgets._workflow_inspector import (
+            WorkflowInspector,
+        )
+
+        viewer = make_napari_viewer()
+        widget = WorkflowInspector(viewer)
+        qtbot.addWidget(widget)
+        return widget
+
+    def test_load_nonexistent_file(self, inspector, tmp_path):
+        """Test loading a file that doesn't exist."""
+        bad_path = tmp_path / 'nonexistent.yaml'
+
+        inspector.load_workflow_file(bad_path)
+
+        assert 'Error' in inspector.status_label.text()
+
+    def test_load_invalid_yaml(self, inspector, tmp_path):
+        """Test loading an invalid YAML file."""
+        bad_file = tmp_path / 'bad.yaml'
+        bad_file.write_text('not: valid: yaml: file:')
+
+        inspector.load_workflow_file(bad_file)
+
+        # Should handle error gracefully
+        assert inspector._loaded_workflow is None or 'Error' in inspector.status_label.text()
+
+    def test_empty_workflow_display(self, inspector):
+        """Test display when workflow is empty."""
+        inspector._update()
+
+        assert 'No workflow loaded' in inspector.lbl_from_roots.text()
+
+    def test_get_workflow_live_mode_no_manager(self, inspector):
+        """Test _get_workflow in live mode without manager."""
+        inspector._use_live_mode = True
+
+        # Should return None without raising
+        result = inspector._get_workflow()
+        # May or may not return None depending on whether manager can be installed
+        # Just ensure it doesn't raise
+
+
+class TestNodeStatus:
+    """Tests for node status determination."""
+
+    @pytest.fixture
+    def inspector(self, make_napari_viewer, qtbot):
+        """Create a WorkflowInspector widget."""
+        pytest.importorskip('matplotlib')
+        pytest.importorskip('networkx')
+
+        from ndev_workflows.widgets._workflow_inspector import (
+            WorkflowInspector,
+        )
+
+        viewer = make_napari_viewer()
+        widget = WorkflowInspector(viewer)
+        qtbot.addWidget(widget)
+        return widget
+
+    def test_get_node_status_root(self, inspector, tmp_path):
+        """Test node status for root nodes."""
+        from ndev_workflows import Workflow, save_workflow
+
+        def identity(x):
+            return x
+
+        workflow = Workflow()
+        workflow.set('output', identity, 'root_input')
+
+        yaml_file = tmp_path / 'status_test.yaml'
+        save_workflow(yaml_file, workflow)
+
+        inspector.load_workflow_file(yaml_file)
+
+        status = inspector._get_node_status('root_input', inspector._loaded_workflow)
+        assert status == 'root'
+
+    def test_get_node_status_leaf(self, inspector, tmp_path):
+        """Test node status for leaf nodes."""
+        from ndev_workflows import Workflow, save_workflow
+
+        def identity(x):
+            return x
+
+        workflow = Workflow()
+        workflow.set('leaf_output', identity, 'input')
+
+        yaml_file = tmp_path / 'leaf_test.yaml'
+        save_workflow(yaml_file, workflow)
+
+        inspector.load_workflow_file(yaml_file)
+
+        status = inspector._get_node_status('leaf_output', inspector._loaded_workflow)
+        assert status == 'leaf'
+
+    def test_get_node_status_middle(self, inspector, tmp_path):
+        """Test node status for middle nodes."""
+        from ndev_workflows import Workflow, save_workflow
+
+        def step1(x):
+            return x + 1
+
+        def step2(x):
+            return x * 2
+
+        workflow = Workflow()
+        workflow.set('middle', step1, 'input')
+        workflow.set('output', step2, 'middle')
+
+        yaml_file = tmp_path / 'middle_test.yaml'
+        save_workflow(yaml_file, workflow)
+
+        inspector.load_workflow_file(yaml_file)
+
+        status = inspector._get_node_status('middle', inspector._loaded_workflow)
+        assert status == 'valid'
+
+
+class TestMatplotlibWidget:
+    """Tests for the MatplotlibWidget class."""
+
+    def test_matplotlib_widget_has_toolbar(self, make_napari_viewer, qtbot):
+        """Test that MatplotlibWidget includes navigation toolbar."""
+        pytest.importorskip('matplotlib')
+
+        from ndev_workflows.widgets._workflow_inspector import MatplotlibWidget
+
+        widget = MatplotlibWidget()
+        qtbot.addWidget(widget)
+
+        assert widget.canvas is not None
+        assert widget.toolbar is not None
+
+    def test_matplotlib_widget_canvas_clear(self, qtbot):
+        """Test canvas clear method."""
+        pytest.importorskip('matplotlib')
+
+        from ndev_workflows.widgets._workflow_inspector import MatplotlibWidget
+
+        widget = MatplotlibWidget()
+        qtbot.addWidget(widget)
+
+        # Draw something
+        widget.canvas.axes.plot([1, 2, 3], [1, 2, 3])
+        widget.canvas.draw()
+
+        # Clear should work
+        widget.canvas.clear()
+        widget.canvas.draw()
+
+        # Axes should be empty
+        assert len(widget.canvas.axes.lines) == 0
+
+
+class TestMplCanvas:
+    """Tests for the MplCanvas class."""
+
+    def test_mpl_canvas_creation(self):
+        """Test MplCanvas creation and basic properties."""
+        pytest.importorskip('matplotlib')
+
+        from ndev_workflows.widgets._workflow_inspector import MplCanvas
+
+        canvas = MplCanvas()
+
+        # MplCanvas uses .fig and .axes attributes
+        assert canvas.axes is not None
+        assert canvas.fig is not None
+        assert canvas.canvas is not None  # The actual FigureCanvas
+
+    def test_mpl_canvas_clear(self):
+        """Test MplCanvas clear method."""
+        pytest.importorskip('matplotlib')
+
+        from ndev_workflows.widgets._workflow_inspector import MplCanvas
+
+        canvas = MplCanvas()
+
+        # Draw something
+        canvas.axes.plot([1, 2, 3], [4, 5, 6])
+        canvas.draw()
+
+        # Clear
+        canvas.clear()
+
+        # Should have cleared axes
+        assert canvas.axes is not None
+        assert len(canvas.axes.lines) == 0
+
+
+class TestLiveModeStatus:
+    """Tests for live mode node status checking."""
+
+    @pytest.fixture
+    def inspector(self, make_napari_viewer, qtbot):
+        """Create a WorkflowInspector widget."""
+        pytest.importorskip('matplotlib')
+        pytest.importorskip('networkx')
+
+        from ndev_workflows.widgets._workflow_inspector import (
+            WorkflowInspector,
+        )
+
+        viewer = make_napari_viewer()
+        widget = WorkflowInspector(viewer)
+        qtbot.addWidget(widget)
+        return widget
+
+    def test_live_toggle_without_file(self, inspector):
+        """Test toggling live mode when no file is loaded."""
+        # Toggle to live mode
+        inspector._on_live_toggled(True)
+        assert inspector._use_live_mode is True
+        assert 'live' in inspector.status_label.text().lower()
+
+        # Toggle back to file mode with no file
+        inspector._on_live_toggled(False)
+        assert inspector._use_live_mode is False
+        assert 'No workflow loaded' in inspector.status_label.text()
+
+    def test_get_node_status_in_live_mode_missing_layer(
+        self, inspector, tmp_path
+    ):
+        """Test node status when layer doesn't exist in live mode."""
+        from ndev_workflows import Workflow
+
+        def step1(x):
+            return x
+
+        def step2(x):
+            return x
+
+        # Create a workflow where 'middle' is neither root nor leaf
+        workflow = Workflow()
+        workflow.set('middle', step1, 'input')
+        workflow.set('output', step2, 'middle')
+
+        # Switch to live mode
+        inspector._use_live_mode = True
+
+        # Middle node that doesn't exist as layer should be invalid
+        status = inspector._get_node_status('middle', workflow)
+        assert status == 'invalid'
+
+
+class TestTreeBuilding:
+    """Tests for tree HTML building."""
+
+    @pytest.fixture
+    def inspector(self, make_napari_viewer, qtbot):
+        """Create a WorkflowInspector widget."""
+        pytest.importorskip('matplotlib')
+        pytest.importorskip('networkx')
+
+        from ndev_workflows.widgets._workflow_inspector import (
+            WorkflowInspector,
+        )
+
+        viewer = make_napari_viewer()
+        widget = WorkflowInspector(viewer)
+        qtbot.addWidget(widget)
+        return widget
+
+    def test_build_tree_html_complex_workflow(self, inspector, tmp_path):
+        """Test tree building with a complex workflow."""
+        from ndev_workflows import Workflow, save_workflow
+
+        def step(x):
+            return x
+
+        workflow = Workflow()
+        workflow.set('b', step, 'a')
+        workflow.set('c', step, 'b')
+        workflow.set('d', step, 'c')
+
+        yaml_file = tmp_path / 'complex.yaml'
+        save_workflow(yaml_file, workflow)
+
+        inspector.load_workflow_file(yaml_file)
+
+        # Check the tree views contain the expected nodes
+        from_roots_text = inspector.lbl_from_roots.text()
+        from_leaves_text = inspector.lbl_from_leaves.text()
+
+        # All nodes should appear somewhere
+        for node in ['a', 'b', 'c', 'd']:
+            assert node in from_roots_text or node in from_leaves_text
+
+
+class TestCloseEvent:
+    """Tests for widget close behavior."""
+
+    def test_close_stops_timer(self, make_napari_viewer, qtbot):
+        """Test that closing the widget stops the timer."""
+        pytest.importorskip('matplotlib')
+        pytest.importorskip('networkx')
+
+        from ndev_workflows.widgets._workflow_inspector import (
+            WorkflowInspector,
+        )
+
+        viewer = make_napari_viewer()
+        widget = WorkflowInspector(viewer)
+        qtbot.addWidget(widget)
+
+        # Timer should be running
+        assert widget.timer.isActive()
+
+        # Close the widget
+        widget.close()
+
+        # Timer should be stopped
+        assert not widget.timer.isActive()

From d39de12c9e42067e383b6f0a53f39fa81cc38369 Mon Sep 17 00:00:00 2001
From: Tim Monko 
Date: Mon, 22 Dec 2025 14:14:27 -0600
Subject: [PATCH 5/9] simplify test for workflow inspector

---
 tests/widgets/test_workflow_inspector.py | 922 ++++-------------------
 1 file changed, 142 insertions(+), 780 deletions(-)

diff --git a/tests/widgets/test_workflow_inspector.py b/tests/widgets/test_workflow_inspector.py
index 3a77eb1..07e5e12 100644
--- a/tests/widgets/test_workflow_inspector.py
+++ b/tests/widgets/test_workflow_inspector.py
@@ -1,114 +1,15 @@
 """Tests for WorkflowInspector widget.
 
-Tests are organized into:
-- Unit tests (basic functionality without Qt event loop)
-- Widget tests (with qtbot for async Qt testing)
+Focuses on testing our implementation, not underlying library behavior.
 """
 
 from __future__ import annotations
 
-from unittest.mock import MagicMock
-
 import pytest
 
-from ndev_workflows import Workflow
-from ndev_workflows._manager import WorkflowManager
-
-
-# =============================================================================
-# Unit tests - Basic functionality
-# =============================================================================
-
-
-class TestWorkflowInspectorBasics:
-    """Basic tests for WorkflowInspector without full Qt integration."""
-
-    def test_import(self):
-        """Test that WorkflowInspector can be imported."""
-        from ndev_workflows.widgets._workflow_inspector import (
-            WorkflowInspector,
-        )
-
-        assert WorkflowInspector is not None
-
-    def test_mpl_canvas_import(self):
-        """Test MplCanvas can be imported (may fail if matplotlib missing)."""
-        pytest.importorskip('matplotlib')
-
-        from ndev_workflows.widgets._workflow_inspector import MplCanvas
-
-        assert MplCanvas is not None
-
-
-class TestManagerStatusMethods:
-    """Test the new status methods added to WorkflowManager."""
-
-    def test_get_layer_status_root(self, make_napari_viewer):
-        """Test get_layer_status returns 'root' for root tasks."""
-        viewer = make_napari_viewer()
-        manager = WorkflowManager.install(viewer)
-
-        # Add a simple workflow with a root
-        def identity(x):
-            return x
-
-        manager.workflow.set('output', identity, 'input')
-
-        status = manager.get_layer_status('input')
-        assert status == 'root'
-
-    def test_get_layer_status_valid(self, make_napari_viewer):
-        """Test get_layer_status returns 'valid' for computed tasks."""
-        import numpy as np
-
-        viewer = make_napari_viewer()
-        manager = WorkflowManager.install(viewer)
-
-        # Add a simple workflow
-        def identity(x):
-            return x
-
-        manager.workflow.set('input', np.zeros((10, 10)))
-        manager.workflow.set('output', identity, 'input')
-
-        # Clear pending updates to simulate completed computation
-        manager._pending_updates.clear()
-
-        status = manager.get_layer_status('output')
-        assert status == 'valid'
-
-    def test_is_layer_pending(self, make_napari_viewer):
-        """Test is_layer_pending method."""
-        viewer = make_napari_viewer()
-        manager = WorkflowManager.install(viewer)
-
-        # Manually add to pending
-        manager._pending_updates.append('test_layer')
-
-        assert manager.is_layer_pending('test_layer') is True
-        assert manager.is_layer_pending('other_layer') is False
-
-    def test_pending_updates_property(self, make_napari_viewer):
-        """Test pending_updates property returns a copy."""
-        viewer = make_napari_viewer()
-        manager = WorkflowManager.install(viewer)
-
-        manager._pending_updates.append('test')
-        pending = manager.pending_updates
-
-        # Should be a copy, not the original
-        assert pending == ['test']
-        pending.append('modified')
-        assert manager._pending_updates == ['test']
-
-
-# =============================================================================
-# Widget tests - Require qtbot
-# =============================================================================
 
-
-class TestWorkflowInspectorWidget:
-    """Widget tests requiring qtbot for Qt event loop."""
+class TestWorkflowInspector:
+    """Core WorkflowInspector widget tests."""
 
     @pytest.fixture
     def inspector(self, make_napari_viewer, qtbot):
@@ -125,147 +26,55 @@ def inspector(self, make_napari_viewer, qtbot):
         qtbot.addWidget(widget)
         return widget
 
-    def test_inspector_creates(self, inspector):
-        """Test inspector widget can be created."""
-        assert inspector is not None
-        assert inspector.tabs is not None
-
-    def test_inspector_has_tabs(self, inspector):
-        """Test inspector has expected tabs."""
-        assert inspector.tabs.count() == 5
-        assert inspector.tabs.tabText(0) == 'Graph'
-        assert inspector.tabs.tabText(1) == 'From Roots'
-        assert inspector.tabs.tabText(2) == 'From Leaves'
-        assert inspector.tabs.tabText(3) == 'Raw'
-        assert inspector.tabs.tabText(4) == 'Info'
-
-    def test_timer_starts(self, inspector):
-        """Test that update timer starts."""
-        assert inspector.timer.isActive()
+    def test_creates_with_expected_tabs(self, inspector):
+        """Test widget creates with all expected tabs."""
+        tab_names = [
+            inspector.tabs.tabText(i) for i in range(inspector.tabs.count())
+        ]
+        assert 'Graph' in tab_names
+        assert 'From Roots' in tab_names
+        assert 'From Leaves' in tab_names
+        assert 'Raw' in tab_names
+        assert 'Info' in tab_names
+
+    def test_starts_in_file_mode(self, inspector):
+        """Test inspector starts in file mode by default."""
+        assert inspector._use_live_mode is False
+        assert inspector._loaded_workflow is None
 
-    def test_timer_stops_on_close(self, inspector, qtbot):
-        """Test timer stops when widget is closed."""
+    def test_timer_starts_and_stops(self, inspector):
+        """Test timer lifecycle."""
+        assert inspector.timer.isActive()
         inspector.close()
         assert not inspector.timer.isActive()
 
-    def test_create_nx_graph_empty(self, inspector):
-        """Test graph creation with empty workflow."""
-        import networkx as nx
-
-        workflow = Workflow()
-        graph = inspector._create_nx_graph(workflow)
-
-        assert isinstance(graph, nx.DiGraph)
-        assert len(graph.nodes) == 0
-
-    def test_create_nx_graph_simple(self, inspector):
-        """Test graph creation with simple workflow."""
-        import networkx as nx
-
-        def identity(x):
-            return x
-
-        workflow = Workflow()
-        workflow.set('output', identity, 'input')
-
-        graph = inspector._create_nx_graph(workflow)
-
-        assert isinstance(graph, nx.DiGraph)
-        assert 'output' in graph.nodes
-        # Note: 'input' is a reference, not a task, so may not be in nodes
-        # depending on implementation
-
-    def test_build_tree_html(self, inspector, make_napari_viewer):
-        """Test HTML tree building."""
-
-        def identity(x):
-            return x
-
-        workflow = Workflow()
-        workflow.set('output', identity, 'input')
-
-        html = inspector._build_tree_html(
-            ['input'], workflow.followers_of, workflow
-        )
-
-        # Should contain the input name
-        assert 'input' in html or '→' in html
-
-    def test_wrap_html(self, inspector):
-        """Test HTML wrapping."""
-        content = 'test content'
-        wrapped = inspector._wrap_html(content)
-
-        assert '' in wrapped
-        assert '
' in wrapped
-        assert content in wrapped
-
-
-class TestWorkflowInspectorFileMode:
-    """Tests for file loading functionality."""
-
-    @pytest.fixture
-    def inspector(self, make_napari_viewer, qtbot):
-        """Create a WorkflowInspector widget."""
-        pytest.importorskip('matplotlib')
-        pytest.importorskip('networkx')
-
-        from ndev_workflows.widgets._workflow_inspector import (
-            WorkflowInspector,
-        )
-
-        viewer = make_napari_viewer()
-        widget = WorkflowInspector(viewer)
-        qtbot.addWidget(widget)
-        return widget
-
-    def test_initial_mode_is_file(self, inspector):
-        """Test inspector starts in file mode (not live mode)."""
-        # Default is file mode since live mode requires napari-assistant
-        assert inspector._use_live_mode is False
-        assert inspector._loaded_workflow is None
-
     def test_load_workflow_file(self, inspector, tmp_path):
         """Test loading a workflow from YAML file."""
         from ndev_workflows import Workflow, save_workflow
 
-        # Create a test workflow file
         def add_one(x):
             return x + 1
 
         workflow = Workflow()
         workflow.set('output', add_one, 'input')
 
-        yaml_file = tmp_path / 'test_workflow.yaml'
+        yaml_file = tmp_path / 'test.yaml'
         save_workflow(yaml_file, workflow)
 
-        # Load the file
         inspector.load_workflow_file(yaml_file)
 
-        # Should stay in file mode
-        assert inspector._use_live_mode is False
         assert inspector._loaded_workflow is not None
         assert inspector._workflow_file == yaml_file
-
-    def test_load_sets_status_label(self, inspector, tmp_path):
-        """Test loading updates status label."""
-        from ndev_workflows import Workflow, save_workflow
-
-        def identity(x):
-            return x
-
-        workflow = Workflow()
-        workflow.set('out', identity, 'in')
-
-        yaml_file = tmp_path / 'test.yaml'
-        save_workflow(yaml_file, workflow)
-
-        inspector.load_workflow_file(yaml_file)
-
         assert 'test.yaml' in inspector.status_label.text()
 
-    def test_toggle_to_live_mode(self, inspector, tmp_path):
-        """Test toggling to live mode."""
+    def test_load_nonexistent_file_shows_error(self, inspector, tmp_path):
+        """Test loading a missing file shows error in status."""
+        bad_path = tmp_path / 'nonexistent.yaml'
+        inspector.load_workflow_file(bad_path)
+        assert 'Error' in inspector.status_label.text()
+
+    def test_toggle_live_mode(self, inspector, tmp_path):
+        """Test toggling between file and live mode."""
         from ndev_workflows import Workflow, save_workflow
 
         def identity(x):
@@ -282,649 +91,202 @@ def identity(x):
         assert inspector._use_live_mode is False
         assert inspector._loaded_workflow is not None
 
-        # Toggle to live
+        # Toggle to live mode - clears loaded workflow
         inspector._on_live_toggled(True)
         assert inspector._use_live_mode is True
         assert inspector._loaded_workflow is None
 
-    def test_get_workflow_in_file_mode(self, inspector, tmp_path):
-        """Test _get_workflow returns loaded workflow in file mode."""
+    def test_graph_updates_on_workflow_load(self, inspector, tmp_path):
+        """Test that graph is drawn when workflow is loaded."""
         from ndev_workflows import Workflow, save_workflow
 
-        def identity(x):
+        def step(x):
             return x
 
         workflow = Workflow()
-        workflow.set('out', identity, 'in')
+        workflow.set('middle', step, 'input')
+        workflow.set('output', step, 'middle')
 
         yaml_file = tmp_path / 'test.yaml'
         save_workflow(yaml_file, workflow)
 
         inspector.load_workflow_file(yaml_file)
 
-        result = inspector._get_workflow()
-        assert result is not None
-        assert 'out' in result
-
-
-class TestDraggableNodes:
-    """Tests for the DraggableNodes class."""
-
-    @pytest.fixture
-    def mpl_canvas(self):
-        """Create a real MplCanvas."""
-        pytest.importorskip('matplotlib')
-
-        from ndev_workflows.widgets._workflow_inspector import MplCanvas
-
-        return MplCanvas()
-
-    def test_draggable_nodes_init(self, mpl_canvas):
-        """Test DraggableNodes initialization."""
-        from ndev_workflows.widgets._workflow_inspector import DraggableNodes
-
-        positions = {'node1': (0.0, 0.0), 'node2': (1.0, 1.0)}
-        nodes = DraggableNodes(mpl_canvas, positions, viewer=None)
-
-        assert nodes.positions == {'node1': (0.0, 0.0), 'node2': (1.0, 1.0)}
-        assert nodes.points is not None
-        assert len(nodes.x) == 2
-        assert len(nodes.y) == 2
-
-    def test_update_node_status(self, mpl_canvas):
-        """Test updating node status colors."""
-        from ndev_workflows.widgets._workflow_inspector import DraggableNodes
-
-        positions = {'node1': (0.0, 0.0), 'node2': (1.0, 1.0)}
-        nodes = DraggableNodes(mpl_canvas, positions, viewer=None)
-
-        # Test all status types - should not raise
-        nodes.update_node_status('node1', 'root')
-        nodes.update_node_status('node1', 'valid')
-        nodes.update_node_status('node1', 'invalid')
-        nodes.update_node_status('node1', 'leaf')
-
-        # Verify the scatter colors changed (facecolors is an array)
-        facecolors = nodes.points.get_facecolors()
-        assert len(facecolors) == 2  # Two nodes
-
-    def test_dragging_workflow(self, mpl_canvas):
-        """Test the dragging state machine."""
-        from ndev_workflows.widgets._workflow_inspector import DraggableNodes
-
-        callback = MagicMock()
-        positions = {'node1': (0.0, 0.0), 'node2': (1.0, 1.0)}
-        nodes = DraggableNodes(
-            mpl_canvas, positions, viewer=None, on_positions_changed=callback
-        )
-
-        # Initial state
-        assert nodes._dragging is False
-        assert nodes._drag_index is None
-
-        # Simulate pick event (selecting node 0)
-        pick_event = MagicMock()
-        pick_event.mouseevent = MagicMock()
-        pick_event.mouseevent.button = 1
-        pick_event.ind = [0]
-        nodes._on_pick(pick_event)
-
-        assert nodes._drag_index == 0
-
-        # Simulate press
-        press_event = MagicMock()
-        press_event.button = 1
-        nodes._on_press(press_event)
-        assert nodes._dragging is True
-
-        # Simulate motion
-        motion_event = MagicMock()
-        motion_event.xdata = 0.5
-        motion_event.ydata = 0.6
-        nodes._on_motion(motion_event)
-
-        # Position should update
-        assert nodes.positions['node1'] == (0.5, 0.6)
-        assert nodes.x[0] == 0.5
-        assert nodes.y[0] == 0.6
-        assert callback.called
-
-        # Simulate release
-        release_event = MagicMock()
-        release_event.button = 1
-        nodes._on_release(release_event)
-        assert nodes._dragging is False
-
-    def test_motion_without_drag_does_nothing(self, mpl_canvas):
-        """Test motion event when not dragging does nothing."""
-        from ndev_workflows.widgets._workflow_inspector import DraggableNodes
-
-        callback = MagicMock()
-        positions = {'node1': (0.0, 0.0)}
-        nodes = DraggableNodes(
-            mpl_canvas, positions, viewer=None, on_positions_changed=callback
-        )
-
-        # Motion without drag should do nothing
-        motion_event = MagicMock()
-        motion_event.xdata = 0.5
-        motion_event.ydata = 0.5
-        nodes._on_motion(motion_event)
-
-        # Position should not change
-        assert nodes.positions['node1'] == (0.0, 0.0)
-        assert not callback.called
-
-    def test_select_node_with_viewer(self, mpl_canvas, make_napari_viewer):
-        """Test node selection with viewer."""
-        import numpy as np
-
-        from ndev_workflows.widgets._workflow_inspector import DraggableNodes
-
-        viewer = make_napari_viewer()
-        viewer.add_image(np.array([[1, 2], [3, 4]]), name='test_layer')
-
-        positions = {'test_layer': (0.0, 0.0)}
-        nodes = DraggableNodes(mpl_canvas, positions, viewer=viewer)
-
-        # Select the node (index 0)
-        nodes._select_node(0)
-
-        # Layer should be selected in viewer
-        assert viewer.layers.selection == {viewer.layers['test_layer']}
-
-    def test_select_node_nonexistent_layer(self, mpl_canvas, make_napari_viewer):
-        """Test selecting node for layer that doesn't exist in viewer."""
-        from ndev_workflows.widgets._workflow_inspector import DraggableNodes
-
-        viewer = make_napari_viewer()
-        positions = {'nonexistent': (0.0, 0.0)}
-        nodes = DraggableNodes(mpl_canvas, positions, viewer=viewer)
-
-        # Should not raise - just won't select anything
-        nodes._select_node(0)
-        # Selection should remain empty
-        assert len(viewer.layers.selection) == 0
-
-
-class TestGraphDrawing:
-    """Tests for graph drawing functionality."""
-
-    @pytest.fixture
-    def inspector_with_workflow(self, make_napari_viewer, qtbot, tmp_path):
-        """Create inspector with a loaded workflow."""
-        pytest.importorskip('matplotlib')
-        pytest.importorskip('networkx')
-
-        from ndev_workflows import Workflow, save_workflow
-        from ndev_workflows.widgets._workflow_inspector import (
-            WorkflowInspector,
-        )
-
-        def step1(x):
-            return x + 1
-
-        def step2(x):
-            return x * 2
-
-        workflow = Workflow()
-        workflow.set('middle', step1, 'input')
-        workflow.set('output', step2, 'middle')
-
-        yaml_file = tmp_path / 'test.yaml'
-        save_workflow(yaml_file, workflow)
-
-        viewer = make_napari_viewer()
-        widget = WorkflowInspector(viewer)
-        qtbot.addWidget(widget)
-
-        widget.load_workflow_file(yaml_file)
-        return widget
-
-    def test_draw_graph_creates_positions(self, inspector_with_workflow):
-        """Test that drawing creates node positions."""
-        inspector = inspector_with_workflow
+        # Graph should be created with positions
+        assert inspector._graph is not None
         assert inspector._positions is not None
-        # 'middle' and 'output' are processing steps - always in graph
-        assert 'middle' in inspector._positions
-        assert 'output' in inspector._positions
-
-    def test_draw_graph_creates_graph_drawing(self, inspector_with_workflow):
-        """Test that drawing creates DraggableNodes."""
-        inspector = inspector_with_workflow
-        assert inspector._graph_drawing is not None
-
-    def test_graph_changed_detection(self, inspector_with_workflow, tmp_path):
-        """Test graph change detection."""
-        import networkx as nx
-
-        inspector = inspector_with_workflow
-
-        # Create a graph that is clearly different
-        different_graph = nx.DiGraph()
-        different_graph.add_node('completely_different_node')
-        different_graph.add_node('another_node')
-        different_graph.add_edge('completely_different_node', 'another_node')
-
-        # The graphs should be different
-        assert inspector._graph_changed(different_graph)
-
-    def test_graph_not_changed_same_structure(self, inspector_with_workflow):
-        """Test that identical graphs are not detected as changed."""
-        inspector = inspector_with_workflow
-
-        # Create a copy of the current graph
-        same_graph = inspector._create_nx_graph(inspector._loaded_workflow)
-
-        # Same graph should not be detected as changed
-        assert not inspector._graph_changed(same_graph)
-
-    def test_update_calls_draw_or_colors(
-        self, inspector_with_workflow, tmp_path
-    ):
-        """Test that update properly refreshes the view."""
-        inspector = inspector_with_workflow
-
-        # Force an update
-        inspector._update()
-
-        # Labels should be populated
-        assert 'input' in inspector.lbl_from_roots.text()
-        assert 'output' in inspector.lbl_from_leaves.text()
-
-    def test_on_node_positions_changed(self, inspector_with_workflow):
-        """Test callback when node positions change."""
-        inspector = inspector_with_workflow
-
-        # Modify positions directly
-        if inspector._graph_drawing:
-            inspector._graph_drawing.positions['input'] = (0.5, 0.5)
-            inspector._on_node_positions_changed()
-
-            # Positions should be updated
-            assert inspector._positions['input'] == (0.5, 0.5)
-
-
-class TestInfoText:
-    """Tests for info text generation."""
-
-    @pytest.fixture
-    def inspector(self, make_napari_viewer, qtbot):
-        """Create a WorkflowInspector widget."""
-        pytest.importorskip('matplotlib')
-        pytest.importorskip('networkx')
-
-        from ndev_workflows.widgets._workflow_inspector import (
-            WorkflowInspector,
-        )
+        assert len(inspector._positions) > 0
 
-        viewer = make_napari_viewer()
-        widget = WorkflowInspector(viewer)
-        qtbot.addWidget(widget)
-        return widget
-
-    def test_build_info_text_file_mode(self, inspector, tmp_path):
-        """Test info text in file mode."""
+    def test_node_status_detection(self, inspector, tmp_path):
+        """Test node status is correctly determined."""
         from ndev_workflows import Workflow, save_workflow
 
-        def process(x):
-            return x * 2
+        def step(x):
+            return x
 
         workflow = Workflow()
-        workflow.set('out', process, 'in')
+        workflow.set('middle', step, 'input')
+        workflow.set('output', step, 'middle')
 
-        yaml_file = tmp_path / 'info_test.yaml'
+        yaml_file = tmp_path / 'test.yaml'
         save_workflow(yaml_file, workflow)
 
         inspector.load_workflow_file(yaml_file)
-        info = inspector._build_info_text(inspector._loaded_workflow)
 
-        assert 'Source:' in info
-        assert 'info_test.yaml' in info
-        assert 'Total tasks:' in info
-        assert 'Roots (inputs):' in info
-        assert 'Leaves (outputs):' in info
+        # Root should be detected
+        assert inspector._get_node_status('input', workflow) == 'root'
+        # Leaf should be detected
+        assert inspector._get_node_status('output', workflow) == 'leaf'
+        # Middle node should be valid (in file mode)
+        assert inspector._get_node_status('middle', workflow) == 'valid'
 
-    def test_build_info_text_processing_steps(self, inspector, tmp_path):
-        """Test info text shows processing steps."""
+    def test_info_text_contains_workflow_stats(self, inspector, tmp_path):
+        """Test info text includes workflow statistics."""
         from ndev_workflows import Workflow, save_workflow
 
-        def my_function(x):
+        def my_func(x):
             return x
 
         workflow = Workflow()
-        workflow.set('output', my_function, 'input')
+        workflow.set('output', my_func, 'input')
 
-        yaml_file = tmp_path / 'steps_test.yaml'
+        yaml_file = tmp_path / 'test.yaml'
         save_workflow(yaml_file, workflow)
 
         inspector.load_workflow_file(yaml_file)
         info = inspector._build_info_text(inspector._loaded_workflow)
 
-        assert 'Processing Steps' in info
-
-
-class TestErrorHandling:
-    """Tests for error handling paths."""
-
-    @pytest.fixture
-    def inspector(self, make_napari_viewer, qtbot):
-        """Create a WorkflowInspector widget."""
-        pytest.importorskip('matplotlib')
-        pytest.importorskip('networkx')
-
-        from ndev_workflows.widgets._workflow_inspector import (
-            WorkflowInspector,
-        )
-
-        viewer = make_napari_viewer()
-        widget = WorkflowInspector(viewer)
-        qtbot.addWidget(widget)
-        return widget
-
-    def test_load_nonexistent_file(self, inspector, tmp_path):
-        """Test loading a file that doesn't exist."""
-        bad_path = tmp_path / 'nonexistent.yaml'
-
-        inspector.load_workflow_file(bad_path)
-
-        assert 'Error' in inspector.status_label.text()
-
-    def test_load_invalid_yaml(self, inspector, tmp_path):
-        """Test loading an invalid YAML file."""
-        bad_file = tmp_path / 'bad.yaml'
-        bad_file.write_text('not: valid: yaml: file:')
-
-        inspector.load_workflow_file(bad_file)
-
-        # Should handle error gracefully
-        assert inspector._loaded_workflow is None or 'Error' in inspector.status_label.text()
+        assert 'Total tasks:' in info
+        assert 'Roots' in info
+        assert 'Leaves' in info
 
-    def test_empty_workflow_display(self, inspector):
-        """Test display when workflow is empty."""
+    def test_empty_workflow_shows_message(self, inspector):
+        """Test display when no workflow is loaded."""
         inspector._update()
+        assert 'No workflow' in inspector.lbl_from_roots.text()
 
-        assert 'No workflow loaded' in inspector.lbl_from_roots.text()
-
-    def test_get_workflow_live_mode_no_manager(self, inspector):
-        """Test _get_workflow in live mode without manager."""
-        inspector._use_live_mode = True
-
-        # Should return None without raising
-        result = inspector._get_workflow()
-        # May or may not return None depending on whether manager can be installed
-        # Just ensure it doesn't raise
 
+class TestManagerStatusMethods:
+    """Test status methods added to WorkflowManager."""
 
-class TestNodeStatus:
-    """Tests for node status determination."""
-
-    @pytest.fixture
-    def inspector(self, make_napari_viewer, qtbot):
-        """Create a WorkflowInspector widget."""
-        pytest.importorskip('matplotlib')
-        pytest.importorskip('networkx')
-
-        from ndev_workflows.widgets._workflow_inspector import (
-            WorkflowInspector,
-        )
+    def test_get_layer_status(self, make_napari_viewer):
+        """Test get_layer_status returns correct status."""
+        from ndev_workflows._manager import WorkflowManager
 
         viewer = make_napari_viewer()
-        widget = WorkflowInspector(viewer)
-        qtbot.addWidget(widget)
-        return widget
-
-    def test_get_node_status_root(self, inspector, tmp_path):
-        """Test node status for root nodes."""
-        from ndev_workflows import Workflow, save_workflow
-
-        def identity(x):
-            return x
-
-        workflow = Workflow()
-        workflow.set('output', identity, 'root_input')
-
-        yaml_file = tmp_path / 'status_test.yaml'
-        save_workflow(yaml_file, workflow)
-
-        inspector.load_workflow_file(yaml_file)
-
-        status = inspector._get_node_status('root_input', inspector._loaded_workflow)
-        assert status == 'root'
-
-    def test_get_node_status_leaf(self, inspector, tmp_path):
-        """Test node status for leaf nodes."""
-        from ndev_workflows import Workflow, save_workflow
+        manager = WorkflowManager.install(viewer)
 
         def identity(x):
             return x
 
-        workflow = Workflow()
-        workflow.set('leaf_output', identity, 'input')
-
-        yaml_file = tmp_path / 'leaf_test.yaml'
-        save_workflow(yaml_file, workflow)
-
-        inspector.load_workflow_file(yaml_file)
-
-        status = inspector._get_node_status('leaf_output', inspector._loaded_workflow)
-        assert status == 'leaf'
-
-    def test_get_node_status_middle(self, inspector, tmp_path):
-        """Test node status for middle nodes."""
-        from ndev_workflows import Workflow, save_workflow
-
-        def step1(x):
-            return x + 1
-
-        def step2(x):
-            return x * 2
-
-        workflow = Workflow()
-        workflow.set('middle', step1, 'input')
-        workflow.set('output', step2, 'middle')
-
-        yaml_file = tmp_path / 'middle_test.yaml'
-        save_workflow(yaml_file, workflow)
-
-        inspector.load_workflow_file(yaml_file)
-
-        status = inspector._get_node_status('middle', inspector._loaded_workflow)
-        assert status == 'valid'
-
-
-class TestMatplotlibWidget:
-    """Tests for the MatplotlibWidget class."""
-
-    def test_matplotlib_widget_has_toolbar(self, make_napari_viewer, qtbot):
-        """Test that MatplotlibWidget includes navigation toolbar."""
-        pytest.importorskip('matplotlib')
-
-        from ndev_workflows.widgets._workflow_inspector import MatplotlibWidget
-
-        widget = MatplotlibWidget()
-        qtbot.addWidget(widget)
-
-        assert widget.canvas is not None
-        assert widget.toolbar is not None
-
-    def test_matplotlib_widget_canvas_clear(self, qtbot):
-        """Test canvas clear method."""
-        pytest.importorskip('matplotlib')
-
-        from ndev_workflows.widgets._workflow_inspector import MatplotlibWidget
-
-        widget = MatplotlibWidget()
-        qtbot.addWidget(widget)
-
-        # Draw something
-        widget.canvas.axes.plot([1, 2, 3], [1, 2, 3])
-        widget.canvas.draw()
-
-        # Clear should work
-        widget.canvas.clear()
-        widget.canvas.draw()
-
-        # Axes should be empty
-        assert len(widget.canvas.axes.lines) == 0
-
-
-class TestMplCanvas:
-    """Tests for the MplCanvas class."""
-
-    def test_mpl_canvas_creation(self):
-        """Test MplCanvas creation and basic properties."""
-        pytest.importorskip('matplotlib')
-
-        from ndev_workflows.widgets._workflow_inspector import MplCanvas
+        manager.workflow.set('output', identity, 'input')
 
-        canvas = MplCanvas()
+        assert manager.get_layer_status('input') == 'root'
 
-        # MplCanvas uses .fig and .axes attributes
-        assert canvas.axes is not None
-        assert canvas.fig is not None
-        assert canvas.canvas is not None  # The actual FigureCanvas
+    def test_is_layer_pending(self, make_napari_viewer):
+        """Test is_layer_pending method."""
+        from ndev_workflows._manager import WorkflowManager
 
-    def test_mpl_canvas_clear(self):
-        """Test MplCanvas clear method."""
-        pytest.importorskip('matplotlib')
+        viewer = make_napari_viewer()
+        manager = WorkflowManager.install(viewer)
 
-        from ndev_workflows.widgets._workflow_inspector import MplCanvas
+        manager._pending_updates.append('test_layer')
+        assert manager.is_layer_pending('test_layer') is True
+        assert manager.is_layer_pending('other') is False
 
-        canvas = MplCanvas()
+    def test_pending_updates_property(self, make_napari_viewer):
+        """Test pending_updates returns a copy."""
+        from ndev_workflows._manager import WorkflowManager
 
-        # Draw something
-        canvas.axes.plot([1, 2, 3], [4, 5, 6])
-        canvas.draw()
+        viewer = make_napari_viewer()
+        manager = WorkflowManager.install(viewer)
 
-        # Clear
-        canvas.clear()
+        manager._pending_updates.append('test')
+        pending = manager.pending_updates
 
-        # Should have cleared axes
-        assert canvas.axes is not None
-        assert len(canvas.axes.lines) == 0
+        assert pending == ['test']
+        # Should be a copy
+        pending.append('modified')
+        assert 'modified' not in manager.pending_updates
 
 
-class TestLiveModeStatus:
-    """Tests for live mode node status checking."""
+class TestDraggableNodes:
+    """Test DraggableNodes interaction handling."""
 
     @pytest.fixture
-    def inspector(self, make_napari_viewer, qtbot):
-        """Create a WorkflowInspector widget."""
+    def draggable_nodes(self):
+        """Create DraggableNodes with real MplCanvas."""
         pytest.importorskip('matplotlib')
-        pytest.importorskip('networkx')
+        from unittest.mock import MagicMock
 
         from ndev_workflows.widgets._workflow_inspector import (
-            WorkflowInspector,
+            DraggableNodes,
+            MplCanvas,
         )
 
-        viewer = make_napari_viewer()
-        widget = WorkflowInspector(viewer)
-        qtbot.addWidget(widget)
-        return widget
-
-    def test_live_toggle_without_file(self, inspector):
-        """Test toggling live mode when no file is loaded."""
-        # Toggle to live mode
-        inspector._on_live_toggled(True)
-        assert inspector._use_live_mode is True
-        assert 'live' in inspector.status_label.text().lower()
-
-        # Toggle back to file mode with no file
-        inspector._on_live_toggled(False)
-        assert inspector._use_live_mode is False
-        assert 'No workflow loaded' in inspector.status_label.text()
-
-    def test_get_node_status_in_live_mode_missing_layer(
-        self, inspector, tmp_path
-    ):
-        """Test node status when layer doesn't exist in live mode."""
-        from ndev_workflows import Workflow
-
-        def step1(x):
-            return x
-
-        def step2(x):
-            return x
-
-        # Create a workflow where 'middle' is neither root nor leaf
-        workflow = Workflow()
-        workflow.set('middle', step1, 'input')
-        workflow.set('output', step2, 'middle')
-
-        # Switch to live mode
-        inspector._use_live_mode = True
-
-        # Middle node that doesn't exist as layer should be invalid
-        status = inspector._get_node_status('middle', workflow)
-        assert status == 'invalid'
-
-
-class TestTreeBuilding:
-    """Tests for tree HTML building."""
-
-    @pytest.fixture
-    def inspector(self, make_napari_viewer, qtbot):
-        """Create a WorkflowInspector widget."""
-        pytest.importorskip('matplotlib')
-        pytest.importorskip('networkx')
+        canvas = MplCanvas()
+        positions = {'node1': (0.0, 0.0), 'node2': (1.0, 1.0)}
+        callback = MagicMock()
 
-        from ndev_workflows.widgets._workflow_inspector import (
-            WorkflowInspector,
+        nodes = DraggableNodes(
+            canvas, positions, viewer=None, on_positions_changed=callback
         )
+        return nodes, callback
 
-        viewer = make_napari_viewer()
-        widget = WorkflowInspector(viewer)
-        qtbot.addWidget(widget)
-        return widget
-
-    def test_build_tree_html_complex_workflow(self, inspector, tmp_path):
-        """Test tree building with a complex workflow."""
-        from ndev_workflows import Workflow, save_workflow
+    def test_drag_updates_position(self, draggable_nodes):
+        """Test that dragging updates node position."""
+        from unittest.mock import MagicMock
 
-        def step(x):
-            return x
+        nodes, callback = draggable_nodes
 
-        workflow = Workflow()
-        workflow.set('b', step, 'a')
-        workflow.set('c', step, 'b')
-        workflow.set('d', step, 'c')
+        # Simulate pick -> press -> motion -> release
+        pick_event = MagicMock()
+        pick_event.mouseevent.button = 1
+        pick_event.ind = [0]
+        nodes._on_pick(pick_event)
 
-        yaml_file = tmp_path / 'complex.yaml'
-        save_workflow(yaml_file, workflow)
+        press_event = MagicMock()
+        press_event.button = 1
+        nodes._on_press(press_event)
 
-        inspector.load_workflow_file(yaml_file)
+        motion_event = MagicMock()
+        motion_event.xdata = 0.5
+        motion_event.ydata = 0.6
+        nodes._on_motion(motion_event)
 
-        # Check the tree views contain the expected nodes
-        from_roots_text = inspector.lbl_from_roots.text()
-        from_leaves_text = inspector.lbl_from_leaves.text()
+        assert nodes.positions['node1'] == (0.5, 0.6)
+        assert callback.called
 
-        # All nodes should appear somewhere
-        for node in ['a', 'b', 'c', 'd']:
-            assert node in from_roots_text or node in from_leaves_text
+        release_event = MagicMock()
+        release_event.button = 1
+        nodes._on_release(release_event)
+        assert nodes._dragging is False
 
+    def test_update_node_status_changes_color(self, draggable_nodes):
+        """Test status updates change node colors."""
+        nodes, _ = draggable_nodes
 
-class TestCloseEvent:
-    """Tests for widget close behavior."""
+        # Should not raise for any status
+        for status in ['root', 'leaf', 'valid', 'invalid']:
+            nodes.update_node_status('node1', status)
 
-    def test_close_stops_timer(self, make_napari_viewer, qtbot):
-        """Test that closing the widget stops the timer."""
+    def test_select_node_with_viewer(self, make_napari_viewer):
+        """Test clicking a node selects the layer in viewer."""
         pytest.importorskip('matplotlib')
-        pytest.importorskip('networkx')
+        import numpy as np
 
         from ndev_workflows.widgets._workflow_inspector import (
-            WorkflowInspector,
+            DraggableNodes,
+            MplCanvas,
         )
 
         viewer = make_napari_viewer()
-        widget = WorkflowInspector(viewer)
-        qtbot.addWidget(widget)
+        viewer.add_image(np.zeros((10, 10)), name='test_layer')
 
-        # Timer should be running
-        assert widget.timer.isActive()
+        canvas = MplCanvas()
+        positions = {'test_layer': (0.0, 0.0)}
+        nodes = DraggableNodes(canvas, positions, viewer=viewer)
 
-        # Close the widget
-        widget.close()
+        nodes._select_node(0)
 
-        # Timer should be stopped
-        assert not widget.timer.isActive()
+        assert viewer.layers.selection == {viewer.layers['test_layer']}

From c8f1198b81ad50d1bf0dc467d0bda9ff6d1c8cb5 Mon Sep 17 00:00:00 2001
From: Tim Monko 
Date: Mon, 22 Dec 2025 15:12:20 -0600
Subject: [PATCH 6/9] optional matplotlib

---
 pyproject.toml                                |  3 ++
 .../widgets/_workflow_inspector.py            | 37 ++++++++++++++++---
 2 files changed, 34 insertions(+), 6 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index 019a388..2a65a9c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,12 +36,15 @@ dependencies = [
     "numpy",
     "dask",
     "pyyaml",
+    "networkx",  # also a transitive dependency of scikit-image, from napari
 ]
 
 [project.optional-dependencies]
 # Allow easily installation with the full, default napari installation
 # (including Qt backend) using ndev-workflows[all].
 all = ["napari[all]"]
+# Optional visualization dependencies for the workflow inspector graph view
+plot = ["matplotlib"]
 
 [dependency-groups]
 dev = [
diff --git a/src/ndev_workflows/widgets/_workflow_inspector.py b/src/ndev_workflows/widgets/_workflow_inspector.py
index 3fb9343..5339905 100644
--- a/src/ndev_workflows/widgets/_workflow_inspector.py
+++ b/src/ndev_workflows/widgets/_workflow_inspector.py
@@ -32,6 +32,18 @@
     import napari.viewer
 
 
+def _check_matplotlib():
+    """Check if matplotlib is available."""
+    try:
+        import matplotlib  # noqa: F401
+        return True
+    except ImportError:
+        return False
+
+
+HAS_MATPLOTLIB = _check_matplotlib()
+
+
 class MplCanvas:
     """Matplotlib canvas for embedding in Qt widgets.
 
@@ -334,9 +346,18 @@ def _init_ui(self):
         self.tabs = QTabWidget()
         layout.addWidget(self.tabs)
 
-        # Graph tab
-        self.graph_widget = MatplotlibWidget()
-        self.tabs.addTab(self.graph_widget, 'Graph')
+        # Graph tab (requires matplotlib)
+        if HAS_MATPLOTLIB:
+            self.graph_widget = MatplotlibWidget()
+            self.tabs.addTab(self.graph_widget, 'Graph')
+        else:
+            self.graph_widget = None
+            no_mpl_label = QLabel(
+                'Graph view requires matplotlib.\n\n'
+                'Install with: pip install ndev-workflows[plot]'
+            )
+            no_mpl_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+            self.tabs.addTab(no_mpl_label, 'Graph')
 
         # From Roots tab
         self.roots_scroll = QScrollArea()
@@ -522,8 +543,9 @@ def _update(self):
             self.lbl_from_leaves.setText('No workflow loaded or empty workflow')
             self.lbl_raw.setText('No workflow loaded or empty workflow')
             self.lbl_info.setText('Load a YAML file or enable live mode')
-            self.graph_widget.canvas.clear()
-            self.graph_widget.canvas.draw()
+            if self.graph_widget is not None:
+                self.graph_widget.canvas.clear()
+                self.graph_widget.canvas.draw()
             return
 
         # Update tree views
@@ -549,7 +571,10 @@ def _update(self):
         info_text = self._build_info_text(workflow)
         self.lbl_info.setText(info_text)
 
-        # Update graph
+        # Update graph (only if matplotlib available)
+        if self.graph_widget is None:
+            return
+
         new_graph = self._create_nx_graph(workflow)
         if self._graph is None or self._graph_changed(new_graph):
             self._graph = new_graph

From 80e44e4a1a483f5b96968eacde8ad3c6b5edbc6c Mon Sep 17 00:00:00 2001
From: Tim Monko 
Date: Mon, 22 Dec 2025 15:21:29 -0600
Subject: [PATCH 7/9] proper skip for no matplotlib import

---
 tests/widgets/test_workflow_inspector.py | 18 ++++++++----------
 1 file changed, 8 insertions(+), 10 deletions(-)

diff --git a/tests/widgets/test_workflow_inspector.py b/tests/widgets/test_workflow_inspector.py
index 07e5e12..aec0a70 100644
--- a/tests/widgets/test_workflow_inspector.py
+++ b/tests/widgets/test_workflow_inspector.py
@@ -7,6 +7,11 @@
 
 import pytest
 
+from ndev_workflows.widgets._workflow_inspector import (
+    HAS_MATPLOTLIB,
+    WorkflowInspector,
+)
+
 
 class TestWorkflowInspector:
     """Core WorkflowInspector widget tests."""
@@ -14,13 +19,6 @@ class TestWorkflowInspector:
     @pytest.fixture
     def inspector(self, make_napari_viewer, qtbot):
         """Create a WorkflowInspector widget."""
-        pytest.importorskip('matplotlib')
-        pytest.importorskip('networkx')
-
-        from ndev_workflows.widgets._workflow_inspector import (
-            WorkflowInspector,
-        )
-
         viewer = make_napari_viewer()
         widget = WorkflowInspector(viewer)
         qtbot.addWidget(widget)
@@ -96,6 +94,7 @@ def identity(x):
         assert inspector._use_live_mode is True
         assert inspector._loaded_workflow is None
 
+    @pytest.mark.skipif(not HAS_MATPLOTLIB, reason='requires matplotlib')
     def test_graph_updates_on_workflow_load(self, inspector, tmp_path):
         """Test that graph is drawn when workflow is loaded."""
         from ndev_workflows import Workflow, save_workflow
@@ -210,13 +209,13 @@ def test_pending_updates_property(self, make_napari_viewer):
         assert 'modified' not in manager.pending_updates
 
 
+@pytest.mark.skipif(not HAS_MATPLOTLIB, reason='requires matplotlib')
 class TestDraggableNodes:
-    """Test DraggableNodes interaction handling."""
+    """Test DraggableNodes interaction handling (requires matplotlib)."""
 
     @pytest.fixture
     def draggable_nodes(self):
         """Create DraggableNodes with real MplCanvas."""
-        pytest.importorskip('matplotlib')
         from unittest.mock import MagicMock
 
         from ndev_workflows.widgets._workflow_inspector import (
@@ -272,7 +271,6 @@ def test_update_node_status_changes_color(self, draggable_nodes):
 
     def test_select_node_with_viewer(self, make_napari_viewer):
         """Test clicking a node selects the layer in viewer."""
-        pytest.importorskip('matplotlib')
         import numpy as np
 
         from ndev_workflows.widgets._workflow_inspector import (

From df3ec136de32e358b7974c26272aca56b1c48ceb Mon Sep 17 00:00:00 2001
From: Tim Monko 
Date: Tue, 23 Dec 2025 11:38:25 -0600
Subject: [PATCH 8/9] add caching to inspector

---
 src/ndev_workflows/_manager.py                |   4 +-
 .../widgets/_workflow_inspector.py            | 224 +++++++++++++-----
 tests/widgets/test_workflow_inspector.py      |  49 +++-
 3 files changed, 209 insertions(+), 68 deletions(-)

diff --git a/src/ndev_workflows/_manager.py b/src/ndev_workflows/_manager.py
index c25a644..087a1cb 100644
--- a/src/ndev_workflows/_manager.py
+++ b/src/ndev_workflows/_manager.py
@@ -140,10 +140,12 @@ def get_layer_status(self, name: str) -> str:
         Returns
         -------
         str
-            One of 'root', 'invalid', or 'valid'.
+            One of 'root', 'leaf', 'invalid', or 'valid'.
         """
         if name in self._workflow.roots():
             return 'root'
+        elif name in self._workflow.leaves():
+            return 'leaf'
         elif name in self._pending_updates:
             return 'invalid'
         else:
diff --git a/src/ndev_workflows/widgets/_workflow_inspector.py b/src/ndev_workflows/widgets/_workflow_inspector.py
index 5339905..2d02479 100644
--- a/src/ndev_workflows/widgets/_workflow_inspector.py
+++ b/src/ndev_workflows/widgets/_workflow_inspector.py
@@ -36,6 +36,7 @@ def _check_matplotlib():
     """Check if matplotlib is available."""
     try:
         import matplotlib  # noqa: F401
+
         return True
     except ImportError:
         return False
@@ -58,9 +59,7 @@ def __init__(self):
 
         self.fig = Figure()
         self.axes = self.fig.add_subplot(111)
-        self.fig.subplots_adjust(
-            left=0.04, bottom=0.04, right=0.97, top=0.96
-        )
+        self.fig.subplots_adjust(left=0.04, bottom=0.04, right=0.97, top=0.96)
         # Dark theme to match napari
         self.fig.patch.set_facecolor('#262930')
         self.axes.set_facecolor('#262930')
@@ -126,8 +125,11 @@ def __init__(
         self._drag_index = None
         self._selected_index = None
 
-        self.x = [positions[key][0] for key in positions]
-        self.y = [positions[key][1] for key in positions]
+        # Cache keys list for consistent indexing
+        self._keys: list[str] = list(positions.keys())
+
+        self.x = [positions[key][0] for key in self._keys]
+        self.y = [positions[key][1] for key in self._keys]
 
         # Create scatter plot of nodes
         self.points = self.canvas.axes.scatter(
@@ -142,11 +144,25 @@ def __init__(
 
         self.edgecolors = self.points.get_edgecolors().copy()
 
-        # Connect mouse events for dragging
-        self.canvas.canvas.mpl_connect('pick_event', self._on_pick)
-        self.canvas.canvas.mpl_connect('button_press_event', self._on_press)
-        self.canvas.canvas.mpl_connect('button_release_event', self._on_release)
-        self.canvas.canvas.mpl_connect('motion_notify_event', self._on_motion)
+        # Connect mouse events for dragging and store connection IDs for cleanup
+        self._cids = [
+            self.canvas.canvas.mpl_connect('pick_event', self._on_pick),
+            self.canvas.canvas.mpl_connect(
+                'button_press_event', self._on_press
+            ),
+            self.canvas.canvas.mpl_connect(
+                'button_release_event', self._on_release
+            ),
+            self.canvas.canvas.mpl_connect(
+                'motion_notify_event', self._on_motion
+            ),
+        ]
+
+    def disconnect(self):
+        """Disconnect all event handlers to prevent memory leaks."""
+        for cid in self._cids:
+            self.canvas.canvas.mpl_disconnect(cid)
+        self._cids.clear()
 
     def _on_pick(self, event):
         """Handle pick event on a node."""
@@ -175,8 +191,10 @@ def _on_motion(self, event):
 
         # Update the position
         idx = self._drag_index
-        keys = list(self.positions.keys())
-        node_name = keys[idx]
+        if idx >= len(self._keys):
+            return
+
+        node_name = self._keys[idx]
 
         # Update stored position
         self.positions[node_name] = (event.xdata, event.ydata)
@@ -196,6 +214,9 @@ def _on_motion(self, event):
 
     def _select_node(self, index):
         """Select a node and its corresponding layer."""
+        if index >= len(self._keys):
+            return
+
         # Reset previous selection
         edgecolors = self.edgecolors.copy()
 
@@ -208,8 +229,7 @@ def _select_node(self, index):
         if self.viewer is None:
             return
 
-        keys = list(self.positions.keys())
-        node_name = keys[index]
+        node_name = self._keys[index]
 
         if node_name in self.viewer.layers:
             layer = self.viewer.layers[node_name]
@@ -225,19 +245,21 @@ def update_node_status(self, node_name: str, status: str):
         status : str
             One of 'valid', 'invalid', 'root', or 'leaf'.
         """
-        for idx, key in enumerate(self.positions.keys()):
-            if key == node_name:
-                facecolors = self.points.get_facecolors()
-                if status == 'invalid':
-                    facecolors[idx] = self.INVALID_COLOR
-                elif status == 'root':
-                    facecolors[idx] = self.ROOT_COLOR
-                elif status == 'leaf':
-                    facecolors[idx] = self.LEAF_COLOR
-                else:
-                    facecolors[idx] = self.VALID_COLOR
-                self.points.set_facecolors(facecolors)
-                break
+        try:
+            idx = self._keys.index(node_name)
+        except ValueError:
+            return
+
+        facecolors = self.points.get_facecolors()
+        if status == 'invalid':
+            facecolors[idx] = self.INVALID_COLOR
+        elif status == 'root':
+            facecolors[idx] = self.ROOT_COLOR
+        elif status == 'leaf':
+            facecolors[idx] = self.LEAF_COLOR
+        else:
+            facecolors[idx] = self.VALID_COLOR
+        self.points.set_facecolors(facecolors)
 
 
 class MatplotlibWidget(QWidget):
@@ -398,7 +420,10 @@ def _init_ui(self):
         # Spacer
         layout.addItem(
             QSpacerItem(
-                20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding
+                20,
+                40,
+                QSizePolicy.Policy.Minimum,
+                QSizePolicy.Policy.Expanding,
             )
         )
 
@@ -511,9 +536,35 @@ def _get_node_status(self, node_name: str, workflow) -> str:
         str
             One of 'root', 'leaf', 'invalid', or 'valid'.
         """
-        roots = workflow.roots()
-        leaves = workflow.leaves()
+        return self._get_node_status_cached(
+            node_name, workflow, workflow.roots(), workflow.leaves()
+        )
+
+    def _get_node_status_cached(
+        self,
+        node_name: str,
+        workflow,
+        roots: list[str],
+        leaves: list[str],
+    ) -> str:
+        """Determine the status of a node using cached roots/leaves.
+
+        Parameters
+        ----------
+        node_name : str
+            The name of the node/task.
+        workflow : Workflow
+            The workflow being inspected.
+        roots : list[str]
+            Cached list of root nodes.
+        leaves : list[str]
+            Cached list of leaf nodes.
 
+        Returns
+        -------
+        str
+            One of 'root', 'leaf', 'invalid', or 'valid'.
+        """
         # Check if it's a root (input)
         if node_name in roots:
             return 'root'
@@ -525,7 +576,7 @@ def _get_node_status(self, node_name: str, workflow) -> str:
         # In live mode, check for pending updates
         if self._use_live_mode:
             manager = self._get_manager()
-            if manager and node_name in manager._pending_updates:
+            if manager and manager.is_layer_pending(node_name):
                 return 'invalid'
 
             # Check if layer exists in viewer
@@ -540,7 +591,9 @@ def _update(self):
 
         if workflow is None or len(workflow) == 0:
             self.lbl_from_roots.setText('No workflow loaded or empty workflow')
-            self.lbl_from_leaves.setText('No workflow loaded or empty workflow')
+            self.lbl_from_leaves.setText(
+                'No workflow loaded or empty workflow'
+            )
             self.lbl_raw.setText('No workflow loaded or empty workflow')
             self.lbl_info.setText('Load a YAML file or enable live mode')
             if self.graph_widget is not None:
@@ -548,19 +601,19 @@ def _update(self):
                 self.graph_widget.canvas.draw()
             return
 
-        # Update tree views
+        # Cache roots and leaves to avoid repeated computation
         roots = workflow.roots()
+        leaves = workflow.leaves()
 
         # From Roots view
         roots_text = self._build_tree_html(
-            roots, workflow.followers_of, workflow
+            roots, workflow.followers_of, workflow, roots, leaves
         )
         self.lbl_from_roots.setText(self._wrap_html(roots_text))
 
         # From Leaves view
-        leaves = workflow.leaves()
         leaves_text = self._build_tree_html(
-            leaves, workflow.sources_of, workflow
+            leaves, workflow.sources_of, workflow, roots, leaves
         )
         self.lbl_from_leaves.setText(self._wrap_html(leaves_text))
 
@@ -568,7 +621,7 @@ def _update(self):
         self.lbl_raw.setText(repr(workflow))
 
         # Info view
-        info_text = self._build_info_text(workflow)
+        info_text = self._build_info_text(workflow, roots, leaves)
         self.lbl_info.setText(info_text)
 
         # Update graph (only if matplotlib available)
@@ -580,10 +633,15 @@ def _update(self):
             self._graph = new_graph
             self._draw_graph(workflow)
         else:
-            self._update_graph_colors(workflow)
+            self._update_graph_colors(workflow, roots, leaves)
 
     def _build_tree_html(
-        self, items: list[str], get_next: Callable, workflow
+        self,
+        items: list[str],
+        get_next: Callable,
+        workflow,
+        roots: list[str],
+        leaves: list[str],
     ) -> str:
         """Build an HTML tree representation.
 
@@ -595,12 +653,18 @@ def _build_tree_html(
             Function to get next items (followers_of or sources_of).
         workflow : Workflow
             The workflow object.
+        roots : list[str]
+            Cached list of root nodes.
+        leaves : list[str]
+            Cached list of leaf nodes.
 
         Returns
         -------
         str
             HTML string representing the tree.
         """
+        import html
+
         visited = set()
 
         def build(item_list: list[str], level: int = 0) -> str:
@@ -610,7 +674,9 @@ def build(item_list: list[str], level: int = 0) -> str:
                     continue
                 visited.add(item)
 
-                status = self._get_node_status(item, workflow)
+                status = self._get_node_status_cached(
+                    item, workflow, roots, leaves
+                )
                 color = {
                     'root': '#dddddd',  # Light gray
                     'leaf': '#5599ff',  # Light blue
@@ -619,7 +685,8 @@ def build(item_list: list[str], level: int = 0) -> str:
                 }.get(status, '#dddddd')
 
                 indent = '   ' * level
-                output += f'{indent}→ {item}
' + escaped_name = html.escape(item) + output += f'{indent}→ {escaped_name}
' next_items = get_next(item) if next_items: @@ -633,13 +700,19 @@ def _wrap_html(self, content: str) -> str: """Wrap content in HTML tags.""" return f'
{content}
' - def _build_info_text(self, workflow) -> str: + def _build_info_text( + self, workflow, roots: list[str], leaves: list[str] + ) -> str: """Build workflow info text. Parameters ---------- workflow : Workflow The workflow object. + roots : list[str] + Cached list of root nodes. + leaves : list[str] + Cached list of leaf nodes. Returns ------- @@ -658,23 +731,23 @@ def _build_info_text(self, workflow) -> str: # Workflow stats lines.append('─── Workflow Statistics ───') lines.append(f' Total tasks: {len(workflow)}') - lines.append(f' Roots (inputs): {len(workflow.roots())}') - lines.append(f' Leaves (outputs): {len(workflow.leaves())}') + lines.append(f' Roots (inputs): {len(roots)}') + lines.append(f' Leaves (outputs): {len(leaves)}') lines.append('') # Root details lines.append('─── Roots (Inputs) ───') - for root in workflow.roots(): + for root in roots: lines.append(f' • {root}') - if not workflow.roots(): + if not roots: lines.append(' (none)') lines.append('') # Leaf details lines.append('─── Leaves (Outputs) ───') - for leaf in workflow.leaves(): + for leaf in leaves: lines.append(f' • {leaf}') - if not workflow.leaves(): + if not leaves: lines.append(' (none)') lines.append('') @@ -708,8 +781,12 @@ def _build_info_text(self, workflow) -> str: lines.append('─── Live Mode Info ───') lines.append(f' Can undo: {undo_redo.can_undo}') lines.append(f' Can redo: {undo_redo.can_redo}') - lines.append(f' Undo stack: {undo_redo.undo_stack_size} states') - lines.append(f' Redo stack: {undo_redo.redo_stack_size} states') + lines.append( + f' Undo stack: {undo_redo.undo_stack_size} states' + ) + lines.append( + f' Redo stack: {undo_redo.redo_stack_size} states' + ) pending = manager.pending_updates if pending: lines.append(f' Pending updates: {pending}') @@ -748,10 +825,9 @@ def _graph_changed(self, new_graph) -> bool: """Check if the graph structure has changed.""" if self._graph is None: return True - return ( - set(self._graph.nodes) != set(new_graph.nodes) - or set(self._graph.edges) != set(new_graph.edges) - ) + return set(self._graph.nodes) != set(new_graph.nodes) or set( + self._graph.edges + ) != set(new_graph.edges) def _draw_graph(self, workflow): """Draw the workflow graph.""" @@ -766,6 +842,11 @@ def _draw_graph(self, workflow): ax.clear() ax.set_facecolor('#262930') + # Cleanup old graph drawing to prevent memory leaks + if self._graph_drawing is not None: + self._graph_drawing.disconnect() + self._graph_drawing = None + # Calculate positions try: self._positions = nx.drawing.layout.kamada_kawai_layout( @@ -868,18 +949,45 @@ def _on_node_positions_changed(self): self.graph_widget.canvas.draw() - def _update_graph_colors(self, workflow): - """Update node colors based on current status.""" + def _update_graph_colors( + self, + workflow, + roots: list[str] | None = None, + leaves: list[str] | None = None, + ): + """Update node colors based on current status. + + Parameters + ---------- + workflow : Workflow + The workflow object. + roots : list[str], optional + Cached list of root nodes. + leaves : list[str], optional + Cached list of leaf nodes. + """ if self._graph is None or self._graph_drawing is None: return + # Use cached values or compute if not provided + if roots is None: + roots = workflow.roots() + if leaves is None: + leaves = workflow.leaves() + for node in self._graph.nodes: - status = self._get_node_status(node, workflow) + status = self._get_node_status_cached( + node, workflow, roots, leaves + ) self._graph_drawing.update_node_status(node, status) self.graph_widget.canvas.draw() def closeEvent(self, a0): - """Stop the timer when closing.""" + """Stop the timer and cleanup when closing.""" self.timer.stop() + # Disconnect matplotlib event handlers to prevent memory leaks + if self._graph_drawing is not None: + self._graph_drawing.disconnect() + self._graph_drawing = None super().closeEvent(a0) diff --git a/tests/widgets/test_workflow_inspector.py b/tests/widgets/test_workflow_inspector.py index aec0a70..926f335 100644 --- a/tests/widgets/test_workflow_inspector.py +++ b/tests/widgets/test_workflow_inspector.py @@ -153,7 +153,10 @@ def my_func(x): save_workflow(yaml_file, workflow) inspector.load_workflow_file(yaml_file) - info = inspector._build_info_text(inspector._loaded_workflow) + loaded = inspector._loaded_workflow + info = inspector._build_info_text( + loaded, loaded.roots(), loaded.leaves() + ) assert 'Total tasks:' in info assert 'Roots' in info @@ -168,8 +171,8 @@ def test_empty_workflow_shows_message(self, inspector): class TestManagerStatusMethods: """Test status methods added to WorkflowManager.""" - def test_get_layer_status(self, make_napari_viewer): - """Test get_layer_status returns correct status.""" + def test_get_layer_status_returns_all_statuses(self, make_napari_viewer): + """Test get_layer_status returns correct status for all node types.""" from ndev_workflows._manager import WorkflowManager viewer = make_napari_viewer() @@ -178,10 +181,26 @@ def test_get_layer_status(self, make_napari_viewer): def identity(x): return x - manager.workflow.set('output', identity, 'input') + def process(a, b): + return a + b + # Build a workflow: input -> middle -> output + manager.workflow.set('middle', identity, 'input') + manager.workflow.set('output', process, 'middle', 'input') + + # Root status (input nodes) assert manager.get_layer_status('input') == 'root' + # Leaf status (output nodes) + assert manager.get_layer_status('output') == 'leaf' + + # Valid status (middle nodes not pending) + assert manager.get_layer_status('middle') == 'valid' + + # Invalid status (pending updates) - use public method to set state + manager.invalidate('middle') + assert manager.get_layer_status('middle') == 'invalid' + def test_is_layer_pending(self, make_napari_viewer): """Test is_layer_pending method.""" from ndev_workflows._manager import WorkflowManager @@ -189,8 +208,15 @@ def test_is_layer_pending(self, make_napari_viewer): viewer = make_napari_viewer() manager = WorkflowManager.install(viewer) - manager._pending_updates.append('test_layer') - assert manager.is_layer_pending('test_layer') is True + def identity(x): + return x + + # Set up a workflow step + manager.workflow.set('output', identity, 'input') + + # Use public invalidate() to mark as pending + manager.invalidate('output') + assert manager.is_layer_pending('output') is True assert manager.is_layer_pending('other') is False def test_pending_updates_property(self, make_napari_viewer): @@ -200,11 +226,16 @@ def test_pending_updates_property(self, make_napari_viewer): viewer = make_napari_viewer() manager = WorkflowManager.install(viewer) - manager._pending_updates.append('test') + def identity(x): + return x + + manager.workflow.set('test', identity, 'input') + manager.invalidate('test') + pending = manager.pending_updates - assert pending == ['test'] - # Should be a copy + assert 'test' in pending + # Should be a copy - modifying returned list shouldn't affect manager pending.append('modified') assert 'modified' not in manager.pending_updates From ab874931c1fece004a42f984542104efdf3995d8 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Tue, 23 Dec 2025 12:22:02 -0600 Subject: [PATCH 9/9] further slim API --- src/ndev_workflows/_manager.py | 22 --------- .../widgets/_workflow_inspector.py | 34 +------------ tests/widgets/test_workflow_inspector.py | 49 ++++++------------- 3 files changed, 18 insertions(+), 87 deletions(-) diff --git a/src/ndev_workflows/_manager.py b/src/ndev_workflows/_manager.py index 087a1cb..061e190 100644 --- a/src/ndev_workflows/_manager.py +++ b/src/ndev_workflows/_manager.py @@ -129,28 +129,6 @@ def is_layer_pending(self, name: str) -> bool: """ return name in self._pending_updates - def get_layer_status(self, name: str) -> str: - """Get the status of a layer/task. - - Parameters - ---------- - name : str - The task name to check. - - Returns - ------- - str - One of 'root', 'leaf', 'invalid', or 'valid'. - """ - if name in self._workflow.roots(): - return 'root' - elif name in self._workflow.leaves(): - return 'leaf' - elif name in self._pending_updates: - return 'invalid' - else: - return 'valid' - def update( self, target_layer: str | Layer, diff --git a/src/ndev_workflows/widgets/_workflow_inspector.py b/src/ndev_workflows/widgets/_workflow_inspector.py index 2d02479..0f6212b 100644 --- a/src/ndev_workflows/widgets/_workflow_inspector.py +++ b/src/ndev_workflows/widgets/_workflow_inspector.py @@ -328,7 +328,6 @@ def __init__(self, viewer: napari.viewer.Viewer): # Graph drawing elements (for dynamic updates when dragging) self._edge_collection = None self._label_texts = None - self._current_workflow = None # Workflow source: file or live manager self._workflow_file: Path | None = None @@ -521,29 +520,9 @@ def _get_manager(self): except (ImportError, AttributeError, RuntimeError): return None - def _get_node_status(self, node_name: str, workflow) -> str: - """Determine the status of a node. - - Parameters - ---------- - node_name : str - The name of the node/task. - workflow : Workflow - The workflow being inspected. - - Returns - ------- - str - One of 'root', 'leaf', 'invalid', or 'valid'. - """ - return self._get_node_status_cached( - node_name, workflow, workflow.roots(), workflow.leaves() - ) - def _get_node_status_cached( self, node_name: str, - workflow, roots: list[str], leaves: list[str], ) -> str: @@ -553,8 +532,6 @@ def _get_node_status_cached( ---------- node_name : str The name of the node/task. - workflow : Workflow - The workflow being inspected. roots : list[str] Cached list of root nodes. leaves : list[str] @@ -674,9 +651,7 @@ def build(item_list: list[str], level: int = 0) -> str: continue visited.add(item) - status = self._get_node_status_cached( - item, workflow, roots, leaves - ) + status = self._get_node_status_cached(item, roots, leaves) color = { 'root': '#dddddd', # Light gray 'leaf': '#5599ff', # Light blue @@ -856,9 +831,6 @@ def _draw_graph(self, workflow): # Fall back to spring layout if kamada_kawai fails self._positions = nx.spring_layout(self._graph) - # Store workflow reference for redraw callback - self._current_workflow = workflow - # Draw edges (store reference for redrawing) self._edge_collection = nx.draw_networkx_edges( self._graph, @@ -976,9 +948,7 @@ def _update_graph_colors( leaves = workflow.leaves() for node in self._graph.nodes: - status = self._get_node_status_cached( - node, workflow, roots, leaves - ) + status = self._get_node_status_cached(node, roots, leaves) self._graph_drawing.update_node_status(node, status) self.graph_widget.canvas.draw() diff --git a/tests/widgets/test_workflow_inspector.py b/tests/widgets/test_workflow_inspector.py index 926f335..68ce84f 100644 --- a/tests/widgets/test_workflow_inspector.py +++ b/tests/widgets/test_workflow_inspector.py @@ -132,12 +132,25 @@ def step(x): inspector.load_workflow_file(yaml_file) + # Get the loaded workflow's roots and leaves + loaded = inspector._loaded_workflow + roots = loaded.roots() + leaves = loaded.leaves() + # Root should be detected - assert inspector._get_node_status('input', workflow) == 'root' + assert ( + inspector._get_node_status_cached('input', roots, leaves) == 'root' + ) # Leaf should be detected - assert inspector._get_node_status('output', workflow) == 'leaf' + assert ( + inspector._get_node_status_cached('output', roots, leaves) + == 'leaf' + ) # Middle node should be valid (in file mode) - assert inspector._get_node_status('middle', workflow) == 'valid' + assert ( + inspector._get_node_status_cached('middle', roots, leaves) + == 'valid' + ) def test_info_text_contains_workflow_stats(self, inspector, tmp_path): """Test info text includes workflow statistics.""" @@ -171,36 +184,6 @@ def test_empty_workflow_shows_message(self, inspector): class TestManagerStatusMethods: """Test status methods added to WorkflowManager.""" - def test_get_layer_status_returns_all_statuses(self, make_napari_viewer): - """Test get_layer_status returns correct status for all node types.""" - from ndev_workflows._manager import WorkflowManager - - viewer = make_napari_viewer() - manager = WorkflowManager.install(viewer) - - def identity(x): - return x - - def process(a, b): - return a + b - - # Build a workflow: input -> middle -> output - manager.workflow.set('middle', identity, 'input') - manager.workflow.set('output', process, 'middle', 'input') - - # Root status (input nodes) - assert manager.get_layer_status('input') == 'root' - - # Leaf status (output nodes) - assert manager.get_layer_status('output') == 'leaf' - - # Valid status (middle nodes not pending) - assert manager.get_layer_status('middle') == 'valid' - - # Invalid status (pending updates) - use public method to set state - manager.invalidate('middle') - assert manager.get_layer_status('middle') == 'invalid' - def test_is_layer_pending(self, make_napari_viewer): """Test is_layer_pending method.""" from ndev_workflows._manager import WorkflowManager