diff --git a/backend/nodes/particle_analysis.py b/backend/nodes/particle_analysis.py index 049fb9f..75f26aa 100644 --- a/backend/nodes/particle_analysis.py +++ b/backend/nodes/particle_analysis.py @@ -2,6 +2,7 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node from backend.data_types import DataField, RecordTable +from backend.nodes.helpers import _square_unit @register_node(display_name="Particle Analysis") @@ -33,6 +34,8 @@ class ParticleAnalysis: labeled, n_particles = label(binary) pixel_area = field.dx * field.dy + xy_unit = str(field.si_unit_xy or "").strip() + z_unit = str(field.si_unit_z or "").strip() rows = RecordTable() for pid in range(1, n_particles + 1): @@ -54,10 +57,15 @@ class ParticleAnalysis: rows.append({ "particle_id": pid, "area_px": area_px, + "area_px_unit": _square_unit("px"), "area_m2": area_m2, + "area_m2_unit": _square_unit(xy_unit), "equiv_diam_m": equiv_diam, + "equiv_diam_m_unit": xy_unit, "mean_height": mean_h, + "mean_height_unit": z_unit, "max_height": max_h, + "max_height_unit": z_unit, "bbox": bbox, }) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a28f1ff..7884a75 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -30,6 +30,11 @@ import { getAutoRunnableNodes, hasBlockingAutoRunInput, } from './executionGraph'; +import { + beginTrackedNodeRequest, + isTrackedNodeRequestCurrent, + resolveLoadNodeChannelPath, +} from './loadNodeOutputs.js'; import { DATA_TYPES, SOCKET_COMPATIBILITY, TYPE_COLORS, CAT_COLORS, CANVAS_COLORS, @@ -823,6 +828,7 @@ function Flow() { const activeDragNodeIdRef = useRef(null); const canvasRightZoomRef = useRef(null); const suppressPaneContextMenuUntilRef = useRef(0); + const loadNodeOutputRequestVersionsRef = useRef(new Map()); const reactFlow = useReactFlow(); // ── WebSocket ─────────────────────────────────────────────────────── @@ -1163,26 +1169,26 @@ function Flow() { const refreshLoadNodeOutputs = useCallback(async (nodeId, explicitPath = null) => { const node = reactFlow.getNode(nodeId); - if (!node) return; + const resolvedPath = resolveLoadNodeChannelPath({ + explicitPath, + resolvedPathInput: getResolvedPathInput(nodeId), + className: node?.data?.className || '', + widgetValues: node?.data?.widgetValues || {}, + }); + const requestVersion = beginTrackedNodeRequest(loadNodeOutputRequestVersionsRef.current, nodeId); - let resolvedPath = typeof explicitPath === 'string' && explicitPath ? explicitPath : null; if (!resolvedPath) { - resolvedPath = getResolvedPathInput(nodeId); - } - if (!resolvedPath) { - if (node.data.className === 'Image') { - resolvedPath = node.data.widgetValues?.filename || ''; - } else if (node.data.className === 'ImageDemo') { - resolvedPath = node.data.widgetValues?.name || ''; + if (!isTrackedNodeRequestCurrent(loadNodeOutputRequestVersionsRef.current, nodeId, requestVersion)) { + return; } - } - - if (!resolvedPath) { setNodeOutputs(nodeId, ['DATA_FIELD'], ['field'], { output_paths: [] }); return; } const channels = await api.getChannels(resolvedPath); + if (!isTrackedNodeRequestCurrent(loadNodeOutputRequestVersionsRef.current, nodeId, requestVersion)) { + return; + } setNodeOutputs( nodeId, channels.map((channel) => channel.type), @@ -1625,17 +1631,21 @@ function Flow() { setNodes((ns) => [...ns, newNode]); // Initialize dynamic outputs for nodes that depend on the selected path/folder. - if (className === 'Folder' && widgetValues.folder) { - refreshFolderNodeOutputs(newNodeId, widgetValues.folder); - } + setTimeout(() => { + if (className === 'Folder' && widgetValues.folder) { + refreshFolderNodeOutputs(newNodeId, widgetValues.folder); + } - // For Image/ImageDemo, auto-fetch channels for the default value - if (className === 'ImageDemo' && widgetValues.name) { - refreshLoadNodeOutputs(newNodeId, widgetValues.name); - } - if (className === 'Image' && widgetValues.filename) { - refreshLoadNodeOutputs(newNodeId, widgetValues.filename); - } + // For Image/ImageDemo, auto-fetch channels for the default value. + // Delay this until after the node exists in React Flow so the async + // response cannot be dropped on creation. + if (className === 'ImageDemo' && widgetValues.name) { + refreshLoadNodeOutputs(newNodeId, widgetValues.name); + } + if (className === 'Image' && widgetValues.filename) { + refreshLoadNodeOutputs(newNodeId, widgetValues.filename); + } + }, 0); // Auto-connect if this was triggered by dropping a connection on blank space if (contextMenu.pendingHandleId) { diff --git a/frontend/src/CustomNode.jsx b/frontend/src/CustomNode.jsx index df8ea01..854c8da 100644 --- a/frontend/src/CustomNode.jsx +++ b/frontend/src/CustomNode.jsx @@ -12,19 +12,13 @@ import { DATA_TYPES, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS, } from './constants'; import { getGroupMinimumSize } from './groupSizing.js'; +import { buildCombinedInputNameByWidgetName, formatUiLabel } from './nodeWidgetLayout.js'; +import { applySIPrefix, formatNumericCell, formatTableRowCell, getTableColumns } from './valueFormatting.js'; // ── Context (provided by App) ───────────────────────────────────────── export const NodeContext = React.createContext(null); -function formatUiLabel(text) { - return String(text ?? '') - .replace(/_/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .toLowerCase(); -} - function parseProxyHandle(handleId) { const text = String(handleId || ''); if (!text.startsWith('group-proxy::')) return null; @@ -421,17 +415,6 @@ function LayerGalleryPreview({ overlay }) { ); } -function getTableColumns(rows) { - const columns = []; - for (const row of rows) { - if (!row || typeof row !== 'object') continue; - for (const key of Object.keys(row)) { - if (!columns.includes(key)) columns.push(key); - } - } - return columns; -} - function getMeasurementChoices(rows) { const names = []; for (const row of rows || []) { @@ -443,85 +426,6 @@ function getMeasurementChoices(rows) { return names; } -const SI_PREFIXES = [ - { exp: -24, prefix: 'y' }, - { exp: -21, prefix: 'z' }, - { exp: -18, prefix: 'a' }, - { exp: -15, prefix: 'f' }, - { exp: -12, prefix: 'p' }, - { exp: -9, prefix: 'n' }, - { exp: -6, prefix: 'u' }, - { exp: -3, prefix: 'm' }, - { exp: 0, prefix: '' }, - { exp: 3, prefix: 'k' }, - { exp: 6, prefix: 'M' }, - { exp: 9, prefix: 'G' }, - { exp: 12, prefix: 'T' }, - { exp: 15, prefix: 'P' }, - { exp: 18, prefix: 'E' }, - { exp: 21, prefix: 'Z' }, - { exp: 24, prefix: 'Y' }, -]; - -const PREFIXABLE_UNITS = new Set([ - 'm', 's', 'A', 'V', 'W', 'Hz', 'F', 'C', 'J', 'N', 'Pa', 'T', 'H', 'S', 'g', 'K', 'Ohm', 'ohm', 'Ω', -]); - -function formatNumericCell(value) { - if (value == null) return ''; - if (typeof value === 'number') { - if (!Number.isFinite(value)) return String(value); - const abs = Math.abs(value); - if (Number.isInteger(value) && abs < 1e6) return String(value); - if ((abs > 0 && abs < 1e-3) || abs >= 1e4) return value.toExponential(3); - return value.toFixed(4).replace(/\.?0+$/, ''); - } - if (Array.isArray(value)) return value.join(', '); - return String(value); -} - -function applySIPrefix(value, unit) { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return { valueText: formatNumericCell(value), unitText: unit }; - } - if (typeof unit !== 'string' || !PREFIXABLE_UNITS.has(unit)) { - return { valueText: formatNumericCell(value), unitText: unit }; - } - if (value === 0) { - return { valueText: '0', unitText: unit }; - } - - const abs = Math.abs(value); - let exp = Math.floor(Math.log10(abs) / 3) * 3; - exp = Math.max(-24, Math.min(24, exp)); - - let scaled = value / (10 ** exp); - if (Math.abs(scaled) >= 999.5 && exp < 24) { - exp += 3; - scaled = value / (10 ** exp); - } - - const prefix = SI_PREFIXES.find((entry) => entry.exp === exp)?.prefix ?? ''; - return { - valueText: formatNumericCell(scaled), - unitText: `${prefix}${unit}`, - }; -} - -function formatTableCell(value) { - return formatNumericCell(value); -} - -function formatTableRowCell(row, column) { - if (column === 'value' && typeof row?.unit === 'string') { - return applySIPrefix(row?.value, row.unit).valueText; - } - if (column === 'unit' && typeof row?.unit === 'string') { - return applySIPrefix(row?.value, row.unit).unitText; - } - return formatTableCell(row?.[column]); -} - function formatScalarValue(value) { if (value == null || Number.isNaN(Number(value))) return '—'; const numeric = Number(value); @@ -550,8 +454,8 @@ function formatScalarDisplay(scalarValue) { if (!payload) return null; if (payload.unit) { - if (PREFIXABLE_UNITS.has(payload.unit)) { - const prefixed = applySIPrefix(payload.value, payload.unit); + const prefixed = applySIPrefix(payload.value, payload.unit); + if (prefixed.unitText !== payload.unit || prefixed.valueText !== formatNumericCell(payload.value)) { return { valueText: prefixed.valueText, unitText: prefixed.unitText, @@ -1061,20 +965,24 @@ function CustomNode({ id, data }) { } } - const visibleWidgets = widgets.filter((w) => ( + const dataInputByName = new Map(dataInputs.map((input) => [input.name, input])); + + const widgetsVisibleByDefinition = widgets.filter((w) => ( widgetVisibleForSourceType(w, connectedSourceTypes?.[getWidgetSourceInputName(w.opts)]) && widgetVisibleForWidgetValues(w, data.widgetValues) && widgetVisibleForInputVisibility(w, visibleInputNames) - && !widgetHiddenByConnectedInput(w, connectedInputs) + )); + const combinedInputNameByWidgetName = buildCombinedInputNameByWidgetName( + widgetsVisibleByDefinition, + dataInputs, + ); + const visibleWidgets = widgetsVisibleByDefinition.filter((widget) => ( + combinedInputNameByWidgetName.has(widget.name) + || !widgetHiddenByConnectedInput(widget, connectedInputs) )); - const combinedTopInputNames = new Set( - visibleWidgets - .map((widget) => widget?.opts?.top_socket_input) - .filter((name) => typeof name === 'string' && name.length > 0), - ); - const renderedDataInputs = dataInputs.filter((input) => !combinedTopInputNames.has(input.name)); - const dataInputByName = new Map(dataInputs.map((input) => [input.name, input])); + const combinedInputNames = new Set(combinedInputNameByWidgetName.values()); + const renderedDataInputs = dataInputs.filter((input) => !combinedInputNames.has(input.name)); const inlineWidgetsByInput = new Map(); const topWidgets = []; @@ -1141,14 +1049,14 @@ function CustomNode({ id, data }) {
{topWidgets.length > 0 && (
- {topWidgets.map((w) => ( -
- {(w.socketType || w.opts?.top_socket_input) && (() => { - const socketInput = w.opts?.top_socket_input ? dataInputByName.get(w.opts.top_socket_input) : null; - const socketType = w.socketType || socketInput?.type; - const socketName = w.socketType ? w.name : socketInput?.name; - if (!socketType || !socketName) return null; - return ( + {topWidgets.map((w) => { + const combinedInputName = combinedInputNameByWidgetName.get(w.name) || null; + const socketInput = combinedInputName ? dataInputByName.get(combinedInputName) : null; + const socketType = w.socketType || socketInput?.type; + const socketName = w.socketType ? w.name : socketInput?.name; + return ( +
+ {socketType && socketName && ( - ); - })()} - {!!( - (w.socketType && connectedInputs?.has(w.name)) - || (w.opts?.top_socket_input && connectedInputs?.has(w.opts.top_socket_input)) - ) ? ( - - ) : ( - - )} -
- ))} + )} + {!!( + (w.socketType && connectedInputs?.has(w.name)) + || (combinedInputName && connectedInputs?.has(combinedInputName)) + ) ? ( + + ) : ( + + )} +
+ ); + })}
)} @@ -1247,31 +1155,38 @@ function CustomNode({ id, data }) { )} {/* Widget rows */} - {standaloneWidgets.map((w) => ( -
- {w.socketType && ( - - )} - {w.socketType && connectedInputs?.has(w.name) ? ( - - ) : ( - - )} -
- ))} + {standaloneWidgets.map((w) => { + const combinedInputName = combinedInputNameByWidgetName.get(w.name) || null; + const socketInput = combinedInputName ? dataInputByName.get(combinedInputName) : null; + const socketType = w.socketType || socketInput?.type; + const socketName = w.socketType ? w.name : socketInput?.name; + return ( +
+ {socketType && socketName && ( + + )} + {(w.socketType && connectedInputs?.has(w.name)) + || (combinedInputName && connectedInputs?.has(combinedInputName)) ? ( + + ) : ( + + )} +
+ ); + })} {/* Manual trigger button (Save) */} {def.manual_trigger && ( diff --git a/frontend/src/loadNodeOutputs.js b/frontend/src/loadNodeOutputs.js new file mode 100644 index 0000000..bd50b60 --- /dev/null +++ b/frontend/src/loadNodeOutputs.js @@ -0,0 +1,30 @@ +export function resolveLoadNodeChannelPath({ + explicitPath = null, + resolvedPathInput = null, + className = '', + widgetValues = {}, +} = {}) { + if (typeof explicitPath === 'string' && explicitPath) { + return explicitPath; + } + if (typeof resolvedPathInput === 'string' && resolvedPathInput) { + return resolvedPathInput; + } + if (className === 'Image') { + return String(widgetValues?.filename || ''); + } + if (className === 'ImageDemo') { + return String(widgetValues?.name || ''); + } + return ''; +} + +export function beginTrackedNodeRequest(requestVersions, nodeId) { + const nextVersion = (requestVersions.get(nodeId) || 0) + 1; + requestVersions.set(nodeId, nextVersion); + return nextVersion; +} + +export function isTrackedNodeRequestCurrent(requestVersions, nodeId, version) { + return requestVersions.get(nodeId) === version; +} diff --git a/frontend/src/nodeWidgetLayout.js b/frontend/src/nodeWidgetLayout.js new file mode 100644 index 0000000..4e7a3d5 --- /dev/null +++ b/frontend/src/nodeWidgetLayout.js @@ -0,0 +1,49 @@ +export function formatUiLabel(text) { + return String(text ?? '') + .replace(/_/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); +} + +function normalizeInputNames(raw) { + if (!raw) return []; + return (Array.isArray(raw) ? raw : [raw]) + .map((value) => String(value)) + .filter((value) => value.length > 0); +} + +export function getWidgetCombinedInputName(widget, dataInputByName) { + const explicitInputName = normalizeInputNames(widget?.opts?.top_socket_input)[0]; + if (explicitInputName && dataInputByName?.has(explicitInputName)) { + return explicitInputName; + } + + const widgetLabel = formatUiLabel(widget?.opts?.label || widget?.name); + if (!widgetLabel) return null; + + for (const inputName of normalizeInputNames(widget?.opts?.hide_when_input_connected)) { + const input = dataInputByName?.get(inputName); + if (!input) continue; + const inputLabel = formatUiLabel(input.label || input.name); + if (inputLabel === widgetLabel) { + return input.name; + } + } + + return null; +} + +export function buildCombinedInputNameByWidgetName(widgets, dataInputs) { + const dataInputByName = new Map((dataInputs || []).map((input) => [input.name, input])); + const combinedInputNameByWidgetName = new Map(); + + for (const widget of widgets || []) { + const combinedInputName = getWidgetCombinedInputName(widget, dataInputByName); + if (combinedInputName) { + combinedInputNameByWidgetName.set(widget.name, combinedInputName); + } + } + + return combinedInputNameByWidgetName; +} diff --git a/frontend/src/valueFormatting.js b/frontend/src/valueFormatting.js new file mode 100644 index 0000000..e1356e8 --- /dev/null +++ b/frontend/src/valueFormatting.js @@ -0,0 +1,171 @@ +const SI_PREFIXES = [ + { exp: -24, prefix: 'y' }, + { exp: -21, prefix: 'z' }, + { exp: -18, prefix: 'a' }, + { exp: -15, prefix: 'f' }, + { exp: -12, prefix: 'p' }, + { exp: -9, prefix: 'n' }, + { exp: -6, prefix: 'u' }, + { exp: -3, prefix: 'm' }, + { exp: 0, prefix: '' }, + { exp: 3, prefix: 'k' }, + { exp: 6, prefix: 'M' }, + { exp: 9, prefix: 'G' }, + { exp: 12, prefix: 'T' }, + { exp: 15, prefix: 'P' }, + { exp: 18, prefix: 'E' }, + { exp: 21, prefix: 'Z' }, + { exp: 24, prefix: 'Y' }, +]; + +const PREFIXABLE_UNITS = new Set([ + 'm', 's', 'A', 'V', 'W', 'Hz', 'F', 'C', 'J', 'N', 'Pa', 'T', 'H', 'S', 'g', 'K', 'Ohm', 'ohm', 'Ω', +]); + +const SUPERSCRIPT_DIGITS = { + '-': '⁻', + '0': '⁰', + '1': '¹', + '2': '²', + '3': '³', + '4': '⁴', + '5': '⁵', + '6': '⁶', + '7': '⁷', + '8': '⁸', + '9': '⁹', +}; + +export function formatNumericCell(value) { + if (value == null) return ''; + if (typeof value === 'number') { + if (!Number.isFinite(value)) return String(value); + const abs = Math.abs(value); + if (Number.isInteger(value) && abs < 1e6) return String(value); + if ((abs > 0 && abs < 1e-3) || abs >= 1e4) return value.toExponential(3); + return value.toFixed(4).replace(/\.?0+$/, ''); + } + if (Array.isArray(value)) return value.join(', '); + return String(value); +} + +function toSuperscript(text) { + return String(text) + .split('') + .map((char) => SUPERSCRIPT_DIGITS[char] || char) + .join(''); +} + +export function formatDisplayUnit(unit) { + return String(unit ?? '').replace(/\^(-?\d+)/g, (_, exponent) => toSuperscript(exponent)); +} + +function parsePrefixableUnit(unit) { + const text = String(unit ?? '').trim(); + if (!text) return null; + + const poweredMatch = text.match(/^([A-Za-zΩ]+)\^([1-9]\d*)$/); + if (poweredMatch) { + const [, baseUnit, powerText] = poweredMatch; + if (!PREFIXABLE_UNITS.has(baseUnit)) return null; + return { baseUnit, power: Number.parseInt(powerText, 10) }; + } + + if (!PREFIXABLE_UNITS.has(text)) return null; + return { baseUnit: text, power: 1 }; +} + +function formatPrefixedUnit(baseUnit, prefix, power) { + return power === 1 ? `${prefix}${baseUnit}` : `${prefix}${baseUnit}${toSuperscript(power)}`; +} + +function choosePrefixExponent(value, power) { + const abs = Math.abs(value); + const candidates = SI_PREFIXES.map(({ exp, prefix }) => { + const scaled = value / (10 ** (exp * power)); + return { + exp, + prefix, + scaled, + absScaled: Math.abs(scaled), + }; + }); + + const inRange = candidates.filter(({ absScaled }) => absScaled >= 1 && absScaled < 999.5); + if (inRange.length > 0) { + return inRange.reduce((best, candidate) => (candidate.absScaled < best.absScaled ? candidate : best)); + } + + const aboveOne = candidates.filter(({ absScaled }) => absScaled >= 1); + if (aboveOne.length > 0) { + return aboveOne.reduce((best, candidate) => (candidate.absScaled < best.absScaled ? candidate : best)); + } + + return candidates.reduce((best, candidate) => (candidate.absScaled > best.absScaled ? candidate : best)); +} + +export function applySIPrefix(value, unit) { + const formattedUnit = formatDisplayUnit(unit); + if (typeof value !== 'number' || !Number.isFinite(value)) { + return { valueText: formatNumericCell(value), unitText: formattedUnit }; + } + if (value === 0) { + return { valueText: '0', unitText: formattedUnit }; + } + + const parsedUnit = parsePrefixableUnit(unit); + if (!parsedUnit) { + return { valueText: formatNumericCell(value), unitText: formattedUnit }; + } + + const chosenPrefix = choosePrefixExponent(value, parsedUnit.power); + return { + valueText: formatNumericCell(chosenPrefix.scaled), + unitText: formatPrefixedUnit(parsedUnit.baseUnit, chosenPrefix.prefix, parsedUnit.power), + }; +} + +function getCompanionUnitColumn(column, row) { + if (!row || typeof row !== 'object' || typeof column !== 'string' || column === 'unit') { + return null; + } + const unitColumn = `${column}_unit`; + return typeof row?.[unitColumn] === 'string' ? unitColumn : null; +} + +export function getTableColumns(rows) { + const columns = []; + const hiddenColumns = new Set(); + + for (const row of rows || []) { + if (!row || typeof row !== 'object') continue; + for (const key of Object.keys(row)) { + if ( + key.endsWith('_unit') + && key.length > 5 + && Object.prototype.hasOwnProperty.call(row, key.slice(0, -5)) + ) { + hiddenColumns.add(key); + continue; + } + if (!columns.includes(key)) columns.push(key); + } + } + + return columns.filter((column) => !hiddenColumns.has(column)); +} + +export function formatTableRowCell(row, column) { + const companionUnitColumn = getCompanionUnitColumn(column, row); + if (companionUnitColumn) { + const formatted = applySIPrefix(row?.[column], row?.[companionUnitColumn]); + return formatted.unitText ? `${formatted.valueText} ${formatted.unitText}` : formatted.valueText; + } + if (column === 'value' && typeof row?.unit === 'string') { + return applySIPrefix(row?.value, row.unit).valueText; + } + if (column === 'unit' && typeof row?.unit === 'string') { + return applySIPrefix(row?.value, row.unit).unitText; + } + return formatNumericCell(row?.[column]); +} diff --git a/frontend/tests/loadNodeOutputs.test.mjs b/frontend/tests/loadNodeOutputs.test.mjs new file mode 100644 index 0000000..0eb4b71 --- /dev/null +++ b/frontend/tests/loadNodeOutputs.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + beginTrackedNodeRequest, + isTrackedNodeRequestCurrent, + resolveLoadNodeChannelPath, +} from '../src/loadNodeOutputs.js'; + +test('resolveLoadNodeChannelPath can resolve a new ImageDemo node from its explicit selection before mount', () => { + const resolvedPath = resolveLoadNodeChannelPath({ + explicitPath: 'APL_Figure4.ibw', + className: '', + widgetValues: {}, + }); + + assert.equal(resolvedPath, 'APL_Figure4.ibw'); +}); + +test('resolveLoadNodeChannelPath falls back to the current widget value for load nodes', () => { + assert.equal(resolveLoadNodeChannelPath({ + className: 'Image', + widgetValues: { filename: 'scan.ibw' }, + }), 'scan.ibw'); + + assert.equal(resolveLoadNodeChannelPath({ + className: 'ImageDemo', + widgetValues: { name: 'demo.ibw' }, + }), 'demo.ibw'); +}); + +test('tracked load-node requests ignore stale async responses', () => { + const requestVersions = new Map(); + + const first = beginTrackedNodeRequest(requestVersions, '42'); + const second = beginTrackedNodeRequest(requestVersions, '42'); + + assert.equal(isTrackedNodeRequestCurrent(requestVersions, '42', first), false); + assert.equal(isTrackedNodeRequestCurrent(requestVersions, '42', second), true); +}); diff --git a/frontend/tests/nodeWidgetLayout.test.mjs b/frontend/tests/nodeWidgetLayout.test.mjs new file mode 100644 index 0000000..11d2f1d --- /dev/null +++ b/frontend/tests/nodeWidgetLayout.test.mjs @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + buildCombinedInputNameByWidgetName, + getWidgetCombinedInputName, +} from '../src/nodeWidgetLayout.js'; + +test('getWidgetCombinedInputName pairs same-label hide_when_input_connected widgets with their matching input', () => { + const dataInputByName = new Map([ + ['colormap_map', { name: 'colormap_map', type: 'COLORMAP', label: 'colormap' }], + ]); + + const combinedInputName = getWidgetCombinedInputName({ + name: 'colormap', + opts: { hide_when_input_connected: 'colormap_map' }, + }, dataInputByName); + + assert.equal(combinedInputName, 'colormap_map'); +}); + +test('getWidgetCombinedInputName leaves unrelated hidden inputs as standalone rows', () => { + const dataInputByName = new Map([ + ['path', { name: 'path', type: 'FILE_PATH', label: 'path' }], + ]); + + const combinedInputName = getWidgetCombinedInputName({ + name: 'filename', + opts: { hide_when_input_connected: 'path' }, + }, dataInputByName); + + assert.equal(combinedInputName, null); +}); + +test('buildCombinedInputNameByWidgetName preserves explicit top_socket_input pairings', () => { + const combinedInputNameByWidgetName = buildCombinedInputNameByWidgetName( + [ + { + name: 'directory', + opts: { + top_socket_input: 'directory', + hide_when_input_connected: 'directory', + }, + }, + ], + [ + { name: 'directory', type: 'STRING', label: 'directory' }, + ], + ); + + assert.equal(combinedInputNameByWidgetName.get('directory'), 'directory'); +}); diff --git a/frontend/tests/valueFormatting.test.mjs b/frontend/tests/valueFormatting.test.mjs new file mode 100644 index 0000000..a970e84 --- /dev/null +++ b/frontend/tests/valueFormatting.test.mjs @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + applySIPrefix, + formatDisplayUnit, + formatTableRowCell, + getTableColumns, +} from '../src/valueFormatting.js'; + +test('getTableColumns hides companion record-table unit columns', () => { + const columns = getTableColumns([ + { + particle_id: 1, + area_m2: 2.5e-12, + area_m2_unit: 'm^2', + mean_height: 1.5e-9, + mean_height_unit: 'm', + bbox: '(0,0)-(2,2)', + }, + ]); + + assert.deepEqual(columns, ['particle_id', 'area_m2', 'mean_height', 'bbox']); +}); + +test('formatTableRowCell appends companion units inline for record-table values', () => { + const row = { + area_m2: 2.5e-12, + area_m2_unit: 'm^2', + mean_height: 1.5e-9, + mean_height_unit: 'm', + }; + + assert.equal(formatTableRowCell(row, 'area_m2'), '2.5 um²'); + assert.equal(formatTableRowCell(row, 'mean_height'), '1.5 nm'); +}); + +test('formatDisplayUnit renders exponent markers as superscripts', () => { + assert.equal(formatDisplayUnit('px^2'), 'px²'); + assert.equal(formatDisplayUnit('(V)^2 m^2'), '(V)² m²'); +}); + +test('applySIPrefix scales squared units using squared SI prefixes', () => { + assert.deepEqual(applySIPrefix(3e-18, 'm^2'), { valueText: '3', unitText: 'nm²' }); + assert.deepEqual(applySIPrefix(3e-9, 'm^2'), { valueText: '3000', unitText: 'um²' }); + assert.deepEqual(applySIPrefix(144, 'px^2'), { valueText: '144', unitText: 'px²' }); +}); diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 90623e9..6b835c5 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -867,6 +867,11 @@ def test_particle_analysis(): assert table[1]["area_px"] == 64 # 8x8 assert abs(table[0]["mean_height"] - 5.0) < 1e-10 assert abs(table[1]["mean_height"] - 3.0) < 1e-10 + assert table[0]["area_px_unit"] == "px^2" + assert table[0]["area_m2_unit"] == "m^2" + assert table[0]["equiv_diam_m_unit"] == "m" + assert table[0]["mean_height_unit"] == "m" + assert table[0]["max_height_unit"] == "m" # min_size filtering: only keep particles >= 80 px table_filtered, = node.process(field, mask=mask, min_size=80)