Files
AEmotionStudio-ComfyUI-Magn…/tests/unit/InfoPanelLifecycle.test.ts
T
Æmotion StudioandClaude Opus 5.5 e4cee7214e Fix Nodes 2.0 support and glass title emphasis
- Paint Vue nodes under the glass from the DOM, since the canvas capture
  has none of them.
- Resolve the Inspector's hovered node from the Vue node element under
  the cursor, falling back to the canvas hit test in pan mode.
- Fit the node highlight to collapsed nodes in both modes.
- Draw Node Title Emphasis on the title bar instead of the first slot
  row, and skip nodes without a title bar such as Reroute.
- Fill the glass with the page background so it is not half-empty at
  the canvas edge or wrong at zoom 1.5 and above.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 07:57:36 -08:00

1174 lines
56 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
const { settingsStore, addSetting } = vi.hoisted(() => {
const settingsStore: Record<string, unknown> = {};
// Like the ComfyUI frontend: onChange runs with the current value at registration
const addSetting = vi.fn((setting: { id: string; defaultValue: unknown; onChange?: (v: unknown) => void }) => {
setting.onChange?.(setting.id in settingsStore ? settingsStore[setting.id] : setting.defaultValue);
});
return { settingsStore, addSetting };
});
vi.mock('/scripts/app.js', () => ({
app: {
ui: {
settings: {
getSettingValue: (key: string) => settingsStore[key],
setSettingValue: (key: string, value: unknown) => { settingsStore[key] = value; },
addSetting
}
}
}
}));
import { InfoPanel, mountInfoPanel, unmountInfoPanel } from '../../src/info-panel/InfoPanel';
import { UIManager } from '../../src/info-panel/UIManager';
import { registerPanelSettings } from '../../src/shared/settings';
import { INFO_PANEL_ID } from '../../src/shared/constants';
import { DEFAULT_SETTINGS } from '../../src/shared/settings/defaults';
import type { MagnifyGlassInstance } from '../../src/types/comfyui';
class FakeUi {
glassDiv = document.createElement('div');
show() { this.glassDiv.style.display = 'block'; }
hide() { this.glassDiv.style.display = 'none'; }
positionGlass() { }
}
class FakeGlass {
state = { active: false };
config = {};
ui = new FakeUi();
renderer = null;
lastKnownMousePosition = { x: 0, y: 0 };
popOutManager = {
isPopOutOpen: () => false,
sendInfo: vi.fn(),
toggle: vi.fn(),
onStateChange: null as unknown,
onNodeSelect: null as unknown,
onWidgetEdit: null as unknown
};
setGlassPreviewActive = vi.fn();
updateMagnifiedView() { }
applyUiChanges() { }
toggle() { }
resetOffsets() { }
}
const asInstance = (mg: FakeGlass) => mg as unknown as MagnifyGlassInstance;
const pressKey = (key: string, init: KeyboardEventInit = {}) => {
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init });
document.dispatchEvent(event);
return event;
};
describe('Info Panel lifecycle', () => {
let mg: FakeGlass;
beforeEach(() => {
vi.useFakeTimers();
// Keep happy-dom from fetching the panel stylesheet
document.head.innerHTML = '<style id="magnify-info-panel-styles-v2"></style>';
for (const key of Object.keys(settingsStore)) delete settingsStore[key];
// Node's own localStorage global hides happy-dom's
vi.stubGlobal('localStorage', new Storage());
addSetting.mockClear();
(window as any).app = { canvas: { onDrawForeground: null, setDirty: vi.fn() }, graph: null };
mg = new FakeGlass();
});
afterEach(() => {
unmountInfoPanel(asInstance(mg));
window.comfyUIMagnifyGlassExtensions = undefined;
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('mount creates the DOM, the global and the hooks', () => {
mountInfoPanel(asInstance(mg));
expect(document.getElementById(INFO_PANEL_ID)).not.toBeNull();
expect(document.getElementById('magnify-glass-node-highlight')).not.toBeNull();
expect(window.infoPanelManager).toBeInstanceOf(InfoPanel);
expect(window.comfyUIMagnifyGlassExtensions).toEqual([window.infoPanelManager]);
expect(mg.updateMagnifiedView).not.toBe(FakeGlass.prototype.updateMagnifiedView);
expect(mg.ui.show).not.toBe(FakeUi.prototype.show);
expect(mg.ui.hide).not.toBe(FakeUi.prototype.hide);
expect(mg.popOutManager.onNodeSelect).toBeTypeOf('function');
expect(mg.setGlassPreviewActive).toHaveBeenCalledWith(true);
});
it('mount shows the floating controls when the glass is already active', () => {
mg.state.active = true;
mountInfoPanel(asInstance(mg));
expect(window.infoPanelManager!.uiManager.elements.controls!.style.display).toBe('flex');
});
it('mounting twice keeps a single instance', () => {
mountInfoPanel(asInstance(mg));
const first = window.infoPanelManager;
mountInfoPanel(asInstance(mg));
expect(window.infoPanelManager).toBe(first);
expect(window.comfyUIMagnifyGlassExtensions).toHaveLength(1);
expect(document.querySelectorAll(`#${INFO_PANEL_ID}`)).toHaveLength(1);
});
it('unmount removes the panel and undoes everything mount set up', () => {
const docAdd = vi.spyOn(document, 'addEventListener');
const docRemove = vi.spyOn(document, 'removeEventListener');
const winAdd = vi.spyOn(window, 'addEventListener');
const winRemove = vi.spyOn(window, 'removeEventListener');
mountInfoPanel(asInstance(mg));
// No theme poll or other timers of its own
expect(vi.getTimerCount()).toBe(0);
mg.state.active = true;
mg.updateMagnifiedView(); // schedules an info update frame
expect(vi.getTimerCount()).toBe(1);
const highlighter = window.infoPanelManager!.canvasHighlighter;
highlighter.setHighlightedNode(1);
expect((window as any).app.canvas.onDrawForeground).toBe((highlighter as any).boundOnDrawForeground);
mg.setGlassPreviewActive.mockClear();
unmountInfoPanel(asInstance(mg));
expect(document.getElementById(INFO_PANEL_ID)).toBeNull();
expect(document.getElementById('magnify-glass-node-highlight')).toBeNull();
expect(document.querySelector('.floating-controls')).toBeNull();
expect(window.infoPanelManager).toBeUndefined();
expect(window.comfyUIMagnifyGlassExtensions).toEqual([]);
expect(mg.updateMagnifiedView).toBe(FakeGlass.prototype.updateMagnifiedView);
expect(mg.ui.show).toBe(FakeUi.prototype.show);
expect(mg.ui.hide).toBe(FakeUi.prototype.hide);
expect(mg.popOutManager.onStateChange).toBeNull();
expect(mg.popOutManager.onNodeSelect).toBeNull();
expect(mg.popOutManager.onWidgetEdit).toBeNull();
expect(mg.setGlassPreviewActive).toHaveBeenCalledWith(true);
for (const [type, handler, options] of docAdd.mock.calls) {
expect(docRemove).toHaveBeenCalledWith(type, handler, ...(options === undefined ? [] : [options]));
}
for (const [type, handler, options] of winAdd.mock.calls) {
expect(winRemove).toHaveBeenCalledWith(type, handler, ...(options === undefined ? [] : [options]));
}
// The pending info update frame is gone
expect(vi.getTimerCount()).toBe(0);
expect((window as any).app.canvas.onDrawForeground).toBeNull();
});
it('unmounted panel hotkeys do nothing', () => {
mountInfoPanel(asInstance(mg));
unmountInfoPanel(asInstance(mg));
expect(pressKey('s').defaultPrevented).toBe(false);
expect(pressKey('u').defaultPrevented).toBe(false);
expect(pressKey('*').defaultPrevented).toBe(false);
expect(settingsStore['🔍MagnifyGlass.InfoPanelPersist']).toBeUndefined();
});
it('re-mounting does not double-register hotkeys', () => {
mountInfoPanel(asInstance(mg));
unmountInfoPanel(asInstance(mg));
const float = vi.spyOn(UIManager.prototype, 'toggleFloating');
mountInfoPanel(asInstance(mg));
mg.state.active = true;
pressKey('u');
expect(float).toHaveBeenCalledTimes(1);
expect(document.querySelectorAll(`#${INFO_PANEL_ID}`)).toHaveLength(1);
});
describe('hotkeys', () => {
const persist = '🔍MagnifyGlass.InfoPanelPersist';
const panel = () => window.infoPanelManager!.uiManager.elements.panel!;
beforeEach(() => {
mountInfoPanel(asInstance(mg));
});
it('S, I and U do nothing while the glass is off', () => {
const float = vi.spyOn(UIManager.prototype, 'toggleFloating');
expect(pressKey('s').defaultPrevented).toBe(false);
expect(pressKey('i').defaultPrevented).toBe(false);
pressKey('u');
expect(window.infoPanelManager!.stateManager.state.settings[persist]).toBe(false);
expect(window.infoPanelManager!.stateManager.state.isPanelVisible).toBe(false);
expect(float).not.toHaveBeenCalled();
});
it('ignore Ctrl, Meta, Shift and Alt combinations', () => {
mg.state.active = true;
expect(pressKey('s', { ctrlKey: true }).defaultPrevented).toBe(false);
expect(pressKey('s', { metaKey: true }).defaultPrevented).toBe(false);
expect(pressKey('i', { altKey: true }).defaultPrevented).toBe(false);
expect(pressKey('S', { shiftKey: true }).defaultPrevented).toBe(false);
expect(window.infoPanelManager!.stateManager.state.settings[persist]).toBe(false);
expect(window.infoPanelManager!.stateManager.state.isPanelVisible).toBe(false);
expect(pressKey('s').defaultPrevented).toBe(true);
expect(window.infoPanelManager!.stateManager.state.settings[persist]).toBe(true);
});
it('need Alt when AltRequired is on', () => {
mg.state.active = true;
settingsStore['🔍MagnifyGlass.AltRequired'] = true;
expect(pressKey('s').defaultPrevented).toBe(false);
expect(pressKey('s', { altKey: true }).defaultPrevented).toBe(true);
expect(window.infoPanelManager!.stateManager.state.settings[persist]).toBe(true);
});
it('read a key changed after mount on the next press', () => {
mg.state.active = true;
settingsStore['🔍MagnifyGlass.ToggleHotkey'] = 'j';
pressKey('i');
expect(window.infoPanelManager!.stateManager.state.isPanelVisible).toBe(false);
pressKey('j');
expect(window.infoPanelManager!.stateManager.state.isPanelVisible).toBe(true);
});
it('* and arrows act only while the panel is visible and hovered or focused', () => {
const ui = window.infoPanelManager!.uiManager;
const navigate = vi.spyOn(ui, 'navigateExecOrder').mockImplementation(() => { });
const focus = vi.spyOn(ui, 'focusCurrentNode').mockImplementation(() => { });
panel().dispatchEvent(new MouseEvent('mouseenter'));
expect(pressKey('ArrowRight').defaultPrevented).toBe(false); // hidden
ui.show();
panel().dispatchEvent(new MouseEvent('mouseleave'));
expect(pressKey('ArrowRight').defaultPrevented).toBe(false); // not hovered or focused
expect(navigate).not.toHaveBeenCalled();
panel().dispatchEvent(new MouseEvent('mouseenter'));
expect(pressKey('ArrowRight').defaultPrevented).toBe(true);
expect(pressKey('ArrowLeft').defaultPrevented).toBe(true);
expect(pressKey('*', { shiftKey: true }).defaultPrevented).toBe(true);
expect(pressKey('ArrowLeft', { ctrlKey: true }).defaultPrevented).toBe(false);
expect(navigate.mock.calls).toEqual([[1], [-1]]);
expect(focus).toHaveBeenCalledTimes(1);
panel().dispatchEvent(new MouseEvent('mouseleave'));
const button = document.createElement('button');
panel().appendChild(button);
button.focus();
expect(pressKey('ArrowRight').defaultPrevented).toBe(true);
// Focus left by a click is dropped when the pointer leaves, so ←/→ go back to the glass
panel().dispatchEvent(new MouseEvent('mouseenter'));
panel().dispatchEvent(new MouseEvent('mouseleave'));
expect(document.activeElement).not.toBe(button);
expect(pressKey('ArrowRight').defaultPrevented).toBe(false);
button.focus();
// Hiding clears the hover, which a hidden panel never gets a mouseleave for
panel().dispatchEvent(new MouseEvent('mouseenter'));
ui.hide();
ui.show();
button.blur();
expect(pressKey('ArrowRight').defaultPrevented).toBe(false);
});
it('arrows reach other listeners', () => {
const ui = window.infoPanelManager!.uiManager;
vi.spyOn(ui, 'navigateExecOrder').mockImplementation(() => { });
ui.show();
panel().dispatchEvent(new MouseEvent('mouseenter'));
const later = vi.fn();
document.addEventListener('keydown', later);
pressKey('ArrowRight');
expect(later).toHaveBeenCalledTimes(1);
document.removeEventListener('keydown', later);
});
it('hiding the panel closes the node picker', () => {
const ui = window.infoPanelManager!.uiManager;
ui.show();
ui.showNodePicker(panel().querySelector('[data-action="go-to-node"]') as HTMLElement);
expect(document.querySelector('.node-selector-dropdown')).not.toBeNull();
ui.hide();
expect(document.querySelector('.node-selector-dropdown')).toBeNull();
});
});
describe('rendering', () => {
let node: any;
const panelManager = () => window.infoPanelManager!;
const content = () => panelManager().uiManager.elements.content!;
beforeEach(() => {
node = {
id: 7, title: 'KSampler', type: 'KSampler', mode: 0, pos: [0, 0], size: [200, 100],
widgets: [
{ name: 'steps', type: 'number', value: 20, options: { precision: 0 } },
{ name: 'sampler_name', type: 'combo', value: 'euler', options: { values: ['euler', 'dpmpp_2m'] } }
],
inputs: [], outputs: [], properties: {}
};
(window as any).app.canvas.graph = { _nodes: [node], getNodeById: () => node };
(window as any).app.canvas.node_over = node;
(mg as any).state = { active: true, x: 0, y: 0, canvasScale: 1 };
window.comfyUIMagnifyGlass = mg as any;
mountInfoPanel(asInstance(mg));
});
afterEach(() => {
window.comfyUIMagnifyGlass = undefined;
});
it('renders only when something shown changes', () => {
const render = vi.spyOn(panelManager().uiManager, 'renderContent');
panelManager().updateInfo();
const row = content().querySelector('.info-row');
panelManager().updateInfo();
panelManager().updateInfo();
expect(render).toHaveBeenCalledTimes(1);
expect(content().querySelector('.info-row')).toBe(row);
node.widgets[0].value = 25; // edited on the canvas
panelManager().updateInfo();
expect(render).toHaveBeenCalledTimes(2);
expect(content().textContent).toContain('25');
node.mode = 4;
panelManager().updateInfo();
expect(render).toHaveBeenCalledTimes(3);
node.inputs = [{ name: 'steps', type: 'INT', widget: { name: 'steps' }, link: 3 }];
panelManager().updateInfo();
expect(render).toHaveBeenCalledTimes(4);
expect(content().querySelector('.linked-tag')).not.toBeNull();
panelManager().stateManager.state.settings['🔍MagnifyGlass.InfoPanelPersist'] = true;
panelManager().updateInfo();
expect(render).toHaveBeenCalledTimes(5);
});
it('renders again after hide tears the controls down', () => {
const render = vi.spyOn(panelManager().uiManager, 'renderContent');
panelManager().updateInfo();
panelManager().uiManager.hide();
panelManager().updateInfo();
expect(render).toHaveBeenCalledTimes(2);
});
it('shows each widget once, without guessed Model, Image or Text rows', () => {
Object.assign(node, {
title: 'Load VAE', type: 'VAELoader', mode: 4,
widgets: [{ name: 'vae_name', type: 'combo', value: 'models/ae.safetensors', options: { values: ['models/ae.safetensors'] } }]
});
panelManager().updateInfo();
const labels = [...content().querySelectorAll('.info-label')].map(label => label.textContent);
expect(labels).toEqual(['vae_name']);
expect(content().querySelector('.section-header')).toBeNull();
expect(panelManager().informationGatherer.getDetailedNodeInfo(node).mode).toBe('Bypass');
});
it('keeps an open menu while values change', () => {
panelManager().stateManager.state.settings['🔍MagnifyGlass.InfoPanelPersist'] = true;
panelManager().updateInfo();
const trigger = content().querySelector('.inline-dropdown-trigger') as HTMLElement;
trigger.click();
expect(document.querySelector('.custom-dropdown-menu')).not.toBeNull();
node.widgets[0].value = 30;
panelManager().updateInfo();
expect(document.querySelector('.custom-dropdown-menu')).not.toBeNull();
expect(content().querySelector('.inline-dropdown-trigger')).toBe(trigger);
trigger.click(); // closes the menu
panelManager().updateInfo();
expect(content().textContent).toContain('30');
});
it('uses local fonts and never loads a web font', () => {
const ui = panelManager().uiManager;
const settings = panelManager().stateManager.state.settings;
settings['🔍MagnifyGlass.InfoPanelFontFamily'] = 'Inter';
ui.applyStyles();
expect(ui.elements.panel!.style.fontFamily).toContain('Inter');
settings['🔍MagnifyGlass.InfoPanelFontFamily'] = 'System Default';
ui.applyStyles();
expect(ui.elements.panel!.style.fontFamily).toBe('inherit');
expect(document.querySelector('link[href*="fonts.googleapis"]')).toBeNull();
});
});
describe('copy and formatting', () => {
let node: any;
let writeText: ReturnType<typeof vi.fn>;
const panelManager = () => window.infoPanelManager!;
const panel = () => panelManager().uiManager.elements.panel!;
const row = (label: string) => [...panel().querySelectorAll('.info-row')].find(r => r.querySelector('.info-label')!.textContent === label) as HTMLElement;
// The value text, without the Lock to edit hint
const value = (label: string) => row(label).querySelector('.info-value')!.firstChild!.textContent;
const flush = async () => { for (let i = 0; i < 3; i++) await Promise.resolve(); };
beforeEach(() => {
node = {
id: 7, title: 'KSampler', type: 'KSampler', mode: 0, pos: [0, 0], size: [200, 100],
widgets: [
{ name: 'denoise', type: 'number', value: 0.0001, options: { precision: 4, step2: 0.0001 } },
{ name: 'cfg', type: 'number', value: 8, options: { precision: 1 } },
{ name: 'list', type: 'custom', value: [1, 2] },
{ name: 'text', type: 'customtext', value: 'line\n'.repeat(6) }
],
inputs: [], outputs: [], properties: {}
};
(window as any).app.canvas.graph = { _nodes: [node], getNodeById: () => node };
(window as any).app.canvas.node_over = node;
(window as any).app.extensionManager = { toast: { add: vi.fn() } };
(mg as any).state = { active: true, x: 0, y: 0, canvasScale: 1 };
window.comfyUIMagnifyGlass = mg as any;
writeText = vi.fn(() => Promise.resolve());
vi.stubGlobal('navigator', { clipboard: { writeText } });
mountInfoPanel(asInstance(mg));
panelManager().updateInfo();
});
afterEach(() => {
window.comfyUIMagnifyGlass = undefined;
delete (document as any).execCommand;
});
it('formats numbers with the widget precision and other values as JSON', () => {
expect(value('denoise')).toBe('0.0001');
expect(value('cfg')).toBe('8.0');
expect(value('list')).toBe(JSON.stringify([1, 2], null, 2));
});
it('every value row and the title copy, with a check as feedback', async () => {
const rows = panel().querySelectorAll('.info-row');
expect(rows.length).toBeGreaterThan(0);
rows.forEach(r => expect(r.querySelector('.copy-btn')).not.toBeNull());
const titleCopy = panel().querySelector('.panel-header [data-action="copy"]') as HTMLButtonElement;
titleCopy.click();
await flush();
expect(writeText).toHaveBeenLastCalledWith('KSampler #7');
expect(titleCopy.classList.contains('copied')).toBe(true);
vi.advanceTimersByTime(1500);
expect(titleCopy.classList.contains('copied')).toBe(false);
(row('denoise').querySelector('.copy-btn') as HTMLButtonElement).click();
await flush();
expect(writeText).toHaveBeenLastCalledWith('0.0001');
});
it('falls back to execCommand without the clipboard API, and says when copying fails', async () => {
vi.stubGlobal('navigator', {});
const execCommand = vi.fn(() => true);
(document as any).execCommand = execCommand;
const button = row('cfg').querySelector('.copy-btn') as HTMLButtonElement;
button.click();
await flush();
expect(execCommand).toHaveBeenCalledWith('copy');
expect(button.classList.contains('copied')).toBe(true);
expect(document.querySelector('.copy-fallback')).toBeNull();
expect((window as any).app.extensionManager.toast.add).not.toHaveBeenCalled();
vi.advanceTimersByTime(1500);
execCommand.mockReturnValue(false);
button.click();
await flush();
expect(button.classList.contains('copied')).toBe(false);
expect((window as any).app.extensionManager.toast.add).toHaveBeenCalledWith(expect.objectContaining({ severity: 'error' }));
});
it('clamps long values with a toggle that a re-render keeps', () => {
const text = row('text');
expect(text.querySelector('.info-value')!.classList.contains('is-clamped')).toBe(true);
expect(row('cfg').querySelector('.value-expand')).toBeNull();
const toggle = text.querySelector('.value-expand') as HTMLButtonElement;
toggle.click();
expect(text.classList.contains('is-expanded')).toBe(true);
expect(toggle.getAttribute('aria-expanded')).toBe('true');
node.widgets[1].value = 9;
panelManager().updateInfo();
expect(value('cfg')).toBe('9.0');
expect(row('text').classList.contains('is-expanded')).toBe(true);
(row('text').querySelector('.value-expand') as HTMLButtonElement).click();
expect(row('text').classList.contains('is-expanded')).toBe(false);
});
});
describe('header and navigation', () => {
let nodes: any[];
let graph: any;
const panelManager = () => window.infoPanelManager!;
const panel = () => panelManager().uiManager.elements.panel!;
const button = (action: string) => panel().querySelector(`[data-action="${action}"]`) as HTMLButtonElement;
const shown = () => panel().querySelector('.header-title')!.textContent;
const makeNode = (id: number, title: string, order?: number) => ({
id, title, type: 'KSampler', order, mode: 0, pos: [0, 0], size: [200, 100],
widgets: [], inputs: [], outputs: [], properties: {}
});
beforeEach(() => {
nodes = [makeNode(1, 'Loader', 0), makeNode(2, 'Sampler', 1), makeNode(3, 'Decode', 2), makeNode(4, 'Note')];
graph = { _nodes: nodes, getNodeById: (id: number) => nodes.find(n => n.id === id) ?? null };
Object.assign((window as any).app.canvas, { graph, node_over: nodes[0], centerOnNode: vi.fn(), openSubgraph: vi.fn() });
(mg as any).state = { active: true, x: 0, y: 0, canvasScale: 1 };
window.comfyUIMagnifyGlass = mg as any;
mountInfoPanel(asInstance(mg));
panelManager().uiManager.show();
panelManager().updateInfo();
});
afterEach(() => {
window.comfyUIMagnifyGlass = undefined;
});
it('names the node in the header instead of Title and Location rows', () => {
expect(panel().getAttribute('role')).toBe('region');
expect(panel().getAttribute('aria-label')).toBeTruthy();
expect(shown()).toBe('Loader');
expect(panel().querySelector('.header-id')!.textContent).toBe('#1');
const badge = panel().querySelector('.header-badge') as HTMLElement;
expect(badge.textContent).toBe('KSampler');
expect(badge.hidden).toBe(false);
expect(panel().querySelector('.header-subtitle')).toBeNull();
expect(panel().textContent).not.toContain('Focus Node');
expect(panel().querySelector('[data-clickable]')).toBeNull();
// The node section has no header to collapse it by
expect(panel().querySelector('.section-header')).toBeNull();
// A badge repeating the title is left out
nodes[0].title = 'KSampler';
panelManager().updateInfo();
expect(badge.hidden).toBe(true);
nodes[0].title = '<b>bold</b>';
panelManager().updateInfo();
expect(shown()).toBe('<b>bold</b>');
expect(panel().querySelector('.header-title b')).toBeNull();
});
it('jumps centre the canvas and lock the node, which hovering does not clear', () => {
button('next-node').click();
expect((window as any).app.canvas.centerOnNode).toHaveBeenCalledWith(nodes[1]);
expect(panelManager().stateManager.state.lockedNodeId).toBe(2);
expect(shown()).toBe('Sampler');
expect(button('lock-node').getAttribute('aria-pressed')).toBe('true');
(window as any).app.canvas.node_over = nodes[2];
panelManager().updateInfo();
expect(shown()).toBe('Sampler');
button('lock-node').click();
expect(shown()).toBe('Decode');
expect(button('lock-node').getAttribute('aria-pressed')).toBe('false');
button('lock-node').click();
(window as any).app.canvas.node_over = nodes[0];
panelManager().updateInfo();
expect(shown()).toBe('Decode');
});
it('Prev and Next follow execution order and wrap; Focus centres on the shown node', () => {
button('prev-node').click();
expect(shown()).toBe('Decode');
button('next-node').click();
expect(shown()).toBe('Loader');
// A node without an order starts Next at the first node
(window as any).app.canvas.node_over = nodes[3];
button('lock-node').click();
button('lock-node').click();
expect(shown()).toBe('Note');
button('next-node').click();
expect(shown()).toBe('Loader');
(window as any).app.canvas.centerOnNode.mockClear();
button('focus-node').click();
expect((window as any).app.canvas.centerOnNode).toHaveBeenCalledWith(nodes[0]);
});
it('Back and Forward walk the jumps', () => {
expect(button('back').disabled).toBe(true);
expect(button('forward').disabled).toBe(true);
button('next-node').click();
button('next-node').click();
expect(shown()).toBe('Decode');
button('back').click();
expect(shown()).toBe('Sampler');
button('back').click();
expect(shown()).toBe('Loader');
expect(button('back').disabled).toBe(true);
button('forward').click();
expect(shown()).toBe('Sampler');
expect(button('forward').disabled).toBe(false);
// A new jump drops what was ahead
panelManager().uiManager.jumpToNode(4);
expect(button('forward').disabled).toBe(true);
button('back').click();
expect(shown()).toBe('Sampler');
// A new graph on screen releases the lock, whose id would be another node there, and starts over
(window as any).app.canvas.graph = { ...graph };
panelManager().updateInfo();
expect(panelManager().stateManager.state.lockedNodeId).toBeNull();
expect(shown()).toBe('Loader');
expect(button('back').disabled).toBe(true);
});
it('a pick in the pop-out is a jump', () => {
(mg.popOutManager.onNodeSelect as (id: number) => void)(3);
expect((window as any).app.canvas.centerOnNode).toHaveBeenCalledWith(nodes[2]);
expect(shown()).toBe('Decode');
expect(button('back').disabled).toBe(false);
});
it('releases the lock when its node is deleted', () => {
panelManager().uiManager.jumpToNode(3);
nodes.splice(2, 1);
panelManager().updateInfo();
expect(panelManager().stateManager.state.lockedNodeId).toBeNull();
expect(shown()).toBe('Loader');
});
it('shows the subgraph path and the group, and enters subgraph nodes', () => {
const breadcrumb = panel().querySelector('.panel-breadcrumb') as HTMLElement;
expect(breadcrumb.hidden).toBe(true);
const root: any = { _nodes: [] };
root.rootGraph = root;
const inner: any = { name: '<i>Upscale</i>', rootGraph: root, _nodes: [] };
const outer: any = { name: 'Refine', rootGraph: root, _nodes: [{ isSubgraphNode: () => true, subgraph: inner }] };
root._nodes.push({ isSubgraphNode: () => false }, { isSubgraphNode: () => true, subgraph: outer });
inner.getGroupOnPos = vi.fn(() => ({ title: 'Sampling' }));
nodes[0].graph = inner;
panelManager().updateInfo();
expect(breadcrumb.hidden).toBe(false);
expect([...breadcrumb.querySelectorAll('.crumb')].map(c => c.textContent)).toEqual(['Root', 'Refine', '<i>Upscale</i>']);
expect(breadcrumb.querySelector('i')).toBeNull();
expect(breadcrumb.querySelector('.crumb-group')!.textContent).toBe('Sampling');
expect(breadcrumb.querySelector('.crumb[aria-current]')!.textContent).toBe('<i>Upscale</i>');
// Looked up at the node centre
expect(inner.getGroupOnPos).toHaveBeenCalledWith(100, 50);
expect(button('enter-subgraph')).toBeNull();
nodes[0].isSubgraphNode = () => true;
nodes[0].subgraph = outer;
panelManager().uiManager.jumpToNode(1);
button('enter-subgraph').click();
expect((window as any).app.canvas.openSubgraph).toHaveBeenCalledWith(outer, nodes[0]);
expect(panelManager().stateManager.state.lockedNodeId).toBeNull();
});
it('double-clicking the header minimizes, but not on its buttons', () => {
const header = panelManager().uiManager.elements.header!;
header.querySelector('.header-title')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
expect(panel().classList.contains('panel-minimized')).toBe(true);
expect(button('minimize').getAttribute('aria-expanded')).toBe('false');
expect(button('minimize').getAttribute('aria-label')).toBe('Expand Panel');
button('lock-node').dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
expect(panel().classList.contains('panel-minimized')).toBe(true);
button('minimize').click();
expect(panel().classList.contains('panel-minimized')).toBe(false);
});
});
describe('modes', () => {
let nodes: any[];
const panelManager = () => window.infoPanelManager!;
const state = () => panelManager().stateManager.state;
const panel = () => panelManager().uiManager.elements.panel!;
const controls = () => panelManager().uiManager.elements.controls!;
const tool = (action: string) => controls().querySelector(`[data-action="${action}"]`) as HTMLButtonElement;
const shown = () => panel().querySelector('.header-title')!.textContent;
const pointer = (target: Element, type: string, clientX: number, clientY: number) =>
target.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, button: 0, clientX, clientY }));
const makeNode = (id: number, title: string) => ({
id, title, type: 'KSampler', order: id, mode: 0, pos: [0, 0], size: [200, 100],
widgets: [{ name: 'steps', type: 'number', value: 20, options: { precision: 0, min: 1, max: 100, step: 10 } }],
inputs: [], outputs: [], properties: {}
});
beforeEach(() => {
nodes = [makeNode(1, 'Loader'), makeNode(2, 'Sampler')];
const graph = { _nodes: nodes, getNodeById: (id: number) => nodes.find(n => n.id === id) ?? null };
Object.assign((window as any).app.canvas, { graph, node_over: nodes[0], selected_nodes: {}, centerOnNode: vi.fn() });
(mg as any).state = { active: true, x: 0, y: 0, canvasScale: 1 };
(mg as any).config = { showCursorPreview: false };
window.comfyUIMagnifyGlass = mg as any;
mountInfoPanel(asInstance(mg));
panelManager().uiManager.show();
panelManager().updateInfo();
vi.spyOn(panel(), 'getBoundingClientRect').mockReturnValue({ left: 50, top: 60, right: 350, bottom: 260, width: 300, height: 200 } as DOMRect);
});
afterEach(() => {
window.comfyUIMagnifyGlass = undefined;
});
it('Sticky is saved from the button, the S key and the eye button', () => {
const persist = '🔍MagnifyGlass.InfoPanelPersist';
tool('persist').click();
expect(settingsStore[persist]).toBe(true);
expect(tool('persist').getAttribute('aria-pressed')).toBe('true');
pressKey('s');
expect(settingsStore[persist]).toBe(false);
expect(tool('persist').getAttribute('aria-pressed')).toBe('false');
pressKey('s');
tool('toggle-panel').click(); // hiding the panel ends Sticky
expect(state().isPanelVisible).toBe(false);
expect(settingsStore[persist]).toBe(false);
expect(state().settings[persist]).toBe(false);
});
it('Hold and its key are gone', () => {
expect(pressKey('p').defaultPrevented).toBe(false);
expect(DEFAULT_SETTINGS).not.toHaveProperty('🔍MagnifyGlass.HoldInfoHotkey');
expect(tool('toggle-hold')).toBeNull();
expect(state()).not.toHaveProperty('isInfoHeld');
});
it('editing needs Sticky, a lock or the selection; otherwise a row offers Lock to edit', () => {
expect(panel().querySelector('.info-row.editable')).toBeNull();
const hint = panel().querySelector('.edit-hint') as HTMLButtonElement;
expect(hint.textContent).toBe('Lock to edit');
hint.click();
expect(state().lockedNodeId).toBe(1);
expect(panel().querySelector('.info-row.editable')).not.toBeNull();
expect(panel().querySelector('.edit-hint')).toBeNull();
// The value opens its editor from the keyboard too
const value = panel().querySelector('.info-row.editable .info-value') as HTMLElement;
expect(value.tabIndex).toBe(0);
expect(value.getAttribute('aria-label')).toBe('Edit steps');
value.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
expect(panel().querySelector('.info-row.editing input')!.getAttribute('aria-label')).toBe('steps');
});
it('Follow selection shows the first selected node, which a lock overrides and hover replaces when nothing is selected', () => {
tool('follow-selection').click();
expect(settingsStore['🔍MagnifyGlass.InfoPanelFollowSelection']).toBe(true);
expect(shown()).toBe('Loader');
// A click that selects without moving the mouse still updates the panel
(window as any).app.canvas.selected_nodes = { 2: nodes[1] };
document.dispatchEvent(new PointerEvent('pointerup'));
vi.advanceTimersToNextFrame();
expect(shown()).toBe('Sampler');
expect(state().showsSelection).toBe(true);
expect(panel().querySelector('.info-row.editable')).not.toBeNull();
panelManager().uiManager.jumpToNode(1);
expect(shown()).toBe('Loader');
expect(state().showsSelection).toBe(false);
panel().querySelector<HTMLButtonElement>('[data-action="lock-node"]')!.click();
expect(shown()).toBe('Sampler');
(window as any).app.canvas.selected_nodes = {};
panelManager().updateInfo();
expect(shown()).toBe('Loader');
expect(panel().querySelector('.info-row.editable')).toBeNull();
});
it('dragging the header floats the panel 4px inside the window and saves it; a click leaves it', () => {
const header = panelManager().uiManager.elements.header!;
const title = header.querySelector('.header-title')!;
const left = panel().style.left;
pointer(title, 'pointerdown', 100, 100);
pointer(document, 'pointerup', 100, 100);
expect(state().isFloating).toBe(false);
expect(panel().style.left).toBe(left);
// Not from the header's buttons
pointer(header.querySelector('[data-action="minimize"]')!, 'pointerdown', 100, 100);
pointer(document, 'pointermove', 300, 300);
expect(state().isFloating).toBe(false);
pointer(document, 'pointerup', 300, 300);
pointer(title, 'pointerdown', 100, 100);
pointer(document, 'pointermove', 300, 200);
expect(state().isFloating).toBe(true);
expect(panel().style.left).toBe('250px');
expect(panel().style.top).toBe('160px');
expect(tool('float').getAttribute('aria-pressed')).toBe('true');
pointer(document, 'pointermove', 5000, -500);
expect(panel().style.left).toBe(`${window.innerWidth - 300 - 4}px`);
expect(panel().style.top).toBe('4px');
pointer(document, 'pointerup', 5000, -500);
expect(JSON.parse(localStorage.getItem('comfyui_magnify_inspector_layout')!)).toMatchObject({ floating: true, y: 4 });
});
it('the corner handle resizes into the Width and Max Height settings', () => {
const handle = panel().querySelector('.panel-resize')!;
const content = panel().querySelector('.panel-content')!;
// Content that reaches its cap
Object.defineProperty(content, 'scrollHeight', { value: 500, configurable: true });
pointer(handle, 'pointerdown', 400, 300);
pointer(document, 'pointermove', 450, 600);
expect(panel().style.width).toBe('350px');
expect(panel().style.getPropertyValue('--panel-max-height')).toBe('300px');
expect(settingsStore['🔍MagnifyGlass.InfoPanelWidth']).toBeUndefined();
pointer(document, 'pointerup', 450, 600);
expect(settingsStore['🔍MagnifyGlass.InfoPanelWidth']).toBe(350);
expect(settingsStore['🔍MagnifyGlass.InfoPanelMaxHeight']).toBe(300);
expect(state().isFloating).toBe(false);
// A sideways drag leaves Max Height alone
delete settingsStore['🔍MagnifyGlass.InfoPanelMaxHeight'];
pointer(handle, 'pointerdown', 400, 300);
pointer(document, 'pointermove', 500, 300);
pointer(document, 'pointerup', 500, 300);
expect(settingsStore['🔍MagnifyGlass.InfoPanelWidth']).toBe(400);
expect(settingsStore['🔍MagnifyGlass.InfoPanelMaxHeight']).toBeUndefined();
// Nor does a little vertical jitter
pointer(handle, 'pointerdown', 400, 300);
pointer(document, 'pointermove', 450, 301);
pointer(document, 'pointerup', 450, 301);
expect(settingsStore['🔍MagnifyGlass.InfoPanelMaxHeight']).toBeUndefined();
// Content below its cap does not show Max Height, so a drag keeps it
Object.defineProperty(content, 'scrollHeight', { value: 0 });
pointer(handle, 'pointerdown', 400, 300);
pointer(document, 'pointermove', 400, 200);
pointer(document, 'pointerup', 400, 200);
expect(settingsStore['🔍MagnifyGlass.InfoPanelMaxHeight']).toBeUndefined();
});
it('Float and Follow switch with the button and the U key; hiding the glass floats the panel where it is', () => {
tool('float').click();
expect(state().isFloating).toBe(true);
pressKey('u');
expect(state().isFloating).toBe(false);
tool('toggle-glass').click();
expect(mg.setGlassPreviewActive).toHaveBeenLastCalledWith(false);
expect(state().isFloating).toBe(true);
expect(state().floatPosition).toEqual({ x: 50, y: 60 });
// A hidden glass cannot be followed
expect(tool('float').disabled).toBe(true);
pressKey('u');
expect(state().isFloating).toBe(true);
// Showing the glass again leaves the panel floating
tool('toggle-glass').click();
expect(state().isFloating).toBe(true);
panelManager().resetPosition();
expect(state().isFloating).toBe(false);
expect(state().floatPosition).toBeNull();
expect(tool('float').getAttribute('aria-pressed')).toBe('false');
// A reset while the glass is hidden keeps the panel floating
tool('toggle-glass').click();
panelManager().resetPosition();
expect(state().isFloating).toBe(true);
});
it('with the glass hidden the eye button keeps a shown panel, but can still show a hidden one', () => {
tool('toggle-glass').click();
expect(tool('toggle-panel').disabled).toBe(true);
pressKey('i');
expect(state().isPanelVisible).toBe(false);
expect(tool('toggle-panel').disabled).toBe(false);
expect(tool('toggle-panel').title).toBe('Show Panel (I)');
tool('toggle-panel').click();
expect(state().isPanelVisible).toBe(true);
});
it('Sticky looks its node up again, and drops it when another graph is on screen', () => {
tool('persist').click();
const canvas = (window as any).app.canvas;
canvas.node_over = null;
panelManager().updateInfo();
expect(shown()).toBe('Loader');
// An undo rebuilds the node objects
nodes[0] = { ...nodes[0], title: 'Loader (undone)' };
panelManager().updateInfo();
expect(shown()).toBe('Loader (undone)');
canvas.graph = { _nodes: [], getNodeById: () => nodes[0] };
panelManager().updateInfo();
expect(state().currentNodeId).toBeNull();
});
it('a reload keeps floating where it was and stays minimized', () => {
localStorage.setItem('comfyui_magnify_inspector_layout', JSON.stringify({ floating: true, minimized: true, x: 200, y: 120 }));
unmountInfoPanel(asInstance(mg));
mountInfoPanel(asInstance(mg));
panelManager().uiManager.show();
panelManager().updateInfo();
expect(panel().classList.contains('panel-minimized')).toBe(true);
expect(panel().style.left).toBe('200px');
expect(panel().style.top).toBe('120px');
panel().querySelector<HTMLButtonElement>('[data-action="minimize"]')!.click();
expect(JSON.parse(localStorage.getItem('comfyui_magnify_inspector_layout')!).minimized).toBe(false);
});
it('the toolbar keeps the same buttons, in groups, whatever the state', () => {
const layout = () => [...controls().children].map(el => (el as HTMLElement).dataset.action ?? '|');
const expected = ['float', 'persist', 'follow-selection', 'toggle-panel', '|', 'toggle-glass', 'toggle-cursor', 'drag-glass', 'reset-glass', '|', 'popout'];
expect(layout()).toEqual(expected);
tool('persist').click();
tool('toggle-glass').click();
panelManager().uiManager.hide();
expect(layout()).toEqual(expected);
expect([...controls().querySelectorAll('button')].every(b => b.style.display === '')).toBe(true);
expect(tool('toggle-cursor').disabled).toBe(true);
expect(tool('lock')).toBeNull();
});
it('toggle-cursor saves Show Cursor Preview', () => {
tool('toggle-cursor').click();
expect(settingsStore['🔍MagnifyGlass.ShowCursorPreview']).toBe(true);
expect(tool('toggle-cursor').getAttribute('aria-pressed')).toBe('true');
});
it('places the toolbar for all 8 Controls Positions and flips a side without room', () => {
const positionManager = panelManager().positionManager;
Object.defineProperty(controls(), 'offsetWidth', { value: 40 });
Object.defineProperty(controls(), 'offsetHeight', { value: 40 });
const place = (position: string, x = 300) => {
state().settings['🔍MagnifyGlass.ControlsPosition'] = position;
panelManager().uiManager.updateControlsLayout(position);
positionManager.cachedPanelPosition = { x, y: 300, width: 200, height: 100 };
positionManager.positionFloatingControls(controls());
return [controls().style.left, controls().style.top, controls().classList.contains('horizontal-layout')];
};
expect(place('left')).toEqual(['256px', '300px', false]);
expect(place('right')).toEqual(['504px', '300px', false]);
expect(place('top')).toEqual(['300px', '256px', true]);
expect(place('top-left')).toEqual(['300px', '256px', true]);
expect(place('top-right')).toEqual(['460px', '256px', true]);
expect(place('bottom')).toEqual(['300px', '404px', true]);
expect(place('bottom-left')).toEqual(['300px', '404px', true]);
expect(place('bottom-right')).toEqual(['460px', '404px', true]);
// No room on the left: beside the panel's right edge instead of over it
expect(place('left', 10)).toEqual(['214px', '300px', false]);
});
});
describe('styles', () => {
const root = document.documentElement.style;
it('carries no theme class and keeps the palette colours at the defaults', () => {
mountInfoPanel(asInstance(mg));
const { panel, controls } = window.infoPanelManager!.uiManager.elements;
expect(panel!.className).toBe('magnify-info-panel');
expect(controls!.className).toBe('floating-controls vertical-layout');
expect(root.getPropertyValue('--mg-panel-fg')).toBe('');
expect(root.getPropertyValue('--mg-panel-accent')).toBe('');
});
it('writes text and accent colours only when they differ from the defaults', () => {
mountInfoPanel(asInstance(mg));
const ui = window.infoPanelManager!.uiManager;
const settings = window.infoPanelManager!.stateManager.state.settings;
settings['🔍MagnifyGlass.InfoPanelAccentColor'] = '#ff0000';
settings['🔍MagnifyGlass.InfoPanelTextColor'] = '#6B7280'; // the default, in another case
ui.applyStyles();
expect(root.getPropertyValue('--mg-panel-accent')).toBe('#ff0000');
expect(root.getPropertyValue('--mg-panel-fg')).toBe('');
settings['🔍MagnifyGlass.InfoPanelAccentColor'] = '#3b82f6';
ui.applyStyles();
expect(root.getPropertyValue('--mg-panel-accent')).toBe('');
settings['🔍MagnifyGlass.InfoPanelAccentColor'] = '#ff0000';
ui.applyStyles();
unmountInfoPanel(asInstance(mg));
expect(root.getPropertyValue('--mg-panel-accent')).toBe('');
});
it('applies opacity to the background only and max height to the content', () => {
settingsStore['🔍MagnifyGlass.InfoPanelOpacity'] = 60;
settingsStore['🔍MagnifyGlass.InfoPanelMaxHeight'] = 400;
mountInfoPanel(asInstance(mg));
const ui = window.infoPanelManager!.uiManager;
ui.show();
expect(ui.elements.panel!.style.getPropertyValue('--panel-opacity')).toBe('60%');
expect(ui.elements.panel!.style.getPropertyValue('--panel-max-height')).toBe('400px');
expect(ui.elements.panel!.style.opacity).toBe('');
});
it('loads High Contrast Text with the other panel settings', () => {
settingsStore['🔍MagnifyGlass.HighContrastText'] = true;
mountInfoPanel(asInstance(mg));
expect(window.infoPanelManager!.uiManager.elements.panel!.classList.contains('high-contrast-text')).toBe(true);
});
it('starts hidden and takes no clicks before it is shown', () => {
mountInfoPanel(asInstance(mg));
const panel = window.infoPanelManager!.uiManager.elements.panel!;
expect(panel.style.display).toBe('none');
window.infoPanelManager!.uiManager.show();
expect(panel.style.display).toBe('');
});
it('the highlight follows the cached setting and clips to the canvas', () => {
// LiteGraph's bounds include the title bar above pos
const node = { id: 3, getBounding: () => [0, -30, 100, 80] };
const canvas = document.createElement('canvas');
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({ left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600 } as DOMRect);
Object.assign((window as any).app.canvas, { canvas, ds: { scale: 1, offset: [0, 0] }, graph: { getNodeById: () => node } });
mountInfoPanel(asInstance(mg));
const highlighter = window.infoPanelManager!.canvasHighlighter;
const highlight = document.getElementById('magnify-glass-node-highlight')!;
highlighter.setHighlightedNode(3);
(window as any).app.canvas.onDrawForeground(null, []);
expect(highlight.style.display).toBe('block');
// The title bar sits above the canvas top here, so the top inset clips it
expect(highlight.style.clipPath).toMatch(/^inset\(36px /);
window.infoPanelManager!.stateManager.state.settings['🔍MagnifyGlass.NodeHighlightEnabled'] = false;
(window as any).app.canvas.onDrawForeground(null, []);
expect(highlight.style.display).toBe('none');
});
it('the highlight covers only the title of a collapsed node', () => {
const node = { id: 3, pos: [100, 100], size: [300, 200], getBounding: () => [100, 70, 120, 30] };
const canvas = document.createElement('canvas');
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({ left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600 } as DOMRect);
Object.assign((window as any).app.canvas, { canvas, ds: { scale: 2, offset: [0, 0] }, graph: { getNodeById: () => node } });
mountInfoPanel(asInstance(mg));
const highlight = document.getElementById('magnify-glass-node-highlight')!;
window.infoPanelManager!.canvasHighlighter.setHighlightedNode(3);
(window as any).app.canvas.onDrawForeground(null, []);
expect([highlight.style.left, highlight.style.top, highlight.style.width, highlight.style.height]).toEqual(['194px', '134px', '252px', '72px']);
});
it('the highlight takes a collapsed Vue node from node.size, where Vue writes its DOM size', () => {
(window as any).LiteGraph = { vueNodesMode: true, NODE_TITLE_HEIGHT: 30 };
// LiteGraph's own pill is narrower than the collapsed Vue node
const node = { id: 3, pos: [100, 100], size: [225, 8], getBounding: () => [100, 70, 120, 30] };
const canvas = document.createElement('canvas');
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({ left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600 } as DOMRect);
Object.assign((window as any).app.canvas, { canvas, ds: { scale: 1, offset: [0, 0] }, graph: { getNodeById: () => node } });
mountInfoPanel(asInstance(mg));
const highlight = document.getElementById('magnify-glass-node-highlight')!;
window.infoPanelManager!.canvasHighlighter.setHighlightedNode(3);
(window as any).app.canvas.onDrawForeground(null, []);
expect([highlight.style.left, highlight.style.top, highlight.style.width, highlight.style.height]).toEqual(['94px', '64px', '237px', '50px']);
delete (window as any).LiteGraph;
});
});
describe('registerPanelSettings', () => {
const setEnabled = (on: boolean) => on ? mountInfoPanel(asInstance(mg)) : unmountInfoPanel(asInstance(mg));
const registeredIds = () => addSetting.mock.calls.map(([setting]) => setting.id);
it('registers every setting and builds nothing when disabled', () => {
settingsStore['🔍MagnifyGlass.InfoPanelEnabled'] = false;
registerPanelSettings(setEnabled);
expect(registeredIds()).toEqual([
'🔍MagnifyGlass.InfoPanelPosition',
'🔍MagnifyGlass.InfoPanelPersist',
'🔍MagnifyGlass.InfoPanelFollowSelection',
'🔍MagnifyGlass.NodeHighlightEnabled',
'🔍MagnifyGlass.ShowHoveringControls',
'🔍MagnifyGlass.InfoPanelWidth',
'🔍MagnifyGlass.InfoPanelMaxHeight',
'🔍MagnifyGlass.InfoPanelOpacity',
'🔍MagnifyGlass.InfoPanelFontSize',
'🔍MagnifyGlass.InfoPanelFontFamily',
'🔍MagnifyGlass.InfoPanelTextColor',
'🔍MagnifyGlass.InfoPanelAccentColor',
'🔍MagnifyGlass.HighContrastText',
'🔍MagnifyGlass.InfoPanelEnabled'
]);
expect(document.getElementById(INFO_PANEL_ID)).toBeNull();
expect(window.infoPanelManager).toBeUndefined();
expect(mg.updateMagnifiedView).toBe(FakeGlass.prototype.updateMagnifiedView);
expect(vi.getTimerCount()).toBe(0);
});
it('mounts the panel when enabled', () => {
registerPanelSettings(setEnabled);
expect(window.infoPanelManager).toBeInstanceOf(InfoPanel);
expect(document.getElementById(INFO_PANEL_ID)).not.toBeNull();
});
it('the Sticky and Follow selection settings update the toolbar', () => {
registerPanelSettings(setEnabled);
const onChange = (id: string) => addSetting.mock.calls.find(([s]) => s.id === id)![0].onChange!;
const tool = (action: string) => window.infoPanelManager!.uiManager.elements.controls!.querySelector(`[data-action="${action}"]`)!;
onChange('🔍MagnifyGlass.InfoPanelPersist')(true);
onChange('🔍MagnifyGlass.InfoPanelFollowSelection')(true);
expect(tool('persist').getAttribute('aria-pressed')).toBe('true');
expect(tool('follow-selection').getAttribute('aria-pressed')).toBe('true');
expect(window.infoPanelManager!.stateManager.state.settings['🔍MagnifyGlass.InfoPanelFollowSelection']).toBe(true);
});
it('setting handlers update the mounted panel', () => {
registerPanelSettings(setEnabled);
const width = addSetting.mock.calls.find(([s]) => s.id === '🔍MagnifyGlass.InfoPanelWidth')![0];
width.onChange!(420);
expect(window.infoPanelManager!.stateManager.state.settings['🔍MagnifyGlass.InfoPanelWidth']).toBe(420);
});
});
});