test(builder): cover useVisualViewport; fix: disable Select-Parent at ROOT in desktop context menu
- useVisualViewport.test.tsx: covers the visualViewport-undefined fallback, the keyboardInset math on a mocked visualViewport resize, and listener add/remove (resize + scroll) across mount/unmount. - ContextMenu.tsx: Select Parent was only disabled at isRoot, so selecting a top-level section and choosing Select Parent silently landed on the un-editable ROOT (no outline, no toolbar) -- a dead end. Switched the guard to useNodeActions' canSelectParent (false whenever the node's parent is ROOT or missing), matching the mobile selection toolbar's existing identical guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { useVisualViewportInsets } from './useVisualViewport';
|
||||
|
||||
/**
|
||||
* Fast-follow coverage for `useVisualViewportInsets` (BottomSheet's
|
||||
* keyboard-avoidance source of truth). Mirrors the bare createRoot + act
|
||||
* harness `useIsMobile.test.tsx` uses (no @testing-library/react in this
|
||||
* repo). Covers:
|
||||
* - the no-`visualViewport` fallback (older WebViews) returns
|
||||
* `{ height: window.innerHeight, keyboardInset: 0 }` AND registers no
|
||||
* listener (there's nothing to listen to).
|
||||
* - the keyboard-open math: `keyboardInset = innerHeight - (vv.height +
|
||||
* vv.offsetTop)`, recomputed when the mocked vv fires `resize`.
|
||||
* - both `resize` and `scroll` listeners are added on mount and the SAME
|
||||
* handler is removed for both on unmount (a leaked listener would keep
|
||||
* reacting after the owning component -- e.g. BottomSheet -- is gone).
|
||||
*/
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let originalInnerHeight: number;
|
||||
let originalVisualViewport: VisualViewport | null;
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
}
|
||||
|
||||
/** A minimal fake `visualViewport`: just enough surface for the hook
|
||||
* (`height`, `offsetTop`, `addEventListener`/`removeEventListener`). */
|
||||
function makeVisualViewport(height: number, offsetTop: number) {
|
||||
const listeners: Record<string, Array<() => void>> = { resize: [], scroll: [] };
|
||||
const vv: any = {
|
||||
height,
|
||||
offsetTop,
|
||||
addEventListener: vi.fn((type: string, fn: () => void) => {
|
||||
listeners[type].push(fn);
|
||||
}),
|
||||
removeEventListener: vi.fn((type: string, fn: () => void) => {
|
||||
const i = listeners[type].indexOf(fn);
|
||||
if (i !== -1) listeners[type].splice(i, 1);
|
||||
}),
|
||||
};
|
||||
return {
|
||||
vv,
|
||||
fire: (type: 'resize' | 'scroll') => listeners[type].forEach((fn) => fn()),
|
||||
listenerCount: (type: 'resize' | 'scroll') => listeners[type].length,
|
||||
};
|
||||
}
|
||||
|
||||
let probed: { height: number; keyboardInset: number } | null = null;
|
||||
const Probe: React.FC = () => {
|
||||
probed = useVisualViewportInsets();
|
||||
return <span data-height={probed.height} data-inset={probed.keyboardInset} />;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
originalInnerHeight = window.innerHeight;
|
||||
originalVisualViewport = window.visualViewport;
|
||||
probed = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'innerHeight', {
|
||||
value: originalInnerHeight,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(window, 'visualViewport', {
|
||||
value: originalVisualViewport,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('useVisualViewportInsets', () => {
|
||||
test('falls back to innerHeight/0 and registers no listener when visualViewport is undefined', () => {
|
||||
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true });
|
||||
Object.defineProperty(window, 'visualViewport', { value: undefined, configurable: true });
|
||||
|
||||
render(<Probe />);
|
||||
|
||||
expect(probed).toEqual({ height: 800, keyboardInset: 0 });
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('computes keyboardInset from visualViewport and recomputes on resize', () => {
|
||||
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true });
|
||||
const { vv, fire } = makeVisualViewport(800, 0);
|
||||
Object.defineProperty(window, 'visualViewport', { value: vv, configurable: true });
|
||||
|
||||
render(<Probe />);
|
||||
|
||||
// No keyboard open: vv fills the layout viewport exactly.
|
||||
expect(probed).toEqual({ height: 800, keyboardInset: 0 });
|
||||
|
||||
// Simulate the on-screen keyboard opening: the visual viewport shrinks.
|
||||
vv.height = 500;
|
||||
vv.offsetTop = 0;
|
||||
act(() => {
|
||||
fire('resize');
|
||||
});
|
||||
|
||||
expect(probed).toEqual({ height: 500, keyboardInset: 300 });
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('registers resize and scroll listeners on mount and removes both (same handler) on unmount', () => {
|
||||
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true });
|
||||
const { vv, listenerCount } = makeVisualViewport(800, 0);
|
||||
Object.defineProperty(window, 'visualViewport', { value: vv, configurable: true });
|
||||
|
||||
render(<Probe />);
|
||||
|
||||
expect(vv.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
|
||||
expect(vv.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
|
||||
expect(listenerCount('resize')).toBe(1);
|
||||
expect(listenerCount('scroll')).toBe(1);
|
||||
|
||||
const resizeHandler = vv.addEventListener.mock.calls.find((c: any[]) => c[0] === 'resize')[1];
|
||||
const scrollHandler = vv.addEventListener.mock.calls.find((c: any[]) => c[0] === 'scroll')[1];
|
||||
expect(resizeHandler).toBe(scrollHandler);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(vv.removeEventListener).toHaveBeenCalledWith('resize', resizeHandler);
|
||||
expect(vv.removeEventListener).toHaveBeenCalledWith('scroll', scrollHandler);
|
||||
expect(listenerCount('resize')).toBe(0);
|
||||
expect(listenerCount('scroll')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -195,7 +195,12 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
label: 'Select Parent',
|
||||
icon: 'level-up',
|
||||
action: selectParent,
|
||||
disabled: isRoot,
|
||||
// Not just `isRoot`: the real dead-end is when nodeId's PARENT is
|
||||
// ROOT (selecting a top-level section -> Select Parent would only
|
||||
// select the page-wide, un-editable ROOT -- no outline, no toolbar).
|
||||
// `canSelectParent` (useNodeActions) already encodes that and matches
|
||||
// the mobile selection toolbar's identical guard.
|
||||
disabled: !nodeActions.canSelectParent,
|
||||
dividerAfter: true,
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user