feature focus on 3d viewer, add copy/paste
This commit is contained in:
179
frontend/tests/nodeClipboard.test.mjs
Normal file
179
frontend/tests/nodeClipboard.test.mjs
Normal file
@@ -0,0 +1,179 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
buildNodeClipboardPayload,
|
||||
instantiateNodeClipboardPayload,
|
||||
NODE_CLIPBOARD_KIND,
|
||||
parseNodeClipboardPayload,
|
||||
} from '../src/nodeClipboard.js';
|
||||
|
||||
test('buildNodeClipboardPayload keeps only selected nodes and internal edges', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: '1',
|
||||
selected: true,
|
||||
type: 'custom',
|
||||
position: { x: 10, y: 20 },
|
||||
data: {
|
||||
label: 'Image',
|
||||
className: 'Image',
|
||||
widgetValues: { filename: 'scan.ibw' },
|
||||
runtimeValues: { layerIndex: 2 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
selected: true,
|
||||
position: { x: 100, y: 200 },
|
||||
data: {
|
||||
label: 'Preview',
|
||||
className: 'Preview',
|
||||
widgetValues: { mode: 'auto' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
selected: false,
|
||||
position: { x: 500, y: 600 },
|
||||
data: {
|
||||
label: 'Save',
|
||||
className: 'Save',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const edges = [
|
||||
{
|
||||
id: 'e1-2',
|
||||
source: '1',
|
||||
sourceHandle: 'output::0::DATA_FIELD',
|
||||
target: '2',
|
||||
targetHandle: 'input::field::DATA_FIELD',
|
||||
style: { stroke: '#fff', strokeWidth: 2 },
|
||||
},
|
||||
{
|
||||
id: 'e2-3',
|
||||
source: '2',
|
||||
sourceHandle: 'output::0::IMAGE',
|
||||
target: '3',
|
||||
targetHandle: 'input::value::SAVE_VALUE',
|
||||
},
|
||||
];
|
||||
|
||||
const payload = buildNodeClipboardPayload(nodes, edges);
|
||||
|
||||
assert.equal(payload.kind, NODE_CLIPBOARD_KIND);
|
||||
assert.equal(payload.nodes.length, 2);
|
||||
assert.deepEqual(payload.nodes.map((node) => node.id), ['1', '2']);
|
||||
assert.equal(payload.edges.length, 1);
|
||||
assert.deepEqual(payload.edges[0], {
|
||||
source: '1',
|
||||
sourceHandle: 'output::0::DATA_FIELD',
|
||||
target: '2',
|
||||
targetHandle: 'input::field::DATA_FIELD',
|
||||
style: { stroke: '#fff', strokeWidth: 2 },
|
||||
});
|
||||
|
||||
const reparsed = parseNodeClipboardPayload(JSON.stringify(payload));
|
||||
assert.deepEqual(reparsed, payload);
|
||||
});
|
||||
|
||||
test('instantiateNodeClipboardPayload remaps ids, offsets positions, and hydrates node shells', () => {
|
||||
const payload = {
|
||||
kind: NODE_CLIPBOARD_KIND,
|
||||
version: 1,
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
position: { x: 10, y: 20 },
|
||||
data: {
|
||||
label: 'Image',
|
||||
className: 'Image',
|
||||
widgetValues: { filename: 'scan.ibw', colormap: 'viridis' },
|
||||
runtimeValues: { layerIndex: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
position: { x: 100, y: 200 },
|
||||
data: {
|
||||
label: 'Preview',
|
||||
className: 'Preview',
|
||||
widgetValues: { colormap: 'gray' },
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
source: '1',
|
||||
sourceHandle: 'output::0::DATA_FIELD',
|
||||
target: '2',
|
||||
targetHandle: 'input::field::DATA_FIELD',
|
||||
style: { stroke: '#abc', strokeWidth: 2 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const defs = {
|
||||
Image: { output: ['DATA_FIELD'], output_name: ['field'] },
|
||||
Preview: { output: ['IMAGE'], output_name: ['preview'] },
|
||||
};
|
||||
|
||||
const instantiated = instantiateNodeClipboardPayload(payload, defs, 12, { x: 32, y: 48 });
|
||||
|
||||
assert.equal(instantiated.nextNodeId, 14);
|
||||
assert.deepEqual(instantiated.nodes.map((node) => node.id), ['12', '13']);
|
||||
assert.deepEqual(instantiated.nodes.map((node) => node.position), [
|
||||
{ x: 42, y: 68 },
|
||||
{ x: 132, y: 248 },
|
||||
]);
|
||||
assert.equal(instantiated.nodes[0].selected, true);
|
||||
assert.deepEqual(instantiated.nodes[0].data.widgetValues, { filename: 'scan.ibw', colormap: 'viridis' });
|
||||
assert.deepEqual(instantiated.nodes[0].data.runtimeValues, { layerIndex: 1 });
|
||||
assert.equal(instantiated.nodes[0].data.previewImage, null);
|
||||
assert.deepEqual(instantiated.nodes[0].data.definition, defs.Image);
|
||||
|
||||
assert.deepEqual(instantiated.edges, [
|
||||
{
|
||||
id: 'e12-13-0',
|
||||
source: '12',
|
||||
sourceHandle: 'output::0::DATA_FIELD',
|
||||
target: '13',
|
||||
targetHandle: 'input::field::DATA_FIELD',
|
||||
selected: false,
|
||||
style: { stroke: '#abc', strokeWidth: 2 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('clipboard payload deep-copies local widget and runtime fields', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: '9',
|
||||
selected: true,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Markup',
|
||||
className: 'Markup',
|
||||
widgetValues: {
|
||||
stroke_width: 3,
|
||||
markup_shapes: [
|
||||
{ kind: 'line', points: [0.1, 0.2, 0.3, 0.4] },
|
||||
],
|
||||
},
|
||||
runtimeValues: {
|
||||
camera: { azimuth: 15, polar: 60 },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const payload = buildNodeClipboardPayload(nodes, []);
|
||||
|
||||
nodes[0].data.widgetValues.markup_shapes[0].points[0] = 0.9;
|
||||
nodes[0].data.runtimeValues.camera.azimuth = 90;
|
||||
|
||||
assert.equal(payload.nodes[0].data.widgetValues.markup_shapes[0].points[0], 0.1);
|
||||
assert.equal(payload.nodes[0].data.runtimeValues.camera.azimuth, 15);
|
||||
});
|
||||
@@ -9,63 +9,6 @@ function makePngBlob() {
|
||||
return new Blob([Buffer.from(PNG_BASE64, 'base64')], { type: 'image/png' });
|
||||
}
|
||||
|
||||
function crc32(bytes) {
|
||||
const table = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
}
|
||||
table[i] = c;
|
||||
}
|
||||
|
||||
let crc = 0xFFFFFFFF;
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
crc = table[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xFFFFFFFF) >>> 0;
|
||||
}
|
||||
|
||||
function buildChunk(type, payload) {
|
||||
const typeBytes = new TextEncoder().encode(type);
|
||||
const crcInput = new Uint8Array(4 + payload.length);
|
||||
crcInput.set(typeBytes, 0);
|
||||
crcInput.set(payload, 4);
|
||||
|
||||
const chunk = new Uint8Array(12 + payload.length);
|
||||
const view = new DataView(chunk.buffer);
|
||||
view.setUint32(0, payload.length);
|
||||
chunk.set(typeBytes, 4);
|
||||
chunk.set(payload, 8);
|
||||
view.setUint32(8 + payload.length, crc32(crcInput));
|
||||
return chunk;
|
||||
}
|
||||
|
||||
async function insertTextChunk(blob, workflow) {
|
||||
const png = new Uint8Array(await blob.arrayBuffer());
|
||||
const encoder = new TextEncoder();
|
||||
const key = encoder.encode('workflow');
|
||||
const text = encoder.encode(JSON.stringify(workflow));
|
||||
const payload = new Uint8Array(key.length + 1 + text.length);
|
||||
payload.set(key, 0);
|
||||
payload.set(text, key.length + 1);
|
||||
const chunk = buildChunk('tEXt', payload);
|
||||
|
||||
let pos = 8;
|
||||
while (pos < png.length) {
|
||||
const len = new DataView(png.buffer, pos, 4).getUint32(0);
|
||||
const type = String.fromCharCode(png[pos + 4], png[pos + 5], png[pos + 6], png[pos + 7]);
|
||||
if (type === 'IEND') break;
|
||||
pos += 12 + len;
|
||||
}
|
||||
|
||||
const out = new Uint8Array(png.length + chunk.length);
|
||||
out.set(png.subarray(0, pos), 0);
|
||||
out.set(chunk, pos);
|
||||
out.set(png.subarray(pos), pos + chunk.length);
|
||||
return new Blob([out], { type: 'image/png' });
|
||||
}
|
||||
|
||||
test('embedWorkflow roundtrips workflow data through an iTXt chunk', async () => {
|
||||
const workflow = {
|
||||
version: 1,
|
||||
@@ -88,15 +31,6 @@ test('embedWorkflow roundtrips workflow data through an iTXt chunk', async () =>
|
||||
assert.match(Buffer.from(bytes).toString('latin1'), /iTXt/);
|
||||
});
|
||||
|
||||
test('extractWorkflow still supports legacy tEXt metadata chunks', async () => {
|
||||
const workflow = { version: 1, legacy: true, nodes: [], edges: [] };
|
||||
const legacyBlob = await insertTextChunk(makePngBlob(), workflow);
|
||||
|
||||
const extracted = await extractWorkflow(legacyBlob);
|
||||
|
||||
assert.deepEqual(extracted, workflow);
|
||||
});
|
||||
|
||||
test('extractWorkflow returns the last workflow chunk when an image is re-saved', async () => {
|
||||
const first = { version: 1, name: 'old', nodes: [], edges: [] };
|
||||
const second = { version: 1, name: 'new', nodes: [], edges: [] };
|
||||
|
||||
@@ -95,7 +95,7 @@ test('serializeWorkflowState keeps only stable workflow fields needed for reload
|
||||
assert.equal('selected' in serialized.edges[0], false);
|
||||
});
|
||||
|
||||
test('hydrateWorkflowState clears shared path widgets while restoring saved dynamic outputs', () => {
|
||||
test('hydrateWorkflowState clears shared path widgets and uses registry definitions', () => {
|
||||
const saved = {
|
||||
version: 1,
|
||||
nodes: [
|
||||
@@ -142,12 +142,12 @@ test('hydrateWorkflowState clears shared path widgets while restoring saved dyna
|
||||
assert.equal(hydrated.nodes[0].data.previewImage, null);
|
||||
assert.equal(hydrated.nodes[0].data.widgetValues.filename, '');
|
||||
assert.equal(hydrated.nodes[0].data.widgetValues.colormap, 'viridis');
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['DATA_FIELD', 'DATA_FIELD']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['Height', 'Phase']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['DATA_FIELD']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['field']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.input, defs.Image.input);
|
||||
});
|
||||
|
||||
test('serializeWorkflowState and hydrateWorkflowState clear path-like widgets but preserve other metadata', () => {
|
||||
test('serializeWorkflowState and hydrateWorkflowState clear path-like widgets without restoring saved outputs', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: '7',
|
||||
@@ -188,8 +188,8 @@ test('serializeWorkflowState and hydrateWorkflowState clear path-like widgets bu
|
||||
const hydrated = hydrateWorkflowState(serialized, defs);
|
||||
|
||||
assert.deepEqual(hydrated.nodes[0].data.widgetValues, { filename: '', colormap: 'gray' });
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['DATA_FIELD', 'DATA_FIELD', 'DATA_FIELD']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['Topography', 'Error', 'Mask']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['DATA_FIELD']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['field']);
|
||||
assert.deepEqual(hydrated.edges, edges);
|
||||
});
|
||||
|
||||
@@ -223,6 +223,6 @@ test('hydrateWorkflowState clears saved folder selections on shared workflows',
|
||||
const hydrated = hydrateWorkflowState(saved, defs);
|
||||
|
||||
assert.equal(hydrated.nodes[0].data.widgetValues.folder, '');
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['PATH', 'PATH']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['scan1.png', 'scan2.png']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['PATH']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['path']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user