Files
AEmotionStudio-ComfyUI-Magn…/tests/unit/WidgetEditing.test.ts
T
Æmotion StudioandClaude Opus 5.5 4b06ee92bb Update the Inspector panel look, navigation and editing
Restyle the panel on ComfyUI palette tokens and drop the built-in themes.
Add a header with Back/Forward, Prev/Next, Focus, Lock and a searchable
Go to node picker, a subgraph/group breadcrumb, and simplified modes:
Follow glass or Float, plus Sticky, Follow selection and Lock to node
(replaces Hold).

Fix widget edits (step2 steps, INT/FLOAT, commit on Enter/blur, undo),
subgraph lookups, orphaned dropdown menus, global arrow-key capture,
the pop-out editor, and remove Google Fonts loading and dead code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 06:47:21 -08:00

272 lines
12 KiB
TypeScript

/**
* Widget editing: constraints, the commit path and the number/text editors.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { WidgetSyncManager } from '../../src/info-panel/widget-editors/WidgetSyncManager';
import { WidgetEditorFactory } from '../../src/info-panel/widget-editors/WidgetEditorFactory';
import { DragValueController } from '../../src/info-panel/widget-editors/DragValueController';
import { getImportantNodeParameters } from '../../src/info-panel/NodeDataExtractor';
// Widget options as frontend 1.39.5 builds them: step is 10x the input step, step2 is the real one
const intWidget = (value = 20) => ({
name: 'steps', type: 'number', value,
options: { min: 1, max: 10000, step: 10, step2: 1, precision: 0 }
});
const floatWidget = (value = 1) => ({
name: 'denoise', type: 'number', value,
options: { min: 0, max: 1, step: 0.1, step2: 0.01, round: 0.01, precision: 2 }
});
let checkState: ReturnType<typeof vi.fn>;
let setDirtyCanvas: ReturnType<typeof vi.fn>;
let node: any;
function setup(widgets: any[]) {
checkState = vi.fn();
setDirtyCanvas = vi.fn();
node = { id: 5, widgets, graph: { setDirtyCanvas } };
const subgraph = { _nodes: [node], getNodeById: (id: number) => (id === 5 ? node : null) };
(globalThis as any).app = {
// app.graph is the root graph; an open subgraph only lives on the canvas
graph: { _nodes: [], getNodeById: () => null },
canvas: { graph: subgraph, graph_mouse: [0, 0] },
extensionManager: { workflow: { activeWorkflow: { changeTracker: { checkState } } } }
};
}
function key(el: HTMLElement, k: string, init: KeyboardEventInit = {}) {
el.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...init }));
}
describe('WidgetSyncManager', () => {
it('uses step2 and keeps INT and FLOAT apart', () => {
expect(WidgetSyncManager.extractConstraints(intWidget()).step).toBe(1);
expect(WidgetSyncManager.extractConstraints(floatWidget()).step).toBe(0.01);
expect(WidgetSyncManager.extractConstraints({ type: 'number', options: { step: 5 } }).step).toBe(0.5);
expect(WidgetSyncManager.getWidgetType(intWidget())).toBe('INT');
expect(WidgetSyncManager.getWidgetType(floatWidget())).toBe('FLOAT');
expect(WidgetSyncManager.getWidgetType({ type: 'knob', options: { precision: 0 } })).toBe('INT');
});
it('scales the step with Shift and Alt, never below what the widget stores', () => {
const c = WidgetSyncManager.extractConstraints(intWidget());
expect(WidgetSyncManager.getStep(c, { shiftKey: true, altKey: false })).toBe(10);
expect(WidgetSyncManager.getStep(c, { shiftKey: false, altKey: true })).toBe(1);
const f = WidgetSyncManager.extractConstraints({ type: 'number', options: { step2: 0.1, precision: 3 } });
expect(WidgetSyncManager.getStep(f, { shiftKey: false, altKey: true })).toBeCloseTo(0.01);
});
it('reads combo values given as a function', () => {
const widget = { type: 'combo', value: 'a', options: { values: () => ['a', 'b'] } };
expect(WidgetSyncManager.extractConstraints(widget).options).toEqual(['a', 'b']);
});
it('treats multiline text as editable and object or DOM widgets as read-only', () => {
expect(WidgetSyncManager.getWidgetType({ type: 'customtext', element: {} })).toBe('multiline');
expect(WidgetSyncManager.isWidgetEditable({ type: 'customtext', value: 'x', element: {} })).toBe(true);
expect(WidgetSyncManager.isWidgetEditable({ type: 'text', value: { a: 1 } })).toBe(false);
expect(WidgetSyncManager.isWidgetEditable({ type: 'preview', value: '', element: {} })).toBe(false);
});
it('commits through widget.setValue on the graph on screen and records an undo step', () => {
const widget: any = { ...intWidget(), setValue: vi.fn(function (this: any, v: unknown) { this.value = v; }) };
setup([widget]);
const result = WidgetSyncManager.syncWidgetValue(5, 'steps', '30.4');
expect(result).toMatchObject({ success: true, previousValue: 20, newValue: 30 });
expect(widget.setValue).toHaveBeenCalledWith(30, { node, canvas: (globalThis as any).app.canvas });
expect(setDirtyCanvas).toHaveBeenCalledWith(true, true);
expect(checkState).toHaveBeenCalledTimes(1);
});
it('falls back to assigning and calling the callback, and can skip the undo step', () => {
const callback = vi.fn();
const widget: any = { ...floatWidget(), callback };
setup([widget]);
WidgetSyncManager.syncWidgetValue(5, 'denoise', 0.456, false);
expect(widget.value).toBe(0.46);
expect(callback).toHaveBeenCalledWith(0.46, expect.anything(), node, [0, 0]);
expect(checkState).not.toHaveBeenCalled();
});
});
describe('number editor', () => {
let widget: any;
let input: HTMLInputElement;
let editor: ReturnType<typeof WidgetEditorFactory.createEditor>;
beforeEach(() => {
widget = intWidget(20);
setup([widget]);
editor = WidgetEditorFactory.createEditor({
nodeId: 5, widgetName: 'steps', widgetType: 'INT', currentValue: '20',
constraints: WidgetSyncManager.extractConstraints(widget)
});
document.body.appendChild(editor.element);
input = editor.element.querySelector('input')!;
});
it('does not write while typing and commits on Enter', () => {
input.value = '102';
input.dispatchEvent(new Event('input'));
expect(widget.value).toBe(20);
input.focus();
key(input, 'Enter');
expect(widget.value).toBe(102);
expect(checkState).toHaveBeenCalledTimes(1);
});
it('drops the typing on Escape and keeps the steps', () => {
editor.element.querySelector<HTMLButtonElement>('.increment')!.click();
expect(widget.value).toBe(21);
input.value = '99';
key(input, 'Escape');
expect(widget.value).toBe(21);
expect(input.value).toBe('21');
expect(checkState).toHaveBeenCalledTimes(1);
});
it('keeps focus in the input on a stepper press and closes when focus leaves a stepper', () => {
const onBlur = vi.fn();
const other = WidgetEditorFactory.createEditor({
nodeId: 5, widgetName: 'steps', widgetType: 'INT', currentValue: '20',
constraints: WidgetSyncManager.extractConstraints(widget), onBlur
});
document.body.appendChild(other.element);
const stepper = other.element.querySelector<HTMLButtonElement>('.increment')!;
const press = new MouseEvent('mousedown', { bubbles: true, cancelable: true });
stepper.dispatchEvent(press);
expect(press.defaultPrevented).toBe(true);
stepper.focus();
stepper.blur();
expect(onBlur).toHaveBeenCalledTimes(1);
other.destroy();
});
it('shows a float at its precision', () => {
const denoise = floatWidget(1);
setup([denoise]);
const floatEditor = WidgetEditorFactory.createEditor({
nodeId: 5, widgetName: 'denoise', widgetType: 'FLOAT', currentValue: '1',
constraints: WidgetSyncManager.extractConstraints(denoise)
});
const floatInput = floatEditor.element.querySelector('input')!;
expect(floatInput.value).toBe('1.00');
floatEditor.element.querySelector<HTMLButtonElement>('.decrement')!.click();
expect(denoise.value).toBe(0.99);
expect(floatInput.value).toBe('0.99');
});
it('steps by step2 with the buttons and arrow keys', () => {
editor.element.querySelector<HTMLButtonElement>('.decrement')!.click();
expect(widget.value).toBe(19);
key(input, 'ArrowUp', { shiftKey: true });
expect(widget.value).toBe(29);
});
});
describe('text editor', () => {
it('commits a single line on Enter and reverts on Escape', () => {
const widget: any = { name: 'filename_prefix', type: 'text', value: 'ComfyUI' };
setup([widget]);
const editor = WidgetEditorFactory.createEditor({ nodeId: 5, widgetName: 'filename_prefix', widgetType: 'text', currentValue: 'ComfyUI' });
document.body.appendChild(editor.element);
const input = editor.element.querySelector('input')!;
input.focus();
input.value = 'fennec';
input.dispatchEvent(new Event('input'));
expect(widget.value).toBe('ComfyUI');
key(input, 'Enter');
expect(widget.value).toBe('fennec');
input.focus();
input.value = 'oops';
key(input, 'Escape');
expect(widget.value).toBe('ComfyUI');
});
it('uses a textarea for multiline, commits on Ctrl+Enter and flushes on destroy', () => {
const widget: any = { name: 'text', type: 'customtext', value: 'a fox' };
setup([widget]);
const editor = WidgetEditorFactory.createEditor({ nodeId: 5, widgetName: 'text', widgetType: 'multiline', currentValue: 'a fox' });
document.body.appendChild(editor.element);
const textarea = editor.element.querySelector('textarea')!;
textarea.focus();
textarea.value = 'a fox\nwith big ears';
key(textarea, 'Enter');
expect(widget.value).toBe('a fox');
key(textarea, 'Enter', { ctrlKey: true });
expect(widget.value).toBe('a fox\nwith big ears');
textarea.focus();
textarea.value = 'a fluffy tail';
editor.destroy();
expect(widget.value).toBe('a fluffy tail');
});
});
describe('drag', () => {
it('moves an INT by step2 per 10px and records one undo step on release', () => {
const widget = intWidget(20);
setup([widget]);
const row = document.createElement('div');
document.body.appendChild(row);
row.setPointerCapture = vi.fn();
row.releasePointerCapture = vi.fn();
new DragValueController(row, {
nodeId: 5, widgetName: 'steps',
constraints: WidgetSyncManager.extractConstraints(widget)
});
row.dispatchEvent(new PointerEvent('pointerdown', { button: 0, clientX: 100, bubbles: true }));
document.dispatchEvent(new PointerEvent('pointermove', { clientX: 135 }));
expect(widget.value).toBe(23);
expect(checkState).not.toHaveBeenCalled();
document.dispatchEvent(new PointerEvent('pointerup', { clientX: 135 }));
expect(checkState).toHaveBeenCalledTimes(1);
// A later drag starts from the widget, whatever changed it in between
widget.value = 30;
row.dispatchEvent(new PointerEvent('pointerdown', { button: 0, clientX: 100, bubbles: true }));
document.dispatchEvent(new PointerEvent('pointermove', { clientX: 110 }));
expect(widget.value).toBe(31);
document.dispatchEvent(new PointerEvent('pointerup', { clientX: 110 }));
});
});
describe('getImportantNodeParameters', () => {
it('shows a real widget named type and marks linked widgets read-only', () => {
const items = getImportantNodeParameters({
id: 5, type: 'CLIPLoader',
widgets: [
{ name: 'clip_name', type: 'combo', value: 'clip.safetensors', options: { values: ['clip.safetensors'] } },
{ name: 'type', type: 'combo', value: 'stable_diffusion', options: { values: ['stable_diffusion'] } },
{ name: 'mode', type: 'text', value: 'not a real widget' },
{ ...intWidget(1), name: 'seed' }
],
inputs: [
{ name: 'clip_name', widget: { name: 'clip_name' } },
{ name: 'type', widget: { name: 'type' } },
{ name: 'seed', widget: { name: 'seed' }, link: 7 }
]
} as any);
expect(items.map(i => i.label)).toEqual(['clip_name', 'type', 'seed']);
const seed = items[2];
expect(seed.isLinked).toBe(true);
expect(seed.isEditable).toBe(false);
});
});