diff --git a/README.md b/README.md index 49706fb..8c81a87 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ tono is a node-based SPM image processing and analysis tool. The main focus is o It is heavily inspired by [Gwyddion](https://gwyddion.net/), one of my favorite scientific FOSS programs on the web. + ## Quick start Install a local binary from the Releases section, or run locally: diff --git a/backend/execution_context.py b/backend/execution_context.py index 1c96725..757533c 100644 --- a/backend/execution_context.py +++ b/backend/execution_context.py @@ -2,7 +2,6 @@ from __future__ import annotations from contextlib import contextmanager from contextvars import ContextVar -import inspect from typing import Any, Callable Callback = Callable[[str, Any], None] @@ -13,16 +12,6 @@ _callbacks_var: ContextVar[dict[str, Callback | None]] = ContextVar( ) _node_id_var: ContextVar[str | None] = ContextVar("tono_execution_node_id", default=None) -_LEGACY_CALLBACK_ATTRS = { - "preview": "_broadcast_fn", - "table": "_broadcast_table_fn", - "mesh": "_broadcast_mesh_fn", - "overlay": "_broadcast_overlay_fn", - "value": "_broadcast_value_fn", - "warning": "_broadcast_warning_fn", - "file_download": "_broadcast_file_download_fn", -} - @contextmanager def execution_callbacks( @@ -63,42 +52,12 @@ def current_node_id() -> str | None: return _node_id_var.get() -def _legacy_emit(kind: str, payload: Any) -> bool: - callback_attr = _LEGACY_CALLBACK_ATTRS.get(kind) - if not callback_attr: - return False - - frame = inspect.currentframe() - try: - frame = frame.f_back - while frame is not None: - for owner_name in ("self", "cls"): - owner = frame.f_locals.get(owner_name) - if owner is None: - continue - - candidate = owner if isinstance(owner, type) else owner.__class__ - callback = getattr(candidate, callback_attr, None) - node_id = getattr(candidate, "_current_node_id", None) - if callback is not None and node_id: - callback(str(node_id), payload) - return True - frame = frame.f_back - finally: - del frame - - return False - - def _emit(kind: str, payload: Any) -> None: callbacks = _callbacks_var.get() callback = callbacks.get(kind) node_id = current_node_id() if callback is not None and node_id: callback(node_id, payload) - return - - _legacy_emit(kind, payload) def emit_preview(payload: Any) -> None: diff --git a/backend/nodes/annotations.py b/backend/nodes/annotations.py index d9b2af4..365ff9c 100644 --- a/backend/nodes/annotations.py +++ b/backend/nodes/annotations.py @@ -16,9 +16,6 @@ from backend.data_types import ( @register_node(display_name="Annotations") class Annotations: - _broadcast_warning_fn = None - _current_node_id: str = "" - @classmethod def INPUT_TYPES(cls): return { diff --git a/backend/nodes/crop_resize.py b/backend/nodes/crop_resize.py index 32e7b33..fd7f539 100644 --- a/backend/nodes/crop_resize.py +++ b/backend/nodes/crop_resize.py @@ -37,9 +37,6 @@ class CropResizeField: "resizing preserves the cropped physical size." ) - _broadcast_overlay_fn = None - _current_node_id: str = "" - def process( self, field: DataField, diff --git a/backend/nodes/cross_section.py b/backend/nodes/cross_section.py index 8ab7d05..41d3305 100644 --- a/backend/nodes/cross_section.py +++ b/backend/nodes/cross_section.py @@ -39,9 +39,6 @@ class CrossSection: "Equivalent to gwy_data_field_get_profile." ) - _broadcast_overlay_fn = None - _current_node_id: str = "" - def process( self, field: DataField, x1: float, y1: float, x2: float, y2: float, diff --git a/backend/nodes/cursors.py b/backend/nodes/cursors.py index b84b7a3..1b25e37 100644 --- a/backend/nodes/cursors.py +++ b/backend/nodes/cursors.py @@ -39,9 +39,6 @@ class Cursors: "On fields it reports x/y/z at both markers plus dx/dy/dz." ) - _broadcast_overlay_fn = None - _current_node_id: str = "" - def process( self, line, x1: float, y1: float, x2: float, y2: float, coord_pair=None, diff --git a/backend/nodes/fft_1d.py b/backend/nodes/fft_1d.py index f927ef8..a634122 100644 --- a/backend/nodes/fft_1d.py +++ b/backend/nodes/fft_1d.py @@ -30,9 +30,6 @@ class FFT1D: "Returns the FFT spectrum of the line, and identifies peaks." ) - _broadcast_overlay_fn = None - _current_node_id: str = "" - def process( self, profile, ) -> tuple: diff --git a/backend/nodes/helpers.py b/backend/nodes/helpers.py index 4a15a68..cf77bdc 100644 --- a/backend/nodes/helpers.py +++ b/backend/nodes/helpers.py @@ -122,84 +122,6 @@ def _measurement_value(table: list, selection: str) -> float: raise ValueError(f"Measurement '{row.get('quantity', selection)}' does not have a numeric value.") -# --------------------------------------------------------------------------- -# SI formatting helpers (from display.py — used by Annotations) -# --------------------------------------------------------------------------- - -_SI_PREFIXES = [ - (1e24, "Y"), (1e21, "Z"), (1e18, "E"), (1e15, "P"), (1e12, "T"), - (1e9, "G"), (1e6, "M"), (1e3, "k"), (1.0, ""), (1e-3, "m"), - (1e-6, "u"), (1e-9, "n"), (1e-12, "p"), (1e-15, "f"), - (1e-18, "a"), (1e-21, "z"), (1e-24, "y"), -] -_PREFIXABLE_UNITS = {"m", "s", "A", "V", "W", "Hz", "F", "C", "J", "N", "Pa", "T", "H", "S", "g", "K", "Ohm", "ohm", "\u03a9"} - - -def _format_numeric(value: float) -> str: - if not np.isfinite(value): - return str(value) - abs_value = abs(value) - if abs_value == 0: - return "0" - if abs_value >= 1e4 or abs_value < 1e-3: - return f"{value:.3e}" - return f"{value:.4g}" - - -def _format_with_unit(value: float, unit: str) -> str: - unit = (unit or "").strip() - if not unit: - return _format_numeric(value) - if unit in _PREFIXABLE_UNITS and np.isfinite(value) and value != 0: - abs_value = abs(value) - for scale, prefix in _SI_PREFIXES: - scaled = abs_value / scale - if 1 <= scaled < 1000: - signed = value / scale - return f"{_format_numeric(signed)} {prefix}{unit}" - return f"{_format_numeric(value)} {unit}" - - -def _nice_length(target: float) -> float: - if not np.isfinite(target) or target <= 0: - return 0.0 - exponent = np.floor(np.log10(target)) - base = 10.0 ** exponent - for step in (5.0, 2.0, 1.0): - candidate = step * base - if candidate <= target: - return candidate - return base - - -def _display_value_range(field) -> tuple[float, float, float]: - data = np.asarray(field.data, dtype=np.float64) - dmin = float(data.min()) - dmax = float(data.max()) - if not np.isfinite(dmin) or not np.isfinite(dmax) or dmax <= dmin: - return dmin, dmin, dmin - - offset = float(field.display_offset) - scale = float(field.display_scale) - if not np.isfinite(offset): - offset = 0.0 - if not np.isfinite(scale) or scale <= 0.0: - scale = 1.0 - - low_norm = float(np.clip(offset, 0.0, 1.0)) - high_norm = float(np.clip(offset + scale, 0.0, 1.0)) - if high_norm < low_norm: - low_norm, high_norm = high_norm, low_norm - mid_norm = 0.5 * (low_norm + high_norm) - - span = dmax - dmin - return ( - dmin + low_norm * span, - dmin + mid_norm * span, - dmin + high_norm * span, - ) - - def _render_annotation_text(text: str, size_px: int, color: tuple[int, int, int]): from PIL import Image, ImageDraw, ImageFont diff --git a/backend/nodes/histogram.py b/backend/nodes/histogram.py index 897472b..2b6bd30 100644 --- a/backend/nodes/histogram.py +++ b/backend/nodes/histogram.py @@ -34,9 +34,6 @@ class Histogram: "Equivalent to gwy_data_field_dh." ) - _broadcast_overlay_fn = None - _current_node_id: str = "" - def process( self, field: DataField, diff --git a/backend/nodes/image.py b/backend/nodes/image.py index 59bd7bd..68439b7 100644 --- a/backend/nodes/image.py +++ b/backend/nodes/image.py @@ -36,9 +36,6 @@ class Image: "Images (.png, .tiff, .jpg) and arrays (.npy, .npz) are loaded as uncalibrated fields." ) - _broadcast_warning_fn = None - _current_node_id = None - def load(self, filename: str = "", colormap: str = "viridis", colormap_map=None, path: str | None = None): selected_path = str(path).strip() if path is not None else str(filename).strip() if not selected_path: diff --git a/backend/nodes/image_demo.py b/backend/nodes/image_demo.py index 7137773..9710810 100644 --- a/backend/nodes/image_demo.py +++ b/backend/nodes/image_demo.py @@ -26,9 +26,6 @@ class ImageDemo: DESCRIPTION = "Load a bundled demo file so you can try the app without providing your own data." - _broadcast_warning_fn = None - _current_node_id = None - def load(self, name: str = "", colormap: str = "viridis", colormap_map=None): from backend.nodes.image import Image loader = Image() diff --git a/backend/nodes/markup.py b/backend/nodes/markup.py index f14b0aa..cf45c6b 100644 --- a/backend/nodes/markup.py +++ b/backend/nodes/markup.py @@ -43,9 +43,6 @@ class Markup: "or rasterize markup directly onto an IMAGE." ) - _broadcast_overlay_fn = None - _current_node_id: str = "" - def process( self, input, diff --git a/backend/nodes/mask_draw.py b/backend/nodes/mask_draw.py index e88c11f..3ca27c4 100644 --- a/backend/nodes/mask_draw.py +++ b/backend/nodes/mask_draw.py @@ -33,9 +33,6 @@ class DrawMask: "and invert flips the final binary output." ) - _broadcast_overlay_fn = None - _current_node_id: str = "" - def process(self, field: DataField, pen_size: int, invert: bool, mask_paths: str) -> tuple: strokes = _parse_mask_strokes(mask_paths) mask = _rasterize_mask(field.xres, field.yres, strokes, pen_size) diff --git a/backend/nodes/mask_invert.py b/backend/nodes/mask_invert.py index 474797f..c0028da 100644 --- a/backend/nodes/mask_invert.py +++ b/backend/nodes/mask_invert.py @@ -28,9 +28,6 @@ class MaskInvert: DESCRIPTION = "Invert a binary mask — swap masked and unmasked regions." - _broadcast_fn = None - _current_node_id: str = "" - def process(self, mask: np.ndarray, field: DataField | None = None) -> tuple: out = np.where(mask > 127, np.uint8(0), np.uint8(255)) diff --git a/backend/nodes/mask_morphology.py b/backend/nodes/mask_morphology.py index 2c7c363..89dfd46 100644 --- a/backend/nodes/mask_morphology.py +++ b/backend/nodes/mask_morphology.py @@ -41,9 +41,6 @@ class MaskMorphology: "Equivalent to Gwyddion mask_morph." ) - _broadcast_fn = None - _current_node_id: str = "" - def process(self, mask: np.ndarray, operation: str, radius: int, shape: str, field: DataField | None = None) -> tuple: from scipy.ndimage import binary_closing, binary_dilation, binary_erosion, binary_opening diff --git a/backend/nodes/mask_threshold.py b/backend/nodes/mask_threshold.py index 75b1639..90bf2e9 100644 --- a/backend/nodes/mask_threshold.py +++ b/backend/nodes/mask_threshold.py @@ -33,9 +33,6 @@ class ThresholdMask: "Equivalent to Gwyddion's threshold and otsu_threshold modules." ) - _broadcast_fn = None - _current_node_id: str = "" - def process(self, field: DataField, method: str, threshold: float, direction: str) -> tuple: data = field.data diff --git a/backend/nodes/preview_image.py b/backend/nodes/preview_image.py index bfcd9c4..d9c096b 100644 --- a/backend/nodes/preview_image.py +++ b/backend/nodes/preview_image.py @@ -36,9 +36,6 @@ class PreviewImage: OUTPUT_NODE = True DESCRIPTION = "Display an IMAGE or DATA_FIELD as a coloured thumbnail." - _broadcast_fn = None - _current_node_id: str = "" - def preview( self, colormap: str, diff --git a/backend/nodes/print_table.py b/backend/nodes/print_table.py index e6caf53..217f366 100644 --- a/backend/nodes/print_table.py +++ b/backend/nodes/print_table.py @@ -21,9 +21,6 @@ class PrintTable: OUTPUT_NODE = True DESCRIPTION = "Send a measurement or record table to the browser as a WebSocket message for display." - _broadcast_table_fn = None - _current_node_id: str = "" - def print_table(self, table: list) -> tuple: emit_table(table) return () diff --git a/backend/nodes/rotate.py b/backend/nodes/rotate.py index 866255c..535d94a 100644 --- a/backend/nodes/rotate.py +++ b/backend/nodes/rotate.py @@ -28,9 +28,6 @@ class RotateField: "Optionally expand the canvas to keep the full rotated field while preserving the field center." ) - _broadcast_warning_fn = None - _current_node_id: str = "" - def process( self, field: DataField, diff --git a/backend/nodes/save.py b/backend/nodes/save.py index 51bcdbf..1f68876 100644 --- a/backend/nodes/save.py +++ b/backend/nodes/save.py @@ -74,9 +74,6 @@ class Save: "Save a single graph value to disk. Supports fields, images, lines, tables, scalars, and 3D meshes." ) - _broadcast_warning_fn = None - _current_node_id = None - def save( self, filename: str, diff --git a/backend/nodes/save_layers.py b/backend/nodes/save_layers.py index 022c883..36bf274 100644 --- a/backend/nodes/save_layers.py +++ b/backend/nodes/save_layers.py @@ -63,9 +63,6 @@ class SaveImage: "Click Save to write (does not auto-run)." ) - _broadcast_warning_fn = None - _current_node_id = None - def save( self, filename: str, @@ -187,5 +184,3 @@ class SaveImage: def _send_warning(self, message: str): emit_warning(message) - - return () diff --git a/backend/nodes/stats.py b/backend/nodes/stats.py index 05c83c1..db6df85 100644 --- a/backend/nodes/stats.py +++ b/backend/nodes/stats.py @@ -19,9 +19,6 @@ from backend.nodes.helpers import ( class Stats: """Polymorphic scalar stats node for LINE, DATA_TABLE, DATA_FIELD, or IMAGE inputs.""" - _broadcast_value_fn = None - _current_node_id: str = "" - @classmethod def INPUT_TYPES(cls): return { diff --git a/backend/nodes/value_io.py b/backend/nodes/value_io.py index 67155ab..606f3cb 100644 --- a/backend/nodes/value_io.py +++ b/backend/nodes/value_io.py @@ -41,9 +41,6 @@ class ValueIO: DESCRIPTION = "Display a FLOAT, or a selected numeric row from a measurement table, and pass the value through unchanged." - _broadcast_value_fn = None - _current_node_id: str = "" - def display_value(self, number_input: str = "0", value=None, measurement: str = "") -> tuple: unit = "" if isinstance(value, RecordTable): diff --git a/backend/nodes/view_3d.py b/backend/nodes/view_3d.py index 86c4a41..cebb7bf 100644 --- a/backend/nodes/view_3d.py +++ b/backend/nodes/view_3d.py @@ -148,9 +148,6 @@ class View3D: "Drag to rotate, middle-drag to pan, and right-drag or scroll to zoom. z_scale exaggerates height." ) - _broadcast_mesh_fn = None - _current_node_id: str = "" - def render( self, field: DataField, colormap: str, z_scale: float, resolution: int, make_solid: bool = False, diff --git a/frontend/src/canvasInteractionTargets.ts b/frontend/src/canvasInteractionTargets.ts deleted file mode 100644 index 2af3f98..0000000 --- a/frontend/src/canvasInteractionTargets.ts +++ /dev/null @@ -1,50 +0,0 @@ -const EXCLUDED_CANVAS_TARGETS = '.context-menu, .react-flow__node, .react-flow__edge, .react-flow__controls, .react-flow__minimap, .surface-view-container'; -const CANVAS_AREA_TARGETS = '.react-flow, .react-flow__renderer, .react-flow__viewport, .react-flow__pane, .react-flow__background, .react-flow__selectionpane'; - -function getTargetElement(target: EventTarget | null): Element | null { - if (!target) return null; - if (typeof (target as Element).closest === 'function') return target as Element; - const parent = (target as Node).parentElement; - if (parent && typeof parent.closest === 'function') { - return parent; - } - return null; -} - -function supportsClosest(target: EventTarget | null): boolean { - return !!getTargetElement(target); -} - -function matchesClosest(target: EventTarget | null, selector: string): boolean { - const element = getTargetElement(target); - return !!element && element.closest(selector) !== null; -} - -export function isEditableInteractionTarget(target: EventTarget | null): boolean { - if (!supportsClosest(target)) return false; - if (matchesClosest(target, 'input, textarea, select')) return true; - return matchesClosest(target, '[contenteditable="true"]'); -} - -export function canStartCanvasRightDragZoomTarget(target: EventTarget | null): boolean { - if (!supportsClosest(target)) return false; - if (isEditableInteractionTarget(target)) return false; - if (matchesClosest(target, EXCLUDED_CANVAS_TARGETS)) { - return false; - } - return matchesClosest(target, CANVAS_AREA_TARGETS); -} - -export function canOpenCanvasContextMenuTarget(target: EventTarget | null): boolean { - if (!supportsClosest(target)) return false; - if (isEditableInteractionTarget(target)) return false; - if (matchesClosest(target, EXCLUDED_CANVAS_TARGETS)) { - return false; - } - return matchesClosest(target, CANVAS_AREA_TARGETS); -} - -export function isSecondaryCanvasContextEvent(event: MouseEvent | null): boolean { - if (!event || typeof event.button !== 'number') return false; - return event.button === 2 || (event.button === 0 && !!event.ctrlKey); -} diff --git a/frontend/tests/canvasInteractionTargets.test.mjs b/frontend/tests/canvasInteractionTargets.test.mjs deleted file mode 100644 index fd48167..0000000 --- a/frontend/tests/canvasInteractionTargets.test.mjs +++ /dev/null @@ -1,49 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -import { - canOpenCanvasContextMenuTarget, - canStartCanvasRightDragZoomTarget, - isEditableInteractionTarget, - isSecondaryCanvasContextEvent, -} from '../src/canvasInteractionTargets.ts'; - -function makeTarget(activeSelectors = []) { - const selectorSet = new Set(activeSelectors); - return { - closest(selector) { - const parts = String(selector).split(',').map((part) => part.trim()); - return parts.some((part) => selectorSet.has(part)) ? {} : null; - }, - }; -} - -test('editable canvas targets stay editable', () => { - const inputTarget = makeTarget(['input']); - assert.equal(isEditableInteractionTarget(inputTarget), true); - assert.equal(canOpenCanvasContextMenuTarget(inputTarget), false); - assert.equal(canStartCanvasRightDragZoomTarget(inputTarget), false); -}); - -test('empty pane targets allow the custom canvas context menu', () => { - const paneTarget = makeTarget(['.react-flow__pane']); - assert.equal(canOpenCanvasContextMenuTarget(paneTarget), true); - assert.equal(canStartCanvasRightDragZoomTarget(paneTarget), true); -}); - -test('viewport-level targets also allow the custom canvas context menu', () => { - const viewportTarget = makeTarget(['.react-flow__viewport']); - assert.equal(canOpenCanvasContextMenuTarget(viewportTarget), true); - assert.equal(canStartCanvasRightDragZoomTarget(viewportTarget), true); -}); - -test('node and surface-view targets do not open the canvas context menu', () => { - assert.equal(canOpenCanvasContextMenuTarget(makeTarget(['.react-flow__node', '.react-flow__pane'])), false); - assert.equal(canOpenCanvasContextMenuTarget(makeTarget(['.surface-view-container', '.react-flow__pane'])), false); -}); - -test('secondary canvas context detection includes macOS ctrl-click', () => { - assert.equal(isSecondaryCanvasContextEvent({ button: 2, ctrlKey: false }), true); - assert.equal(isSecondaryCanvasContextEvent({ button: 0, ctrlKey: true }), true); - assert.equal(isSecondaryCanvasContextEvent({ button: 0, ctrlKey: false }), false); -}); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index a5fc152..dde9815 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -5,10 +5,6 @@ export default defineConfig({ plugins: [react()], server: { host: true, - allowedHosts: ["bronchial-lorita-gorgeously.ngrok-free.dev"], - hmr:{ - clientPort: 80, - }, port: 5173, proxy: { '/nodes': 'http://127.0.0.1:8188', diff --git a/tests/node_tests/crop_resize.py b/tests/node_tests/crop_resize.py index b9a9580..a9604dd 100644 --- a/tests/node_tests/crop_resize.py +++ b/tests/node_tests/crop_resize.py @@ -1,5 +1,6 @@ import numpy as np from backend.data_types import DataField +from backend.execution_context import execution_callbacks, active_node def test_crop_resize_field(): @@ -19,42 +20,38 @@ def test_crop_resize_field(): ) overlays = [] - CropResizeField._broadcast_overlay_fn = lambda nid, data: overlays.append(data) - CropResizeField._current_node_id = "test" + with execution_callbacks(overlay=lambda nid, data: overlays.append(data)), active_node("test"): + cropped, = node.process(field, x1=0.25, y1=0.25, x2=0.75, y2=1.0, target_width=0, target_height=0, interpolation="bilinear") + assert cropped.data.shape == (3, 4) + assert np.array_equal(cropped.data, data[1:4, 2:6]) + assert cropped.xreal == 4.0 + assert cropped.yreal == 3.0 + assert cropped.xoff == 12.0 + assert cropped.yoff == 21.0 + assert cropped.si_unit_xy == field.si_unit_xy + assert cropped.si_unit_z == field.si_unit_z + assert cropped.overlays == [] + assert len(overlays) == 1 + assert overlays[0]["kind"] == "crop_box" + assert overlays[0]["image"].startswith("data:image/png;base64,") + assert overlays[0]["a_locked"] is False + assert overlays[0]["b_locked"] is False - cropped, = node.process(field, x1=0.25, y1=0.25, x2=0.75, y2=1.0, target_width=0, target_height=0, interpolation="bilinear") - assert cropped.data.shape == (3, 4) - assert np.array_equal(cropped.data, data[1:4, 2:6]) - assert cropped.xreal == 4.0 - assert cropped.yreal == 3.0 - assert cropped.xoff == 12.0 - assert cropped.yoff == 21.0 - assert cropped.si_unit_xy == field.si_unit_xy - assert cropped.si_unit_z == field.si_unit_z - assert cropped.overlays == [] - assert len(overlays) == 1 - assert overlays[0]["kind"] == "crop_box" - assert overlays[0]["image"].startswith("data:image/png;base64,") - assert overlays[0]["a_locked"] is False - assert overlays[0]["b_locked"] is False + resized, = node.process(field, x1=0.0, y1=0.0, x2=1.0, y2=1.0, target_width=8, target_height=0, interpolation="bilinear", corner_a=(0.25, 0.25), corner_b=(0.75, 1.0)) + assert resized.data.shape == (6, 8) + assert resized.xreal == cropped.xreal + assert resized.yreal == cropped.yreal + assert resized.xoff == cropped.xoff + assert resized.yoff == cropped.yoff + assert resized.domain == field.domain + assert overlays[-1]["a_locked"] is True + assert overlays[-1]["b_locked"] is True - resized, = node.process(field, x1=0.0, y1=0.0, x2=1.0, y2=1.0, target_width=8, target_height=0, interpolation="bilinear", corner_a=(0.25, 0.25), corner_b=(0.75, 1.0)) - assert resized.data.shape == (6, 8) - assert resized.xreal == cropped.xreal - assert resized.yreal == cropped.yreal - assert resized.xoff == cropped.xoff - assert resized.yoff == cropped.yoff - assert resized.domain == field.domain - assert overlays[-1]["a_locked"] is True - assert overlays[-1]["b_locked"] is True + reversed_crop, = node.process(field, x1=0.75, y1=1.0, x2=0.25, y2=0.25, target_width=0, target_height=0, interpolation="nearest") + assert np.array_equal(reversed_crop.data, cropped.data) - reversed_crop, = node.process(field, x1=0.75, y1=1.0, x2=0.25, y2=0.25, target_width=0, target_height=0, interpolation="nearest") - assert np.array_equal(reversed_crop.data, cropped.data) - - try: - node.process(field, x1=0.9, y1=0.0, x2=0.9, y2=1.0, target_width=0, target_height=0, interpolation="nearest") - raise AssertionError("Expected invalid crop bounds to raise ValueError") - except ValueError: - pass - - CropResizeField._broadcast_overlay_fn = None + try: + node.process(field, x1=0.9, y1=0.0, x2=0.9, y2=1.0, target_width=0, target_height=0, interpolation="nearest") + raise AssertionError("Expected invalid crop bounds to raise ValueError") + except ValueError: + pass diff --git a/tests/node_tests/cross_section.py b/tests/node_tests/cross_section.py index a7bb7f6..f320d37 100644 --- a/tests/node_tests/cross_section.py +++ b/tests/node_tests/cross_section.py @@ -42,11 +42,10 @@ def test_cross_section(): assert rows["dx"]["unit"] == field.si_unit_xy assert rows["dy"]["unit"] == field.si_unit_z + from backend.execution_context import execution_callbacks, active_node captured = [] - Stats._broadcast_value_fn = lambda nid, payload: captured.append(payload) - Stats._current_node_id = "test" - stats = Stats() - mean_value, = stats.process(profile, operation="mean", column="value") - assert mean_value > 0 - assert captured[-1]["unit"] == field.si_unit_z - Stats._broadcast_value_fn = None + with execution_callbacks(value=lambda nid, payload: captured.append(payload)), active_node("test"): + stats = Stats() + mean_value, = stats.process(profile, operation="mean", column="value") + assert mean_value > 0 + assert captured[-1]["unit"] == field.si_unit_z diff --git a/tests/node_tests/cursors.py b/tests/node_tests/cursors.py index da0c058..4a1a8f3 100644 --- a/tests/node_tests/cursors.py +++ b/tests/node_tests/cursors.py @@ -12,53 +12,50 @@ def test_line_cursors(): line = np.linspace(0, 10, 100).astype(np.float64) + from backend.execution_context import execution_callbacks, active_node overlays = [] - Cursors._broadcast_overlay_fn = lambda nid, data: overlays.append(data) - Cursors._current_node_id = "test" + with execution_callbacks(overlay=lambda nid, data: overlays.append(data)), active_node("test"): + table, coord_pair = node.process(line, x1=0.25, y1=0.5, x2=0.75, y2=0.5) + assert isinstance(coord_pair, tuple) and len(coord_pair) == 2 + assert len(table) == 7 + quantities = {row["quantity"] for row in table} + assert "Length" in quantities + assert "A x" in quantities + assert "B x" in quantities + assert "dx" in quantities + assert "dy" in quantities - table, coord_pair = node.process(line, x1=0.25, y1=0.5, x2=0.75, y2=0.5) - assert isinstance(coord_pair, tuple) and len(coord_pair) == 2 - assert len(table) == 7 - quantities = {row["quantity"] for row in table} - assert "Length" in quantities - assert "A x" in quantities - assert "B x" in quantities - assert "dx" in quantities - assert "dy" in quantities + a_pos = next(r["value"] for r in table if r["quantity"] == "A x") + b_pos = next(r["value"] for r in table if r["quantity"] == "B x") + assert b_pos > a_pos - a_pos = next(r["value"] for r in table if r["quantity"] == "A x") - b_pos = next(r["value"] for r in table if r["quantity"] == "B x") - assert b_pos > a_pos + dy = next(r["value"] for r in table if r["quantity"] == "dy") + assert dy > 0 - dy = next(r["value"] for r in table if r["quantity"] == "dy") - assert dy > 0 + assert len(overlays) == 1 + assert overlays[0]["kind"] == "line_plot" + assert len(overlays[0]["line"]) == len(line) + assert len(overlays[0]["x_axis"]) == len(line) + assert 0.0 <= overlays[0]["x1"] <= 1.0 + assert 0.0 <= overlays[0]["x2"] <= 1.0 - assert len(overlays) == 1 - assert overlays[0]["kind"] == "line_plot" - assert len(overlays[0]["line"]) == len(line) - assert len(overlays[0]["x_axis"]) == len(line) - assert 0.0 <= overlays[0]["x1"] <= 1.0 - assert 0.0 <= overlays[0]["x2"] <= 1.0 + line_data = LineData(data=line, x_axis=np.linspace(0, 1, 100)) + table2, _ = node.process(line_data, x1=0.25, y1=0.5, x2=0.75, y2=0.5) + assert len(table2) == 7 - line_data = LineData(data=line, x_axis=np.linspace(0, 1, 100)) - table2, _ = node.process(line_data, x1=0.25, y1=0.5, x2=0.75, y2=0.5) - assert len(table2) == 7 - - field = DataField( - data=np.arange(100, dtype=np.float64).reshape(10, 10), - xreal=2.0, yreal=4.0, si_unit_xy="um", si_unit_z="nm", - ) - overlays.clear() - table3, _ = node.process(field, x1=0.2, y1=0.25, x2=0.7, y2=0.75) - assert len(table3) == 9 - field_rows = {row["quantity"]: row for row in table3} - assert field_rows["dx"]["unit"] == "um" - assert field_rows["dy"]["unit"] == "um" - assert field_rows["dz"]["unit"] == "nm" - assert np.isclose(field_rows["dx"]["value"], 1.0) - assert np.isclose(field_rows["dy"]["value"], 2.0) - assert len(overlays) == 1 - assert overlays[0]["kind"] == "cursor_points" - assert overlays[0]["image"].startswith("data:image/png;base64,") - - Cursors._broadcast_overlay_fn = None + field = DataField( + data=np.arange(100, dtype=np.float64).reshape(10, 10), + xreal=2.0, yreal=4.0, si_unit_xy="um", si_unit_z="nm", + ) + overlays.clear() + table3, _ = node.process(field, x1=0.2, y1=0.25, x2=0.7, y2=0.75) + assert len(table3) == 9 + field_rows = {row["quantity"]: row for row in table3} + assert field_rows["dx"]["unit"] == "um" + assert field_rows["dy"]["unit"] == "um" + assert field_rows["dz"]["unit"] == "nm" + assert np.isclose(field_rows["dx"]["value"], 1.0) + assert np.isclose(field_rows["dy"]["value"], 2.0) + assert len(overlays) == 1 + assert overlays[0]["kind"] == "cursor_points" + assert overlays[0]["image"].startswith("data:image/png;base64,") diff --git a/tests/node_tests/helpers.py b/tests/node_tests/helpers.py index 2cb997b..7664fde 100644 --- a/tests/node_tests/helpers.py +++ b/tests/node_tests/helpers.py @@ -109,7 +109,7 @@ def test_measurement_value_errors(): def test_format_with_unit(): - from backend.nodes.helpers import _format_with_unit, _format_numeric + from backend.data_types import _format_with_unit, _format_numeric assert _format_numeric(0.0) == "0" assert not np.isfinite(float('inf')) or _format_numeric(float('inf')) is not None @@ -182,7 +182,7 @@ def test_square_unit_and_apply(): def test_nice_length(): - from backend.nodes.helpers import _nice_length + from backend.data_types import _nice_length assert _nice_length(0.0) == 0.0 assert _nice_length(float('inf')) == 0.0 diff --git a/tests/node_tests/histogram.py b/tests/node_tests/histogram.py index 4539c5c..17ae182 100644 --- a/tests/node_tests/histogram.py +++ b/tests/node_tests/histogram.py @@ -9,32 +9,29 @@ def test_height_histogram(): data = np.linspace(0, 1, 1000).reshape(25, 40) field = make_field(data=data) + from backend.execution_context import execution_callbacks, active_node overlays = [] - Histogram._broadcast_overlay_fn = lambda nid, data: overlays.append(data) - Histogram._current_node_id = "test" - - table, coord_pair = node.process(field, n_bins=10, y_scale="linear", x1=0.2, y1=0.5, x2=0.8, y2=0.5) - assert isinstance(coord_pair, tuple) and len(coord_pair) == 2 - measurements = {row["quantity"]: row for row in table} - assert "A position" in measurements - assert "A count" in measurements - assert "B position" in measurements - assert "B count" in measurements - assert "delta X" in measurements - assert "delta Y" in measurements - assert measurements["A count"]["unit"] == "count" - assert measurements["B count"]["unit"] == "count" - assert measurements["B position"]["value"] > measurements["A position"]["value"] - assert len(overlays) == 1 - assert overlays[0]["kind"] == "line_plot" - assert overlays[0]["section_title"] == "Histogram" - assert len(overlays[0]["line"]) == 10 - assert len(overlays[0]["x_axis"]) == 10 - assert np.isclose(overlays[0]["x1"], 0.2) - assert np.isclose(overlays[0]["x2"], 0.8) - assert np.isclose( - measurements["delta Y"]["value"], - measurements["B count"]["value"] - measurements["A count"]["value"], - ) - - Histogram._broadcast_overlay_fn = None + with execution_callbacks(overlay=lambda nid, data: overlays.append(data)), active_node("test"): + table, coord_pair = node.process(field, n_bins=10, y_scale="linear", x1=0.2, y1=0.5, x2=0.8, y2=0.5) + assert isinstance(coord_pair, tuple) and len(coord_pair) == 2 + measurements = {row["quantity"]: row for row in table} + assert "A position" in measurements + assert "A count" in measurements + assert "B position" in measurements + assert "B count" in measurements + assert "delta X" in measurements + assert "delta Y" in measurements + assert measurements["A count"]["unit"] == "count" + assert measurements["B count"]["unit"] == "count" + assert measurements["B position"]["value"] > measurements["A position"]["value"] + assert len(overlays) == 1 + assert overlays[0]["kind"] == "line_plot" + assert overlays[0]["section_title"] == "Histogram" + assert len(overlays[0]["line"]) == 10 + assert len(overlays[0]["x_axis"]) == 10 + assert np.isclose(overlays[0]["x1"], 0.2) + assert np.isclose(overlays[0]["x2"], 0.8) + assert np.isclose( + measurements["delta Y"]["value"], + measurements["B count"]["value"] - measurements["A count"]["value"], + ) diff --git a/tests/node_tests/image.py b/tests/node_tests/image.py index 13d0f1b..7358b87 100644 --- a/tests/node_tests/image.py +++ b/tests/node_tests/image.py @@ -126,12 +126,13 @@ def test_load_file_unsupported(): def test_load_file_warning(): from backend.nodes.image import Image as ImageNode + from backend.execution_context import execution_callbacks, active_node node = ImageNode() warnings = [] - ImageNode._broadcast_warning_fn = lambda nid, msg: warnings.append(msg) - ImageNode._current_node_id = "test" - with tempfile.TemporaryDirectory() as tmpdir: + with tempfile.TemporaryDirectory() as tmpdir, \ + execution_callbacks(warning=lambda nid, msg: warnings.append(msg)), \ + active_node("test"): arr = np.random.default_rng(10).integers(0, 256, (16, 16), dtype=np.uint8) img = PILImage.fromarray(arr) path = os.path.join(tmpdir, "test.png") @@ -142,8 +143,6 @@ def test_load_file_warning(): assert len(warnings) == 1 assert "Uncalibrated" in warnings[0] - ImageNode._broadcast_warning_fn = None - def test_load_file_ibw(): from backend.nodes.image import Image diff --git a/tests/node_tests/mask_draw.py b/tests/node_tests/mask_draw.py index 0e812f0..bff9605 100644 --- a/tests/node_tests/mask_draw.py +++ b/tests/node_tests/mask_draw.py @@ -8,33 +8,30 @@ def test_draw_mask(): node = DrawMask() field = make_field(data=np.zeros((32, 32), dtype=np.float64)) + from backend.execution_context import execution_callbacks, active_node overlays = [] - DrawMask._broadcast_overlay_fn = lambda nid, data: overlays.append(data) - DrawMask._current_node_id = "test" + with execution_callbacks(overlay=lambda nid, data: overlays.append(data)), active_node("test"): + mask_paths = [{"size": 5, "points": [{"x": 0.2, "y": 0.5}, {"x": 0.8, "y": 0.5}]}] - mask_paths = [{"size": 5, "points": [{"x": 0.2, "y": 0.5}, {"x": 0.8, "y": 0.5}]}] + mask, = node.process(field, pen_size=2, invert=False, mask_paths=json.dumps(mask_paths)) + assert mask.dtype == np.uint8 + assert mask.shape == (32, 32) + assert mask[16, 16] == 255 + assert mask[14, 16] == 255 + assert mask[0, 0] == 0 - mask, = node.process(field, pen_size=2, invert=False, mask_paths=json.dumps(mask_paths)) - assert mask.dtype == np.uint8 - assert mask.shape == (32, 32) - assert mask[16, 16] == 255 - assert mask[14, 16] == 255 - assert mask[0, 0] == 0 + assert len(overlays) == 1 + assert overlays[0]["kind"] == "mask_paint" + assert overlays[0]["section_title"] == "Mask" + assert overlays[0]["image"].startswith("data:image/png;base64,") + assert overlays[0]["image_width"] == field.xres + assert overlays[0]["image_height"] == field.yres + assert overlays[0]["invert"] is False - assert len(overlays) == 1 - assert overlays[0]["kind"] == "mask_paint" - assert overlays[0]["section_title"] == "Mask" - assert overlays[0]["image"].startswith("data:image/png;base64,") - assert overlays[0]["image_width"] == field.xres - assert overlays[0]["image_height"] == field.yres - assert overlays[0]["invert"] is False + inverted, = node.process(field, pen_size=2, invert=True, mask_paths=json.dumps(mask_paths)) + assert inverted[16, 16] == 0 + assert inverted[0, 0] == 255 + assert overlays[-1]["invert"] is True - inverted, = node.process(field, pen_size=2, invert=True, mask_paths=json.dumps(mask_paths)) - assert inverted[16, 16] == 0 - assert inverted[0, 0] == 255 - assert overlays[-1]["invert"] is True - - cleared, = node.process(field, pen_size=12, invert=False, mask_paths="[]") - assert np.count_nonzero(cleared) == 0 - - DrawMask._broadcast_overlay_fn = None + cleared, = node.process(field, pen_size=12, invert=False, mask_paths="[]") + assert np.count_nonzero(cleared) == 0 diff --git a/tests/node_tests/mask_threshold.py b/tests/node_tests/mask_threshold.py index 234d361..01c2218 100644 --- a/tests/node_tests/mask_threshold.py +++ b/tests/node_tests/mask_threshold.py @@ -10,30 +10,27 @@ def test_threshold_mask(): data[:, 32:] = 1.0 field = make_field(data=data) + from backend.execution_context import execution_callbacks, active_node previews = [] - ThresholdMask._broadcast_fn = lambda nid, uri: previews.append(uri) - ThresholdMask._current_node_id = "test" + with execution_callbacks(preview=lambda nid, uri: previews.append(uri)), active_node("test"): + mask, table = node.process(field, method="absolute", threshold=0.5, direction="above") + assert mask.dtype == np.uint8 + assert mask.shape == (64, 64) + assert np.all(mask[:, :32] == 0) + assert np.all(mask[:, 32:] == 255) - mask, table = node.process(field, method="absolute", threshold=0.5, direction="above") - assert mask.dtype == np.uint8 - assert mask.shape == (64, 64) - assert np.all(mask[:, :32] == 0) - assert np.all(mask[:, 32:] == 255) + assert len(previews) == 1 + assert previews[0].startswith("data:image/png;base64,") - assert len(previews) == 1 - assert previews[0].startswith("data:image/png;base64,") + mask_below, _ = node.process(field, method="absolute", threshold=0.5, direction="below") + assert np.all(mask_below[:, :32] == 255) + assert np.all(mask_below[:, 32:] == 0) - mask_below, _ = node.process(field, method="absolute", threshold=0.5, direction="below") - assert np.all(mask_below[:, :32] == 255) - assert np.all(mask_below[:, 32:] == 0) + mask_rel, _ = node.process(field, method="relative", threshold=0.5, direction="above") + assert np.all(mask_rel[:, 32:] == 255) - mask_rel, _ = node.process(field, method="relative", threshold=0.5, direction="above") - assert np.all(mask_rel[:, 32:] == 255) - - mask_otsu, _ = node.process(field, method="otsu", threshold=0.0, direction="above") - assert mask_otsu[:, 32:].sum() > mask_otsu[:, :32].sum() - - ThresholdMask._broadcast_fn = None + mask_otsu, _ = node.process(field, method="otsu", threshold=0.0, direction="above") + assert mask_otsu[:, 32:].sum() > mask_otsu[:, :32].sum() def test_threshold_mask_unknown_method(): diff --git a/tests/node_tests/print_table.py b/tests/node_tests/print_table.py index e6b1f23..5a67070 100644 --- a/tests/node_tests/print_table.py +++ b/tests/node_tests/print_table.py @@ -1,5 +1,6 @@ def test_print_table(): from backend.nodes.print_table import PrintTable + from backend.execution_context import execution_callbacks, active_node node = PrintTable() table_spec = PrintTable.INPUT_TYPES()["required"]["table"] @@ -7,12 +8,8 @@ def test_print_table(): assert table_spec[1]["accepted_types"] == ["DATA_TABLE"] captured = [] - PrintTable._broadcast_table_fn = lambda node_id, rows: captured.append(rows) - PrintTable._current_node_id = "test" - - table = [{"quantity": "test", "value": 42.0, "unit": "m"}] - node.print_table(table=table) - assert len(captured) == 1 - assert captured[0] == table - - PrintTable._broadcast_table_fn = None + with execution_callbacks(table=lambda nid, rows: captured.append(rows)), active_node("test"): + table = [{"quantity": "test", "value": 42.0, "unit": "m"}] + node.print_table(table=table) + assert len(captured) == 1 + assert captured[0] == table diff --git a/tests/node_tests/rotate.py b/tests/node_tests/rotate.py index f31a49f..9364d88 100644 --- a/tests/node_tests/rotate.py +++ b/tests/node_tests/rotate.py @@ -42,22 +42,20 @@ def test_rotate_field(): def test_rotate_field_overlay_warning(): from backend.nodes.rotate import RotateField + from backend.execution_context import execution_callbacks, active_node node = RotateField() warnings = [] - RotateField._broadcast_warning_fn = lambda nid, msg: warnings.append(msg) - RotateField._current_node_id = "test" field = DataField( data=np.arange(16, dtype=np.float64).reshape(4, 4), overlays=[{"kind": "markup", "shapes": [{"kind": "line", "x1": 0.1, "y1": 0.1, "x2": 0.9, "y2": 0.9, "width": 2, "color": "#ffffff"}]}], ) - rotated, = node.process(field, angle=30.0, interpolation="bilinear", expand_canvas=True) - assert rotated.overlays == [] - assert len(warnings) == 1 - assert "clears annotation/markup overlays" in warnings[0] - - RotateField._broadcast_warning_fn = None + with execution_callbacks(warning=lambda nid, msg: warnings.append(msg)), active_node("test"): + rotated, = node.process(field, angle=30.0, interpolation="bilinear", expand_canvas=True) + assert rotated.overlays == [] + assert len(warnings) == 1 + assert "clears annotation/markup overlays" in warnings[0] def test_rotate_unknown_interpolation(): diff --git a/tests/node_tests/stats.py b/tests/node_tests/stats.py index 5fe9394..1eeb8a7 100644 --- a/tests/node_tests/stats.py +++ b/tests/node_tests/stats.py @@ -11,61 +11,58 @@ def test_stats(): assert input_spec[0] == "DATA_FIELD" assert input_spec[1]["accepted_types"] == ["IMAGE", "LINE", "DATA_TABLE"] + from backend.execution_context import execution_callbacks, active_node captured = [] - Stats._broadcast_value_fn = lambda node_id, payload: captured.append((node_id, payload)) - Stats._current_node_id = "test" + with execution_callbacks(value=lambda nid, payload: captured.append((nid, payload))), active_node("test"): + line = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float64) + result, = node.process(line, operation="mean", column="value") + assert np.isclose(result, 2.5) + assert captured[-1] == ("test", {"value": result}) + roughness, = node.process(line, operation="Rq", column="value") + assert np.isclose(roughness, np.sqrt(np.mean((line - line.mean()) ** 2))) - line = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float64) - result, = node.process(line, operation="mean", column="value") - assert np.isclose(result, 2.5) - assert captured[-1] == ("test", {"value": result}) - roughness, = node.process(line, operation="Rq", column="value") - assert np.isclose(roughness, np.sqrt(np.mean((line - line.mean()) ** 2))) + table = DataTable([ + {"name": "a", "value": 3.0, "unit": "m", "other": 10.0}, + {"name": "b", "value": 7.0, "unit": "m", "other": 20.0}, + ]) + result, = node.process(table, operation="max", column="value") + assert result == 7.0 + assert captured[-1] == ("test", {"value": 7.0, "unit": "m"}) + count, = node.process(table, operation="count", column="other") + assert count == 2.0 + auto_column_range, = node.process(table, operation="range", column="") + assert auto_column_range == 4.0 - table = DataTable([ - {"name": "a", "value": 3.0, "unit": "m", "other": 10.0}, - {"name": "b", "value": 7.0, "unit": "m", "other": 20.0}, - ]) - result, = node.process(table, operation="max", column="value") - assert result == 7.0 - assert captured[-1] == ("test", {"value": 7.0, "unit": "m"}) - count, = node.process(table, operation="count", column="other") - assert count == 2.0 - auto_column_range, = node.process(table, operation="range", column="") - assert auto_column_range == 4.0 + field = make_field(data=np.array([[1.0, 5.0], [2.0, 4.0]], dtype=np.float64)) + result, = node.process(field, operation="range", column="value") + assert result == 4.0 + assert captured[-1] == ("test", {"value": 4.0, "unit": "m"}) - field = make_field(data=np.array([[1.0, 5.0], [2.0, 4.0]], dtype=np.float64)) - result, = node.process(field, operation="range", column="value") - assert result == 4.0 - assert captured[-1] == ("test", {"value": 4.0, "unit": "m"}) + image = np.array([[0, 10], [20, 30]], dtype=np.uint8) + result, = node.process(image, operation="avg", column="value") + assert np.isclose(result, 15.0) + assert captured[-1] == ("test", {"value": 15.0}) - image = np.array([[0, 10], [20, 30]], dtype=np.uint8) - result, = node.process(image, operation="avg", column="value") - assert np.isclose(result, 15.0) - assert captured[-1] == ("test", {"value": 15.0}) + try: + node.process(table, operation="Rq", column="value") + raise AssertionError("Expected invalid TABLE operation to raise ValueError") + except ValueError: + pass - try: - node.process(table, operation="Rq", column="value") - raise AssertionError("Expected invalid TABLE operation to raise ValueError") - except ValueError: - pass + try: + node.process([{"label": "only text"}], operation="max", column="label") + raise AssertionError("Expected non-numeric record-table input to raise ValueError") + except ValueError: + pass - try: - node.process([{"label": "only text"}], operation="max", column="label") - raise AssertionError("Expected non-numeric record-table input to raise ValueError") - except ValueError: - pass - - try: - node.process( - RecordTable([{"quantity": "min", "value": 1.0, "unit": "m"}]), - operation="max", column="value", - ) - raise AssertionError("Expected measurement table input to raise ValueError") - except ValueError: - pass - - Stats._broadcast_value_fn = None + try: + node.process( + RecordTable([{"quantity": "min", "value": 1.0, "unit": "m"}]), + operation="max", column="value", + ) + raise AssertionError("Expected measurement table input to raise ValueError") + except ValueError: + pass def test_stats_empty_inputs(): diff --git a/tests/node_tests/value_io.py b/tests/node_tests/value_io.py index f94bde8..37a71e2 100644 --- a/tests/node_tests/value_io.py +++ b/tests/node_tests/value_io.py @@ -11,23 +11,20 @@ def test_value_display(): assert value_spec[0] == "FLOAT" assert value_spec[1]["accepted_types"] == ["RECORD_TABLE"] + from backend.execution_context import execution_callbacks, active_node captured = [] - ValueIO._broadcast_value_fn = lambda node_id, payload: captured.append((node_id, payload)) - ValueIO._current_node_id = "test" + with execution_callbacks(value=lambda nid, payload: captured.append((nid, payload))), active_node("test"): + result = node.display_value(value=3.25) + assert result == (3.25,) + assert captured == [("test", {"value": 3.25})] - result = node.display_value(value=3.25) - assert result == (3.25,) - assert captured == [("test", {"value": 3.25})] - - measurements = RecordTable([ - {"quantity": "delta X", "value": 1.7e-7, "unit": "m"}, - {"quantity": "delta Y", "value": 463, "unit": "count"}, - ]) - result = node.display_value(value=measurements, measurement="delta X") - assert result == (1.7e-7,) - assert captured[-1] == ("test", {"value": 1.7e-7, "unit": "m"}) - - ValueIO._broadcast_value_fn = None + measurements = RecordTable([ + {"quantity": "delta X", "value": 1.7e-7, "unit": "m"}, + {"quantity": "delta Y", "value": 463, "unit": "count"}, + ]) + result = node.display_value(value=measurements, measurement="delta X") + assert result == (1.7e-7,) + assert captured[-1] == ("test", {"value": 1.7e-7, "unit": "m"}) def test_value_display_string_input():