diff --git a/CHANGELOG.md b/CHANGELOG.md index b373bcd..39761a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 1.17.1 — 2026-08-12 + +### Fixes + +* **Switching workspaces always lands on a visible graph.** Clicking or dragging any node pinned the renderer's coordinate normalization to the workspace on screen at that moment, and nothing released the pin except ⊡ Fit — so switching to a workspace whose layout lives in a different coordinate range (a grid template versus a circular one, say) could render it entirely off-screen or collapsed into a corner, and only a Fit or a lucky second switch brought it back. The pin now lives exactly as long as the drag gesture, and every full re-render re-derives the coordinate frame from the nodes actually present. +* **Bubble groups no longer glitch through a workspace switch.** Switching between workspaces with different groups flashed the incoming groups' colours on the outgoing hull shape, let the hull trail behind the moving nodes, and briefly showed the stale shape again before it snapped into place. The hulls now hide the instant a switch starts, stay hidden while the nodes animate over, and fade back in only after they have been refitted around the settled positions — so the target workspace's groups appear fully formed, in their own colours. +* **Switching workspaces in quick succession no longer strands the first switch.** A switch started while the previous one was still animating cancelled that animation in a way its caller never noticed, leaving the older switch waiting forever — its cleanup, status message and undo-history reset never ran. A cancelled switch now finishes immediately and hands everything over to the newer one. +* **A failed switch can no longer freeze the loading overlay.** A narrow window at the start of every workspace switch, creation and re-layout sat outside the error handling that releases the overlay hold; an error there (for instance the selector naming a workspace that no longer exists) left the overlay up for good, with every later action unable to dismiss it short of a reload. +* **The workspace-name prompt validates inline.** Creating a workspace with an empty name popped a native browser alert — the only one left in the app, and one that blocks the whole window. The dialog now marks the name field with a standard validation bubble and clears it as you type. + +### Performance + +* **Style and filter updates stay cheap after an Arrange or Re-layout.** The first arrange of a session raised an internal "layout changed" flag that was never lowered, silently upgrading every later style- or filter-only update to a full re-indexing render for the rest of the session. + ## 1.17.0 — 2026-08-07 Saved graph files, workspaces, filters, styles and bubble groups all load unchanged, including files written before this release — but **the interface is rearranged**, so it is worth reading the first section below before looking for a control where it used to be. The short version: the filter sidebar, styling sidebar, selection HUD and workspace bar are now a **rail** across the top, one **inspector** on the right with Filters / Overlays / Selection contexts, and a **workbench** of tabs (Data, Query, Metrics, Assistant) at the foot of the stage. `⌘K` / `Ctrl+K` finds any control by name and tells you where it lives, which is the fastest way to relearn the layout. diff --git a/package-lock.json b/package-lock.json index 59e5cb5..7eab3cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "graph-lens-lite", - "version": "1.17.0", + "version": "1.17.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "graph-lens-lite", - "version": "1.17.0", + "version": "1.17.1", "license": "MIT", "dependencies": { "@antv/layout": "^2.0.0", diff --git a/package.json b/package.json index 52278cf..3adfb92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graph-lens-lite", - "version": "1.17.0", + "version": "1.17.1", "main": "src/package/electron_app.js", "description": "Visualise and explore property graphs in a lightweight desktop app.", "homepage": "https://github.com/Delta4AI/GraphLensLite", diff --git a/src/config.js b/src/config.js index 3d99287..eeff180 100644 --- a/src/config.js +++ b/src/config.js @@ -1,7 +1,7 @@ /** * Defaults for the graph, layouts and UI */ -const VERSION = "1.17.0"; +const VERSION = "1.17.1"; const DEFAULTS = { NODE: { diff --git a/src/graph/bubble_layer.js b/src/graph/bubble_layer.js index 8eb2777..1fc3e07 100644 --- a/src/graph/bubble_layer.js +++ b/src/graph/bubble_layer.js @@ -51,6 +51,9 @@ const CHEAP_FIT_MS = 8; // re-fit once motion stops. The hull lags the node mid-drag and snaps true on // release, which is the trade the alternative cannot buy: a locked UI. const REFIT_SETTLE_MS = 90; +// Workspace-switch tween: the adapter fades the canvases out for the position +// tween (hulls can't track animated nodes) and back in over the settled refit. +const TRANSITION_FADE_MS = 200; class BubbleSetLayer { /** @@ -151,6 +154,42 @@ class BubbleSetLayer { this.labelCanvas?.remove(); } + /** + * Hide both canvases for a workspace switch and reveal them afterwards + * (GraphLayoutManager.changeLayout / SigmaAdapter.runLayoutTransition). + * Hiding is INSTANT — an eased fade-out would still show the incoming + * groups' colors repainted onto the outgoing shape. The reveal refits + * first (never show a hull the deferral left at stale positions), then + * eases the fresh one in. Pure CSS on top of the paint loop: the layer + * keeps painting underneath, and exports re-paint from the cached + * outlines regardless of canvas opacity. + */ + setFaded(faded) { + if (!faded) this.refitNow(); + for (const canvas of [this.canvas, this.labelCanvas]) { + if (!canvas) continue; + canvas.style.transition = faded ? 'none' : `opacity ${TRANSITION_FADE_MS}ms ease`; + canvas.style.opacity = faded ? '0' : '1'; + } + } + + /** + * Fit + paint every deferred outline immediately (no settle wait). Shared + * by the settle timer and the reveal path above; cheap when nothing moved + * (unchanged identity/position keys skip the fit). + */ + refitNow() { + if (this.killed) return; + clearTimeout(this.settleHandle); + this.settleHandle = null; + this.forceRefit = true; + try { + this.#paint(); + } finally { + this.forceRefit = false; + } + } + /** Show or hide every bubble, on screen and in both export paths. */ setVisible(visible) { if (this.visible === visible) return; @@ -177,13 +216,7 @@ class BubbleSetLayer { clearTimeout(this.settleHandle); this.settleHandle = setTimeout(() => { this.settleHandle = null; - if (this.killed) return; - this.forceRefit = true; - try { - this.#paint(); - } finally { - this.forceRefit = false; - } + this.refitNow(); }, REFIT_SETTLE_MS); } diff --git a/src/graph/core.js b/src/graph/core.js index af83f51..03c4475 100644 --- a/src/graph/core.js +++ b/src/graph/core.js @@ -76,7 +76,13 @@ class GraphCoreManager { } await this.cache.ui.showLoading('Loading', 'Rendering graph ..'); await new Promise((resolve) => requestAnimationFrame(resolve)); - return await this.cache.graph.render(); + const rendered = await this.cache.graph.render(); + // Consume the flag only after the render succeeded (a failed render + // keeps it up so the next call re-renders). Left un-reset, the first + // Arrange/Re-layout of a session forced the full re-indexing render + // branch on every later style- or filter-only update. + this.cache.layoutChanged = false; + return rendered; } else { await this.cache.ui.showLoading('Loading', 'Redrawing graph ..'); await new Promise((resolve) => requestAnimationFrame(resolve)); diff --git a/src/graph/interactions.js b/src/graph/interactions.js index 921add1..e93e503 100644 --- a/src/graph/interactions.js +++ b/src/graph/interactions.js @@ -136,7 +136,11 @@ class InteractionManager { } } // Pin the normalization bbox: without it every x/y write re-normalizes - // the coordinate space and the graph swims under the cursor. + // the coordinate space and the graph swims under the cursor. Released on + // mouseup (the pin's lifetime is exactly the gesture); full renders + // (SigmaAdapter.render) and fitView clear it too — a pin left in place + // froze normalization across workspace switches and rendered workspaces + // with a different coordinate range off-screen. const sigma = this.adapter.sigma; if (!sigma.getCustomBBox()) sigma.setCustomBBox(sigma.getBBox()); } @@ -176,6 +180,12 @@ class InteractionManager { if (graph.hasNode(id)) graph.mergeNodeAttributes(id, { forceLabel: false }); } this.pinnedLabels = null; + // Release the normalization pin taken in #onDownNode: its lifetime is + // exactly the gesture. Left in place, a full render fired mid-drag by + // something else (a filter event, an expand) would release it under the + // cursor instead — and until that render the frozen bbox distorts every + // extent-changing update. + this.adapter.sigma.setCustomBBox(null); if (!moved) return; // Set synchronously before any await: sigma emits clickNode right after // mouseup with no microtask boundary, so the flag must already be up. diff --git a/src/graph/layout.js b/src/graph/layout.js index b6a0b61..d4f65a8 100644 --- a/src/graph/layout.js +++ b/src/graph/layout.js @@ -35,24 +35,33 @@ class GraphLayoutManager { // hide-disconnected finish. Released right before the position tween (which // is meant to animate with the overlay clear) and again in finally. this.cache.ui.holdLoading(); - await new Promise((resolve) => requestAnimationFrame(resolve)); - const currentLayout = this.cache.data.layouts[this.cache.data.selectedLayout]; - - // Animate node positions from the outgoing workspace to this one when it - // carries persisted positions. The adapter leaves positions in place - // through the render (pendingLayoutTransition) and tweens them once the - // loading overlay clears (runLayoutTransition, last step below). A - // position-less view (fresh template) has nothing to tween from/to and - // takes the normal snap path. - const animatePositions = currentLayout.positions?.size > 0; - this.cache.graph.pendingLayoutTransition = animatePositions; - - // finally: never leave pendingLayoutTransition stuck on. If any step below - // throws before runLayoutTransition consumes it, every later render would - // otherwise skip #applyPersistedPositions and freeze nodes at the outgoing - // workspace for the adapter's lifetime. + // The try starts HERE, immediately after the hold: a throw anywhere below + // (e.g. #selectView naming a workspace that no longer exists) must reach + // the finally, or the leaked hold blocks every hideLoading() forever and + // bricks the UI until reload. The finally also clears + // pendingLayoutTransition — left on, every later render would skip + // #applyPersistedPositions and freeze nodes at the outgoing workspace. try { + await new Promise((resolve) => requestAnimationFrame(resolve)); + + // The incoming workspace's styles, visibility flips and group set all + // repaint the bubble hulls in place below — the new groups' colors on the + // OLD shape, visible through the overlay. Hide the hulls for the whole + // switch; revealed (refit + fade-in) by runLayoutTransition or finally. + this.cache.graph?.bubbleLayer?.setFaded(true); + + const currentLayout = this.cache.data.layouts[this.cache.data.selectedLayout]; + + // Animate node positions from the outgoing workspace to this one when it + // carries persisted positions. The adapter leaves positions in place + // through the render (pendingLayoutTransition) and tweens them once the + // loading overlay clears (runLayoutTransition, last step below). A + // position-less view (fresh template) has nothing to tween from/to and + // takes the normal snap path. + const animatePositions = currentLayout.positions?.size > 0; + this.cache.graph.pendingLayoutTransition = animatePositions; + // Apply per-view node and edge styles (positions held at the outgoing // view's when animating, so the tween starts from what's on screen). await this.applyLayoutStyles(currentLayout, animatePositions); @@ -108,6 +117,13 @@ class GraphLayoutManager { this.cache.ui.releaseLoading(); await this.cache.ui.hideLoading(); if (this.cache.graph) this.cache.graph.pendingLayoutTransition = false; + // Reveal the hulls hidden at the top (refit + fade-in; no-op when + // runLayoutTransition already revealed them) — UNLESS a newer switch + // cancelled this one mid-tween and is still animating: it owns the + // fade now (layoutTransitionCancel is its live cancel handle). + if (!this.cache.graph?.layoutTransitionCancel) { + this.cache.graph?.bubbleLayer?.setFaded(false); + } } } @@ -344,38 +360,39 @@ class GraphLayoutManager { // still running. Released right before the position tween, and in finally. this.cache.ui.holdLoading(); - // Clear the filter lock since this is a fresh template with no query - this.cache.EVENT_LOCKS.FILTERS_LOCKED_BY_MANUAL_QUERY = false; - - // Clear selection FIRST before doing anything else - await this.cache.sm.toggleSelectionForAllNodes(false); - await this.cache.sm.toggleSelectionForAllEdges(false); - - // Update UI to show the new layout's filters and query - this.cache.ui.buildFilterUI(); - this.cache.qm.updateQueryTextArea(); - this.cache.ui.updateFilterLockState(); - this.cache.ui.clearActivePropsCacheOnLayoutChange(); - - // Process filters to determine which nodes should be visible - await this.cache.gcm.preRenderEvent(); + // The try starts immediately after the hold: any failure below — + // selection clears, the filter pass, the layout worker rejecting — + // must release the hold, drop the overlay and clear + // pendingLayoutTransition, or the leaked hold blocks every + // hideLoading() until reload. + try { + // Clear the filter lock since this is a fresh template with no query + this.cache.EVENT_LOCKS.FILTERS_LOCKED_BY_MANUAL_QUERY = false; - // Snapshot the on-screen (outgoing-workspace) positions so the new - // template layout animates IN from them instead of snapping — same - // effect as switching between existing workspaces. graphData is y-up - // graphology, which is exactly what runLayoutTransition tweens toward. - const fromPositions = new Map(); - this.cache.graphData?.forEachNode((id, attrs) => { - if (Number.isFinite(attrs.x) && Number.isFinite(attrs.y)) { - fromPositions.set(id, { x: attrs.x, y: attrs.y }); - } - }); + // Clear selection FIRST before doing anything else + await this.cache.sm.toggleSelectionForAllNodes(false); + await this.cache.sm.toggleSelectionForAllEdges(false); + + // Update UI to show the new layout's filters and query + this.cache.ui.buildFilterUI(); + this.cache.qm.updateQueryTextArea(); + this.cache.ui.updateFilterLockState(); + this.cache.ui.clearActivePropsCacheOnLayoutChange(); + + // Process filters to determine which nodes should be visible + await this.cache.gcm.preRenderEvent(); + + // Snapshot the on-screen (outgoing-workspace) positions so the new + // template layout animates IN from them instead of snapping — same + // effect as switching between existing workspaces. graphData is y-up + // graphology, which is exactly what runLayoutTransition tweens toward. + const fromPositions = new Map(); + this.cache.graphData?.forEachNode((id, attrs) => { + if (Number.isFinite(attrs.x) && Number.isFinite(attrs.y)) { + fromPositions.set(id, { x: attrs.x, y: attrs.y }); + } + }); - // setLayout/layout (possibly the off-thread worker), the full render - // pipeline and the position tween all run under one try so any failure — - // including the layout worker rejecting — releases the loading hold, - // drops the overlay and clears pendingLayoutTransition. - try { // Apply the layout algorithm once await this.cache.graph.setLayout({ type: result.templateType, @@ -667,16 +684,19 @@ class GraphLayoutManager { // before the position tween, and again in finally. this.cache.ui.holdLoading(); - // Snapshot the on-screen positions so the new layout animates IN from them - // instead of snapping (same approach as the addLayout template branch). - const fromPositions = new Map(); - this.cache.graphData?.forEachNode((id, attrs) => { - if (Number.isFinite(attrs.x) && Number.isFinite(attrs.y)) { - fromPositions.set(id, { x: attrs.x, y: attrs.y }); - } - }); - + // Try starts immediately after the hold (a throw before the finally would + // leak it and block every hideLoading() until reload). try { + // Snapshot the on-screen positions so the new layout animates IN from + // them instead of snapping (same approach as the addLayout template + // branch). + const fromPositions = new Map(); + this.cache.graphData?.forEachNode((id, attrs) => { + if (Number.isFinite(attrs.x) && Number.isFinite(attrs.y)) { + fromPositions.set(id, { x: attrs.x, y: attrs.y }); + } + }); + await this.cache.graph.setLayout({ type: layoutType, ...this.cache.DEFAULTS.LAYOUT_INTERNALS[layoutType], diff --git a/src/graph/sigma_adapter.js b/src/graph/sigma_adapter.js index 25d0c51..7c1a0f0 100644 --- a/src/graph/sigma_adapter.js +++ b/src/graph/sigma_adapter.js @@ -434,6 +434,13 @@ class SigmaAdapter { async render() { if (this.killed) return false; if (this.pendingLayout) await this.layout(); + // Release the normalization bbox pinned by node drags (InteractionManager + // pins it on mousedown; only fitView used to release it). A full render + // must re-derive normalization from the CURRENT node extent: workspace + // templates put coordinates in very different ranges (grid [0, cols·100] + // vs circular [-r, r]), so a bbox frozen in one workspace rendered the + // next one off-screen — "switched and the graph is gone". + this.sigma.setCustomBBox(null); await this.#applyPersistedPositions(); this.#syncLabelVisibility(); @@ -842,31 +849,58 @@ class SigmaAdapter { snap(); this.sigma.refresh({ skipIndexation: true }); } else { + // Bubble hulls can't track the tween (position refits defer while nodes + // are in motion), so they'd trail the nodes and snap at the end. Fade + // them out for the tween; faded back in after the settled redraw below. + this.bubbleLayer?.setFaded(true); // A prior tween still running (rapid switches): cancel before starting a // new one so they don't fight over the same node attributes. this.layoutTransitionCancel?.(); + let cancelled = false; + let myCancel; await new Promise((resolve) => { - this.layoutTransitionCancel = animateNodes( + const cancel = animateNodes( this.graph, targets, { duration: LAYOUT_TRANSITION_MS, easing: 'cubicInOut' }, resolve ); + // animateNodes never calls back on cancel, so resolve explicitly — a + // cancelled switch (rapid re-switch, destroy) used to strand its + // caller awaiting forever (changeLayout skipped history.reset and its + // finally block). + myCancel = () => { + cancelled = true; + cancel(); + resolve(); + }; + this.layoutTransitionCancel = myCancel; }); - this.layoutTransitionCancel = null; + // Only clear our own handle: a newer transition may have installed its + // cancel while we were suspended. + if (this.layoutTransitionCancel === myCancel) this.layoutTransitionCancel = null; + // Cancelled means a newer transition (or destroy) took over mid-tween; + // it owns the node positions, the nodeRef mirror and the bubble fade now. + if (cancelled) return; } - // Mirror the settled positions back into the nodeRef cache (the app-model - // store the rest of the code reads), then redraw bubble hulls at the - // final positions (they were last drawn at the outgoing view's layout). - for (const [id, pos] of positionsMap) { - const ref = this.cache.nodeRef.get(id); - if (ref && Number.isFinite(pos?.style?.x) && Number.isFinite(pos?.style?.y)) { - ref.style.x = pos.style.x; - ref.style.y = pos.style.y; + try { + // Mirror the settled positions back into the nodeRef cache (the app-model + // store the rest of the code reads), then redraw bubble hulls at the + // final positions (they were last drawn at the outgoing view's layout). + for (const [id, pos] of positionsMap) { + const ref = this.cache.nodeRef.get(id); + if (ref && Number.isFinite(pos?.style?.x) && Number.isFinite(pos?.style?.y)) { + ref.style.x = pos.style.x; + ref.style.y = pos.style.y; + } } + if (!this.killed) await this.cache.bs?.redrawBubbleSets?.(); + } finally { + // Fade back in over the fresh hulls (no-op on the snap path, which + // never faded out). + this.bubbleLayer?.setFaded(false); } - if (!this.killed) await this.cache.bs?.redrawBubbleSets?.(); } // ------------------------------------------------------------- interactions diff --git a/src/graph/workspace_dialog.js b/src/graph/workspace_dialog.js index b5b2457..cd4cfe3 100644 --- a/src/graph/workspace_dialog.js +++ b/src/graph/workspace_dialog.js @@ -24,6 +24,9 @@ function openWorkspaceCreationDialog(layoutInternals) { nameInput.style.width = '100%'; nameInput.style.marginBottom = '20px'; nameInput.style.padding = '8px'; + // Clears the "name required" validity mark from handleCreate as soon as + // the user starts typing again. + nameInput.addEventListener('input', () => nameInput.setCustomValidity('')); container.appendChild(nameInput); // Mode selection @@ -147,7 +150,12 @@ function openWorkspaceCreationDialog(layoutInternals) { const handleCreate = () => { const name = nameInput.value.trim(); if (!name) { - alert('Please enter a name for the layout'); + // Native constraint bubble, not window.alert(): alert blocks the + // renderer thread outright (automation hangs with no diagnostic) and + // was the last native dialog in a codebase that uses Popup everywhere. + nameInput.setCustomValidity('Please enter a name for the workspace'); + nameInput.reportValidity(); + nameInput.focus(); return; } diff --git a/tests/bubble-layer-canvas-css-size.test.js b/tests/bubble-layer-canvas-css-size.test.js index 320d492..3a51dad 100644 --- a/tests/bubble-layer-canvas-css-size.test.js +++ b/tests/bubble-layer-canvas-css-size.test.js @@ -167,3 +167,48 @@ describe('BubbleSetLayer — canvas CSS display size (non-primary DPR bug)', () expect(canvas.style.height).toBe('600px'); }); }); + +describe('BubbleSetLayer — setFaded (workspace switch)', () => { + it('hides instantly and reveals with an eased fade', () => { + const { sigma, canvas, labelCanvas } = makeSigma(1, { width: 800, height: 600 }); + const layer = new BubbleSetLayer({ sigma, graph: makeGraph(TRI) }, makeCache()); + + // Hiding must be instant: an eased fade-out would still show the incoming + // groups' colors repainted onto the outgoing shape. + layer.setFaded(true); + expect(canvas.style.opacity).toBe('0'); + expect(labelCanvas.style.opacity).toBe('0'); + expect(canvas.style.transition).toBe('none'); + + layer.setFaded(false); + expect(canvas.style.opacity).toBe('1'); + expect(labelCanvas.style.opacity).toBe('1'); + expect(canvas.style.transition).toContain('opacity'); + }); + + it('refits a hull the deferral left at stale positions BEFORE revealing', () => { + const { sigma } = makeSigma(1, { width: 800, height: 600 }); + const graph = makeGraph(TRI); + const layer = new BubbleSetLayer({ sigma, graph }, makeCache()); + layer.getGroupHandle('groupOne').update({ + members: ['a', 'b', 'c'], + label: false, + avoidance: 0, + }); + flushRaf(); + const staleKey = layer.outlines.get('groupOne').key; + + layer.setFaded(true); + // Pretend fits are expensive, then move a member: the paint loop coasts + // on the cached (now stale) hull instead of refitting. + layer.fitDurations.set('groupOne', 500); + graph.getNodeAttributes('a').x = 400; + layer.scheduleRedraw(); + flushRaf(); + expect(layer.outlines.get('groupOne').key).toBe(staleKey); + + // Reveal must never show that stale hull — it refits synchronously first. + layer.setFaded(false); + expect(layer.outlines.get('groupOne').key).not.toBe(staleKey); + }); +}); diff --git a/tests/change-layout-bubble-fade.test.js b/tests/change-layout-bubble-fade.test.js new file mode 100644 index 0000000..6b3c7ad --- /dev/null +++ b/tests/change-layout-bubble-fade.test.js @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { GraphLayoutManager } from '../src/graph/layout.js'; + +// ========================================================================== +// changeLayout × bubble hulls: switching between workspaces with different +// groups used to flash the INCOMING groups' colors on the OUTGOING shape +// (the group sync repaints the layer before the tween), then briefly show +// the stale shape again after the tween. The choreography under test: +// - the hulls are hidden BEFORE any incoming state (styles, group sync) +// can repaint them, and revealed again when the switch is done +// - a switch that was cancelled mid-tween by a newer one must NOT reveal +// the hulls while the newer switch is still animating (its live cancel +// handle marks that ownership) +// ========================================================================== + +function createCache(calls) { + const asyncNoop = async () => {}; + const noop = () => {}; + const graph = { + updateNodeData: asyncNoop, + updateEdgeData: asyncNoop, + pendingLayoutTransition: false, + layoutTransitionCancel: null, + runLayoutTransition: vi.fn(async () => calls.push('tween')), + bubbleLayer: { + setFaded: vi.fn((faded) => calls.push(faded ? 'hide' : 'reveal')), + }, + }; + return { + nodeRef: new Map(), + edgeRef: new Map(), + EVENT_LOCKS: {}, + data: { + selectedLayout: 'Circle', + layouts: { + Circle: { positions: new Map([['a', { style: { x: 1, y: 2 } }]]) }, + }, + }, + ui: { + showLoading: asyncNoop, hideLoading: asyncNoop, holdLoading: noop, + releaseLoading: noop, buildFilterUI: noop, updateFilterLockState: noop, + clearActivePropsCacheOnLayoutChange: noop, info: noop, debug: noop, + }, + qm: { updateQueryTextArea: noop }, + metrics: { updateMetricUI: asyncNoop }, + gcm: { decideToRenderOrDraw: asyncNoop, applyHideDisconnectedState: asyncNoop }, + bs: { + updateBubbleSetIfChanged: vi.fn(async () => calls.push('group-sync')), + renderGroupList: noop, + refreshBubbleStyleElements: noop, + }, + history: { reset: noop }, + graph, + }; +} + +let rafQueue = []; +beforeEach(() => { + rafQueue = []; + globalThis.requestAnimationFrame = (cb) => { + // changeLayout awaits one frame right after showLoading — run it inline. + cb(); + return 1; + }; + document.body.innerHTML = ''; + document.getElementById('selectView').value = 'Circle'; +}); + +describe('changeLayout — bubble hull hand-off', () => { + it('hides the hulls before the group sync and reveals them at the end', async () => { + const calls = []; + const cache = createCache(calls); + await new GraphLayoutManager(cache).changeLayout(); + + expect(calls.indexOf('hide')).toBeGreaterThanOrEqual(0); + expect(calls.indexOf('hide')).toBeLessThan(calls.indexOf('group-sync')); + expect(calls.indexOf('group-sync')).toBeLessThan(calls.indexOf('tween')); + expect(calls[calls.length - 1]).toBe('reveal'); + }); + + it('releases the loading hold and reveals when the selected workspace is missing', async () => { + const calls = []; + const cache = createCache(calls); + cache.ui.releaseLoading = vi.fn(); + cache.ui.hideLoading = vi.fn(async () => {}); + // #selectView names a workspace that no longer exists → the layout lookup + // throws. A leaked hold would block every hideLoading() until reload. + delete cache.data.layouts.Circle; + + await expect(new GraphLayoutManager(cache).changeLayout()).rejects.toThrow(); + + expect(cache.ui.releaseLoading).toHaveBeenCalled(); + expect(cache.ui.hideLoading).toHaveBeenCalled(); + expect(calls).toContain('reveal'); + expect(cache.graph.pendingLayoutTransition).toBe(false); + }); + + it('does not reveal when a newer switch owns the tween (cancelled mid-flight)', async () => { + const calls = []; + const cache = createCache(calls); + // Simulate being cancelled: when this switch's tween returns, the NEWER + // switch's cancel handle is live on the adapter. + cache.graph.runLayoutTransition = vi.fn(async () => { + cache.graph.layoutTransitionCancel = () => {}; + }); + await new GraphLayoutManager(cache).changeLayout(); + + expect(calls).toContain('hide'); + expect(calls).not.toContain('reveal'); + }); +}); diff --git a/tests/decide-render-layout-flag.test.js b/tests/decide-render-layout-flag.test.js new file mode 100644 index 0000000..1b3ae65 --- /dev/null +++ b/tests/decide-render-layout-flag.test.js @@ -0,0 +1,70 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GraphCoreManager } from '../src/graph/core.js'; + +// ========================================================================== +// decideToRenderOrDraw × cache.layoutChanged. The flag is raised by +// handleLayoutChangeLoadingEvent (Arrange/Re-layout) but was never reset, so +// the FIRST arrange of a session forced the full re-indexing render() branch +// onto every later style- or filter-only update. Contract: a successful +// render consumes the flag; a failed one keeps it up so the next call +// re-renders. +// ========================================================================== + +function createCache() { + const asyncNoop = async () => {}; + return { + layoutChanged: false, + styleChanged: false, + bubbleSetChanged: false, + EVENT_LOCKS: {}, + ui: { showLoading: asyncNoop, hideLoading: asyncNoop, error: vi.fn() }, + metrics: { updateMetricUI: asyncNoop }, + graph: { render: vi.fn(async () => true), draw: vi.fn(async () => true) }, + }; +} + +function createManager(cache) { + const gcm = new GraphCoreManager(cache); + // The real preRenderEvent runs the whole filter pipeline — irrelevant here. + gcm.preRenderEvent = async () => {}; + return gcm; +} + +beforeEach(() => { + globalThis.requestAnimationFrame = (cb) => { + cb(); + return 1; + }; +}); + +describe('decideToRenderOrDraw — layoutChanged lifecycle', () => { + it('consumes the flag after a successful render, so later updates draw', async () => { + const cache = createCache(); + const gcm = createManager(cache); + + cache.layoutChanged = true; + await gcm.decideToRenderOrDraw(); + expect(cache.graph.render).toHaveBeenCalledTimes(1); + expect(cache.layoutChanged).toBe(false); + + // A style/filter-only follow-up must take the cheap draw branch again. + await gcm.decideToRenderOrDraw(); + expect(cache.graph.render).toHaveBeenCalledTimes(1); + expect(cache.graph.draw).toHaveBeenCalledTimes(1); + }); + + it('keeps the flag up when the render throws, so the next call re-renders', async () => { + const cache = createCache(); + const gcm = createManager(cache); + cache.graph.render.mockRejectedValueOnce(new Error('boom')); + + cache.layoutChanged = true; + await gcm.decideToRenderOrDraw(); + expect(cache.layoutChanged).toBe(true); + + await gcm.decideToRenderOrDraw(); + expect(cache.graph.render).toHaveBeenCalledTimes(2); + expect(cache.layoutChanged).toBe(false); + }); +}); diff --git a/tests/interactions-drag.test.js b/tests/interactions-drag.test.js index 1bd028c..8ba6763 100644 --- a/tests/interactions-drag.test.js +++ b/tests/interactions-drag.test.js @@ -123,6 +123,23 @@ describe("drag label pinning", () => { }); }); +describe("drag normalization pin", () => { + it("pins the bbox on downNode and releases it on mouseup (even without movement)", async () => { + const { sigma } = makeManager(); + const calls = []; + sigma.setCustomBBox = (v) => calls.push(v); + + sigma.handlers.downNode({ node: "a" }); + expect(calls).toEqual([{ x: [0, 1], y: [0, 1] }]); + + // The pin's lifetime is exactly the gesture: left in place it froze + // sigma's normalization and rendered workspaces whose coordinates live + // in a different range off-screen. + await sigma.captorHandlers.mouseup(); + expect(calls[calls.length - 1]).toBe(null); + }); +}); + describe("node drag movement", () => { it("does not persist anything on mouseup without movement", async () => { const { sigma, cache } = makeManager(); diff --git a/tests/layout-transition-cancel.test.js b/tests/layout-transition-cancel.test.js new file mode 100644 index 0000000..1177b04 --- /dev/null +++ b/tests/layout-transition-cancel.test.js @@ -0,0 +1,166 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Graph } from '../src/lib/graphology.bundle.mjs'; + +// The sigma bundle dereferences the WebGL context interfaces at module scope +// (program constants); jsdom doesn't define them. Bare stand-ins are enough — +// nothing here renders. +vi.hoisted(() => { + globalThis.WebGLRenderingContext ??= class {}; + globalThis.WebGL2RenderingContext ??= class {}; + // The bundle also probes a context for its capability constants; jsdom's + // getContext throws "not implemented". A do-nothing GL satisfies the probe. + const fakeGL = new Proxy( + {}, + { + get: (target, key) => { + if (typeof key === 'symbol') return undefined; + if (key === 'canvas') return document.createElement('canvas'); + return () => 0; + }, + } + ); + globalThis.HTMLCanvasElement.prototype.getContext = () => fakeGL; +}); +const { SigmaAdapter } = await import('../src/graph/sigma_adapter.js'); + +// ========================================================================== +// runLayoutTransition: cancellation + bubble fade (workspace switching). +// +// The bundled animateNodes never invokes its completion callback when +// cancelled — cancelling only does cancelAnimationFrame. A workspace tween +// cancelled by a rapid second switch therefore left the first changeLayout +// awaiting forever (its history.reset and finally block never ran). The +// contract under test: +// - a cancelled transition RESOLVES (no hung caller) and hands ownership +// (nodeRef mirror, bubble fade, cancel handle) to the newer transition +// - the bubble canvases fade out for the tween and back in after the +// settled redraw; a cancelled transition must NOT undo the newer one's +// fade-out +// +// runLayoutTransition uses no private fields, so it runs via prototype.call +// on a stub `this` — constructing a real SigmaAdapter needs WebGL. +// ========================================================================== + +/** Minimal `this` for runLayoutTransition. */ +function makeAdapterStub(nodeIds) { + const graph = new Graph(); + for (const id of nodeIds) graph.addNode(id, { x: 0, y: 0 }); + return { + graph, + killed: false, + pendingLayoutTransition: true, + layoutTransitionCancel: null, + sigma: { refresh: vi.fn() }, + bubbleLayer: { setFaded: vi.fn() }, + cache: { + nodeRef: new Map(nodeIds.map((id) => [id, { style: { x: 0, y: 0 } }])), + bs: { redrawBubbleSets: vi.fn(async () => {}) }, + }, + }; +} + +const positionsFor = (nodeIds, x, y) => + new Map(nodeIds.map((id) => [id, { style: { x, y } }])); + +const run = (stub, positions) => + SigmaAdapter.prototype.runLayoutTransition.call(stub, positions); + +// animateNodes drives itself with requestAnimationFrame + Date.now. Queue the +// frames and fake the clock so the tween completes deterministically. +let rafQueue = []; +const flushFrame = () => { + const queued = rafQueue; + rafQueue = []; + for (const cb of queued) cb(performance.now()); +}; +/** Advance past the tween duration and pump frames + microtasks until quiet. */ +async function finishTween() { + vi.setSystemTime(Date.now() + 10_000); + for (let i = 0; i < 10 && rafQueue.length > 0; i++) { + flushFrame(); + await Promise.resolve(); + } + await Promise.resolve(); +} + +beforeEach(() => { + vi.useFakeTimers(); + rafQueue = []; + vi.stubGlobal('requestAnimationFrame', (cb) => { + rafQueue.push(cb); + return rafQueue.length; + }); + vi.stubGlobal('cancelAnimationFrame', () => {}); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('runLayoutTransition', () => { + it('completes a tween: nodes land on targets, nodeRef mirrored, fade out then in', async () => { + const stub = makeAdapterStub(['a', 'b']); + const done = run(stub, positionsFor(['a', 'b'], 100, 50)); + expect(stub.pendingLayoutTransition).toBe(false); + expect(stub.bubbleLayer.setFaded).toHaveBeenCalledWith(true); + + await finishTween(); + await done; + + expect(stub.graph.getNodeAttributes('a').x).toBe(100); + expect(stub.cache.nodeRef.get('a').style).toEqual({ x: 100, y: 50 }); + expect(stub.cache.bs.redrawBubbleSets).toHaveBeenCalledTimes(1); + expect(stub.bubbleLayer.setFaded).toHaveBeenLastCalledWith(false); + expect(stub.layoutTransitionCancel).toBe(null); + }); + + it('resolves a cancelled tween instead of hanging its caller', async () => { + const stub = makeAdapterStub(['a']); + const first = run(stub, positionsFor(['a'], 100, 0)); + const second = run(stub, positionsFor(['a'], 200, 0)); // cancels the first + + let firstSettled = false; + first.then(() => { + firstSettled = true; + }); + await Promise.resolve(); + await Promise.resolve(); + expect(firstSettled).toBe(true); + + await finishTween(); + await second; + expect(stub.graph.getNodeAttributes('a').x).toBe(200); + }); + + it('a cancelled transition leaves fade and cancel handle to the newer one', async () => { + const stub = makeAdapterStub(['a']); + const first = run(stub, positionsFor(['a'], 100, 0)); + const second = run(stub, positionsFor(['a'], 200, 0)); + await first; + + // The first transition must not have faded the bubbles back in (the + // second is still tweening) nor cleared the second's cancel handle. + expect(stub.bubbleLayer.setFaded).not.toHaveBeenCalledWith(false); + expect(stub.layoutTransitionCancel).not.toBe(null); + // Ownership: the cancelled run skips the nodeRef mirror and the redraw. + expect(stub.cache.bs.redrawBubbleSets).not.toHaveBeenCalled(); + + await finishTween(); + await second; + expect(stub.bubbleLayer.setFaded).toHaveBeenLastCalledWith(false); + expect(stub.layoutTransitionCancel).toBe(null); + expect(stub.cache.nodeRef.get('a').style.x).toBe(200); + }); + + it('snaps without fading past the node budget', async () => { + const ids = Array.from({ length: 2001 }, (_, i) => `n${i}`); + const stub = makeAdapterStub(ids); + await run(stub, positionsFor(ids, 42, 7)); + + expect(stub.graph.getNodeAttributes('n0').x).toBe(42); + expect(stub.sigma.refresh).toHaveBeenCalledWith({ skipIndexation: true }); + // Never faded out; the shared tail may harmlessly re-assert visibility. + expect(stub.bubbleLayer.setFaded).not.toHaveBeenCalledWith(true); + }); +}); diff --git a/tests/workspace-dialog-validation.test.js b/tests/workspace-dialog-validation.test.js new file mode 100644 index 0000000..7b1e6a8 --- /dev/null +++ b/tests/workspace-dialog-validation.test.js @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { openWorkspaceCreationDialog } from '../src/graph/workspace_dialog.js'; + +// ========================================================================== +// Empty-name validation in the workspace creation dialog. It used to call +// window.alert() — the only native dialog left in the codebase, and one that +// blocks the renderer thread outright (an automation harness hangs with no +// diagnostic). Contract: an empty submit marks the input invalid via the +// constraint-validation API, keeps the dialog open, and never touches +// alert(); typing clears the mark. +// ========================================================================== + +const INTERNALS = { force: {}, grid: {}, circular: {} }; + +beforeEach(() => { + document.body.innerHTML = ''; + window.alert = vi.fn(() => { + throw new Error('window.alert must not be called'); + }); +}); + +function clickCreate() { + const buttons = [...document.querySelectorAll('button')]; + buttons.find((b) => b.textContent === 'Create').click(); +} + +describe('workspace creation dialog — empty name', () => { + it('flags the input instead of alert(), and stays open', async () => { + let settled = false; + const dialog = openWorkspaceCreationDialog(INTERNALS).then((r) => { + settled = true; + return r; + }); + await Promise.resolve(); + + clickCreate(); + await Promise.resolve(); + + const nameInput = document.querySelector('input[type="text"]'); + expect(window.alert).not.toHaveBeenCalled(); + expect(nameInput.validationMessage).toBe('Please enter a name for the workspace'); + expect(settled).toBe(false); + + // Typing clears the mark; submitting then resolves normally. + nameInput.value = 'My workspace'; + nameInput.dispatchEvent(new Event('input')); + expect(nameInput.validationMessage).toBe(''); + + clickCreate(); + await expect(dialog).resolves.toMatchObject({ name: 'My workspace', mode: 'clone' }); + }); +});