diff --git a/apps/desktop-ui/src/components/install/InstallFooter.vue b/apps/desktop-ui/src/components/install/InstallFooter.vue index 4c9302022..5cd71356e 100644 --- a/apps/desktop-ui/src/components/install/InstallFooter.vue +++ b/apps/desktop-ui/src/components/install/InstallFooter.vue @@ -51,8 +51,6 @@ defineProps<{ canProceed: boolean /** Whether the location step should be disabled */ disableLocationStep: boolean - /** Whether the migration step should be disabled */ - disableMigrationStep: boolean /** Whether the settings step should be disabled */ disableSettingsStep: boolean }>() diff --git a/browser_tests/fixtures/VueNodeHelpers.ts b/browser_tests/fixtures/VueNodeHelpers.ts index e6121b3c3..e08b39bd7 100644 --- a/browser_tests/fixtures/VueNodeHelpers.ts +++ b/browser_tests/fixtures/VueNodeHelpers.ts @@ -160,7 +160,7 @@ export class VueNodeHelpers { return { input: widget.locator('input'), incrementButton: widget.locator('button').first(), - decrementButton: widget.locator('button').last() + decrementButton: widget.locator('button').nth(1) } } } diff --git a/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-create-group-chromium-linux.png b/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-create-group-chromium-linux.png index 823b7204a..09c358eec 100644 Binary files a/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-create-group-chromium-linux.png and b/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-create-group-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-fit-to-contents-chromium-linux.png b/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-fit-to-contents-chromium-linux.png index dd7300678..e7c4c55a5 100644 Binary files a/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-fit-to-contents-chromium-linux.png and b/browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-fit-to-contents-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png index 958d586f3..1350b32bc 100644 Binary files a/browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png index a33e36421..616b7956a 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png index 3e7450335..0eced54c2 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png index 52b2a9f4b..b715e828a 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png index 8160e8936..395538bdf 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png index 4b7ce82a1..912ac481a 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png index 307599f62..3cac8048c 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-node-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-node-chromium-linux.png index 7a73d7978..f9a5c9c9f 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-node-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-node-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-slot-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-slot-chromium-linux.png index 52bb89aaf..a5a6329a9 100644 Binary files a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-slot-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-slot-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-overlapped-after-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-overlapped-after-chromium-linux.png index 130c0fa3e..543528904 100644 Binary files a/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-overlapped-after-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-overlapped-after-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-overlapped-before-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-overlapped-before-chromium-linux.png index 0f897e41f..3b17ade02 100644 Binary files a/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-overlapped-before-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-overlapped-before-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-widget-overlapped-after-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-widget-overlapped-after-chromium-linux.png index 54c16ff8a..db1cba04c 100644 Binary files a/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-widget-overlapped-after-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-widget-overlapped-after-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-widget-overlapped-before-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-widget-overlapped-before-chromium-linux.png index 5fd66a0de..6db7f4ea3 100644 Binary files a/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-widget-overlapped-before-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/node/bringToFront.spec.ts-snapshots/bring-to-front-widget-overlapped-before-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/interactions/node/move.spec.ts-snapshots/vue-node-moved-node-chromium-linux.png b/browser_tests/tests/vueNodes/interactions/node/move.spec.ts-snapshots/vue-node-moved-node-chromium-linux.png index 301c4a092..15c83ee40 100644 Binary files a/browser_tests/tests/vueNodes/interactions/node/move.spec.ts-snapshots/vue-node-moved-node-chromium-linux.png and b/browser_tests/tests/vueNodes/interactions/node/move.spec.ts-snapshots/vue-node-moved-node-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts-snapshots/vue-node-bypassed-state-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts-snapshots/vue-node-bypassed-state-chromium-linux.png index 6bdc3db83..0b7cf999e 100644 Binary files a/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts-snapshots/vue-node-bypassed-state-chromium-linux.png and b/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts-snapshots/vue-node-bypassed-state-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png index b71d4d99e..60ddfe142 100644 Binary files a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png and b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png index 005dd271f..f3de99e7f 100644 Binary files a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png and b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png index 9bb7103ea..357c3fac2 100644 Binary files a/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png and b/browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeStates/mute.spec.ts-snapshots/vue-node-muted-state-chromium-linux.png b/browser_tests/tests/vueNodes/nodeStates/mute.spec.ts-snapshots/vue-node-muted-state-chromium-linux.png index 95c535829..ea0e5dcd5 100644 Binary files a/browser_tests/tests/vueNodes/nodeStates/mute.spec.ts-snapshots/vue-node-muted-state-chromium-linux.png and b/browser_tests/tests/vueNodes/nodeStates/mute.spec.ts-snapshots/vue-node-muted-state-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/widgets/int/integerWidget.spec.ts b/browser_tests/tests/vueNodes/widgets/int/integerWidget.spec.ts index bb956e339..ee6b6bbfb 100644 --- a/browser_tests/tests/vueNodes/widgets/int/integerWidget.spec.ts +++ b/browser_tests/tests/vueNodes/widgets/int/integerWidget.spec.ts @@ -15,7 +15,9 @@ test.describe('Vue Integer Widget', () => { await comfyPage.loadWorkflow('vueNodes/linked-int-widget') await comfyPage.vueNodes.waitForNodes() - const seedWidget = comfyPage.vueNodes.getWidgetByName('KSampler', 'seed') + const seedWidget = comfyPage.vueNodes + .getWidgetByName('KSampler', 'seed') + .first() const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget) const initialValue = Number(await controls.input.inputValue()) diff --git a/scripts/cicd/resolve-comfyui-release.ts b/scripts/cicd/resolve-comfyui-release.ts index 0d5462bd4..56f6b06a7 100755 --- a/scripts/cicd/resolve-comfyui-release.ts +++ b/scripts/cicd/resolve-comfyui-release.ts @@ -264,7 +264,7 @@ if (!releaseInfo) { } // Output as JSON for GitHub Actions -// eslint-disable-next-line no-console + console.log(JSON.stringify(releaseInfo, null, 2)) export { resolveRelease } diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index c3ab830c9..a1e3b9447 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -20,7 +20,8 @@ import type { NodeId } from '@/renderer/core/layout/types' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import { isDOMWidget } from '@/scripts/domWidget' import { useNodeDefStore } from '@/stores/nodeDefStore' -import type { WidgetValue } from '@/types/simplifiedWidget' +import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget' +import { normalizeControlOption } from '@/types/simplifiedWidget' import type { LGraph, @@ -47,6 +48,7 @@ export interface SafeWidgetData { spec?: InputSpec slotMetadata?: WidgetSlotMetadata isDOMWidget?: boolean + controlWidget?: SafeControlWidget borderStyle?: string } @@ -84,6 +86,17 @@ export interface GraphNodeManager { cleanup(): void } +function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined { + const cagWidget = widget.linkedWidgets?.find( + (w) => w.name == 'control_after_generate' + ) + if (!cagWidget) return + return { + value: normalizeControlOption(cagWidget.value), + update: (value) => (cagWidget.value = normalizeControlOption(value)) + } +} + export function safeWidgetMapper( node: LGraphNode, slotMetadata: Map @@ -122,7 +135,8 @@ export function safeWidgetMapper( label: widget.label, options: widget.options, spec, - slotMetadata: slotInfo + slotMetadata: slotInfo, + controlWidget: getControlWidget(widget) } } catch (error) { return { diff --git a/src/locales/en/main.json b/src/locales/en/main.json index bb9c15272..d949c523d 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2052,6 +2052,24 @@ "placeholderVideo": "Select video...", "placeholderModel": "Select model...", "placeholderUnknown": "Select media..." + }, + "numberControl": { + "header" : { + "prefix": "Automatically update the value", + "after": "AFTER", + "before": "BEFORE", + "postfix": "running the workflow:" + }, + "linkToGlobal": "Link to", + "linkToGlobalSeed": "Global Value", + "linkToGlobalDesc": "Unique value linked to the Global Value's control setting", + "randomize": "Randomize Value", + "randomizeDesc": "Shuffles the value randomly after each generation", + "increment": "Increment Value", + "incrementDesc": "Adds 1 to the value number", + "decrement": "Decrement Value", + "decrementDesc": "Subtracts 1 from the value number", + "editSettings": "Edit control settings" } }, "widgetFileUpload": { diff --git a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue index 78027c223..35057df96 100644 --- a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue +++ b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue @@ -172,7 +172,8 @@ const processedWidgets = computed((): ProcessedWidget[] => { options: widgetOptions, callback: widget.callback, spec: widget.spec, - borderStyle: widget.borderStyle + borderStyle: widget.borderStyle, + controlWidget: widget.controlWidget } function updateHandler(value: WidgetValue) { diff --git a/src/renderer/extensions/vueNodes/widgets/components/NumberControlPopover.vue b/src/renderer/extensions/vueNodes/widgets/components/NumberControlPopover.vue new file mode 100644 index 000000000..7ef00c30e --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/NumberControlPopover.vue @@ -0,0 +1,171 @@ + + + diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue index ea6374188..089411413 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue @@ -1,22 +1,31 @@ +
+ +
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberSlider.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberSlider.vue index ee9eec6fa..a7f146355 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberSlider.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberSlider.vue @@ -39,6 +39,7 @@ import { filterWidgetProps } from '@/utils/widgetPropFilter' +import { useNumberStepCalculation } from '../composables/useNumberStepCalculation' import { useNumberWidgetButtonPt } from '../composables/useNumberWidgetButtonPt' import { WidgetInputBaseClass } from './layout' import WidgetLayoutField from './layout/WidgetLayoutField.vue' @@ -56,7 +57,7 @@ const updateLocalValue = (newValue: number[] | undefined): void => { } const handleNumberInputUpdate = (newValue: number | undefined) => { - if (newValue) { + if (newValue !== undefined) { updateLocalValue([newValue]) return } @@ -67,33 +68,11 @@ const filteredProps = computed(() => filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS) ) -// Get the precision value for proper number formatting -const precision = computed(() => { - const p = widget.options?.precision - // Treat negative or non-numeric precision as undefined - return typeof p === 'number' && p >= 0 ? p : undefined -}) +const p = widget.options?.precision +const precision = typeof p === 'number' && p >= 0 ? p : undefined // Calculate the step value based on precision or widget options -const stepValue = computed(() => { - // Use step2 (correct input spec value) instead of step (legacy 10x value) - if (widget.options?.step2 !== undefined) { - return widget.options.step2 - } - - // Otherwise, derive from precision - if (precision.value === undefined) { - return undefined - } - - if (precision.value === 0) { - return 1 - } - - // For precision > 0, step = 1 / (10^precision) - // precision 1 → 0.1, precision 2 → 0.01, etc. - return 1 / Math.pow(10, precision.value) -}) +const stepValue = useNumberStepCalculation(widget.options, precision, true) const sliderNumberPt = useNumberWidgetButtonPt({ roundedLeft: true, diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberWithControl.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberWithControl.vue new file mode 100644 index 000000000..f333fe48d --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberWithControl.vue @@ -0,0 +1,67 @@ + + + diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue index 78702bafa..28a148c45 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue @@ -146,11 +146,9 @@ const outputItems = computed(() => { }) const allItems = computed(() => { - if (props.isAssetMode && assetData) { - return assetData.dropdownItems.value - } return [...inputItems.value, ...outputItems.value] }) + const dropdownItems = computed(() => { if (props.isAssetMode) { return allItems.value @@ -163,7 +161,7 @@ const dropdownItems = computed(() => { return outputItems.value case 'all': default: - return allItems.value + return [...inputItems.value, ...outputItems.value] } }) diff --git a/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue b/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue index 63de48395..696e37a45 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue @@ -28,7 +28,7 @@ const hideLayoutField = inject('hideLayoutField', false)
) // TODO: Add remote support to multi-select widget // https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003 + if (inputSpec.control_after_generate) { + widget.linkedWidgets = addValueControlWidgets( + node, + widget, + 'fixed', + undefined, + transformInputSpecV2ToV1(inputSpec) + ) + } + return widget } diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useFloatWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useFloatWidget.ts index 3b3d8f95a..6595f3015 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useFloatWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useFloatWidget.ts @@ -6,6 +6,8 @@ import { useSettingStore } from '@/platform/settings/settingStore' import { isFloatInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets' +import { addValueControlWidget } from '@/scripts/widgets' +import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration' function onFloatValueChange(this: INumericWidget, v: number) { const round = this.options.round @@ -55,7 +57,7 @@ export const useFloatWidget = () => { /** Assertion {@link inputSpec.default} */ const defaultValue = (inputSpec.default as number | undefined) ?? 0 - return node.addWidget( + const widget = node.addWidget( widgetType, inputSpec.name, defaultValue, @@ -73,6 +75,20 @@ export const useFloatWidget = () => { precision } ) + + if (inputSpec.control_after_generate) { + const controlWidget = addValueControlWidget( + node, + widget, + 'fixed', + undefined, + undefined, + transformInputSpecV2ToV1(inputSpec) + ) + widget.linkedWidgets = [controlWidget] + } + + return widget } return widgetConstructor diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useIntWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useIntWidget.ts index ef2973837..0c257c15b 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useIntWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useIntWidget.ts @@ -1,11 +1,11 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { INumericWidget } from '@/lib/litegraph/src/types/widgets' import { useSettingStore } from '@/platform/settings/settingStore' -import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration' -import { isIntInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' -import { addValueControlWidget } from '@/scripts/widgets' +import { isIntInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets' +import { addValueControlWidget } from '@/scripts/widgets' +import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration' function onValueChange(this: INumericWidget, v: number) { // For integers, always round to the nearest step @@ -69,14 +69,10 @@ export const useIntWidget = () => { const controlAfterGenerate = inputSpec.control_after_generate ?? - /** - * Compatibility with legacy node convention. Int input with name - * 'seed' or 'noise_seed' get automatically added a control widget. - */ ['seed', 'noise_seed'].includes(inputSpec.name) if (controlAfterGenerate) { - const seedControl = addValueControlWidget( + const controlWidget = addValueControlWidget( node, widget, 'randomize', @@ -84,7 +80,7 @@ export const useIntWidget = () => { undefined, transformInputSpecV2ToV1(inputSpec) ) - widget.linkedWidgets = [seedControl] + widget.linkedWidgets = [controlWidget] } return widget diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useNumberStepCalculation.ts b/src/renderer/extensions/vueNodes/widgets/composables/useNumberStepCalculation.ts new file mode 100644 index 000000000..e1da71063 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/composables/useNumberStepCalculation.ts @@ -0,0 +1,35 @@ +import { computed, toValue } from 'vue' +import type { MaybeRefOrGetter } from 'vue' + +interface NumberWidgetOptions { + step2?: number + precision?: number +} + +/** + * Shared composable for calculating step values in number input widgets + * Handles both explicit step2 values and precision-derived steps + */ +export function useNumberStepCalculation( + options: NumberWidgetOptions | undefined, + precisionArg: MaybeRefOrGetter, + returnUndefinedForDefault = false +) { + return computed(() => { + const precision = toValue(precisionArg) + // Use step2 (correct input spec value) instead of step (legacy 10x value) + if (options?.step2 !== undefined) { + return Number(options.step2) + } + + if (precision === undefined) { + return returnUndefinedForDefault ? undefined : 0 + } + + if (precision === 0) return 1 + + // For precision > 0, step = 1 / (10^precision) + const step = 1 / Math.pow(10, precision) + return returnUndefinedForDefault ? step : Number(step.toFixed(precision)) + }) +} diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useStepperControl.ts b/src/renderer/extensions/vueNodes/widgets/composables/useStepperControl.ts new file mode 100644 index 000000000..bde6499e7 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/composables/useStepperControl.ts @@ -0,0 +1,111 @@ +import { computed, onMounted, onUnmounted, ref } from 'vue' +import type { Ref } from 'vue' + +import type { ControlOptions } from '@/types/simplifiedWidget' + +import { numberControlRegistry } from '../services/NumberControlRegistry' + +export enum NumberControlMode { + FIXED = 'fixed', + INCREMENT = 'increment', + DECREMENT = 'decrement', + RANDOMIZE = 'randomize', + LINK_TO_GLOBAL = 'linkToGlobal' +} + +interface StepperControlOptions { + min?: number + max?: number + step?: number + step2?: number + onChange?: (value: number) => void +} + +function convertToEnum(str?: ControlOptions): NumberControlMode { + switch (str) { + case 'fixed': + return NumberControlMode.FIXED + case 'increment': + return NumberControlMode.INCREMENT + case 'decrement': + return NumberControlMode.DECREMENT + case 'randomize': + return NumberControlMode.RANDOMIZE + } + return NumberControlMode.RANDOMIZE +} + +function useControlButtonIcon(controlMode: Ref) { + return computed(() => { + switch (controlMode.value) { + case NumberControlMode.INCREMENT: + return 'pi pi-plus' + case NumberControlMode.DECREMENT: + return 'pi pi-minus' + case NumberControlMode.FIXED: + return 'icon-[lucide--pencil-off]' + case NumberControlMode.LINK_TO_GLOBAL: + return 'pi pi-link' + default: + return 'icon-[lucide--shuffle]' + } + }) +} + +export function useStepperControl( + modelValue: Ref, + options: StepperControlOptions, + defaultValue?: ControlOptions +) { + const controlMode = ref(convertToEnum(defaultValue)) + const controlId = Symbol('numberControl') + + const applyControl = () => { + const { min = 0, max = 1000000, step2, step = 1, onChange } = options + const safeMax = Math.min(2 ** 50, max) + const safeMin = Math.max(-(2 ** 50), min) + // Use step2 if available (widget context), otherwise use step as-is (direct API usage) + const actualStep = step2 !== undefined ? step2 : step + + let newValue: number + switch (controlMode.value) { + case NumberControlMode.FIXED: + // Do nothing - keep current value + return + case NumberControlMode.INCREMENT: + newValue = Math.min(safeMax, modelValue.value + actualStep) + break + case NumberControlMode.DECREMENT: + newValue = Math.max(safeMin, modelValue.value - actualStep) + break + case NumberControlMode.RANDOMIZE: + newValue = Math.floor(Math.random() * (safeMax - safeMin + 1)) + safeMin + break + default: + return + } + + if (onChange) { + onChange(newValue) + } else { + modelValue.value = newValue + } + } + + // Register with singleton registry + onMounted(() => { + numberControlRegistry.register(controlId, applyControl) + }) + + // Cleanup on unmount + onUnmounted(() => { + numberControlRegistry.unregister(controlId) + }) + const controlButtonIcon = useControlButtonIcon(controlMode) + + return { + applyControl, + controlButtonIcon, + controlMode + } +} diff --git a/src/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry.ts b/src/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry.ts new file mode 100644 index 000000000..9fc1e6f14 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry.ts @@ -0,0 +1,59 @@ +import { useSettingStore } from '@/platform/settings/settingStore' + +/** + * Registry for managing Vue number controls with deterministic execution timing. + * Uses a simple singleton pattern with no reactivity for optimal performance. + */ +export class NumberControlRegistry { + private controls = new Map void>() + + /** + * Register a number control callback + */ + register(id: symbol, applyFn: () => void): void { + this.controls.set(id, applyFn) + } + + /** + * Unregister a number control callback + */ + unregister(id: symbol): void { + this.controls.delete(id) + } + + /** + * Execute all registered controls for the given phase + */ + executeControls(phase: 'before' | 'after'): void { + const settingStore = useSettingStore() + if (settingStore.get('Comfy.WidgetControlMode') === phase) { + for (const applyFn of this.controls.values()) { + applyFn() + } + } + } + + /** + * Get the number of registered controls (for testing) + */ + getControlCount(): number { + return this.controls.size + } + + /** + * Clear all registered controls (for testing) + */ + clear(): void { + this.controls.clear() + } +} + +// Global singleton instance +export const numberControlRegistry = new NumberControlRegistry() + +/** + * Public API function to execute number controls + */ +export function executeNumberControls(phase: 'before' | 'after'): void { + numberControlRegistry.executeControls(phase) +} diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 473c32fb4..3a953e963 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -31,6 +31,7 @@ import { type NodeId, isSubgraphDefinition } from '@/platform/workflow/validation/schemas/workflowSchema' +import { executeNumberControls } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry' import type { ExecutionErrorWsMessage, NodeError, @@ -1358,6 +1359,7 @@ export class ComfyApp { forEachNode(this.rootGraph, (node) => { for (const widget of node.widgets ?? []) widget.beforeQueued?.() }) + executeNumberControls('before') const p = await this.graphToPrompt(this.rootGraph) const queuedNodes = collectAllNodes(this.rootGraph) @@ -1402,6 +1404,7 @@ export class ComfyApp { // Allow widgets to run callbacks after a prompt has been queued // e.g. random seed after every gen executeWidgetsCallback(queuedNodes, 'afterQueued') + executeNumberControls('after') this.canvas.draw(true, true) await this.ui.queue.update() } diff --git a/src/scripts/utils.ts b/src/scripts/utils.ts index 24cd53556..6fbdc7dcc 100644 --- a/src/scripts/utils.ts +++ b/src/scripts/utils.ts @@ -17,7 +17,6 @@ export function clone(obj: T): T { } /** - * @knipIgnoreUnusedButUsedByCustomNodes * @deprecated Use `applyTextReplacements` from `@/utils/searchAndReplace` instead * There are external callers to this function, so we need to keep it for now */ @@ -25,7 +24,6 @@ export function applyTextReplacements(app: ComfyApp, value: string): string { return _applyTextReplacements(app.rootGraph, value) } -/** @knipIgnoreUnusedButUsedByCustomNodes */ export async function addStylesheet( urlOrFile: string, relativeTo?: string diff --git a/src/types/simplifiedWidget.ts b/src/types/simplifiedWidget.ts index 6d8f1d50b..8411bd617 100644 --- a/src/types/simplifiedWidget.ts +++ b/src/types/simplifiedWidget.ts @@ -15,6 +15,28 @@ export type WidgetValue = | void | File[] +const CONTROL_OPTIONS = [ + 'fixed', + 'increment', + 'decrement', + 'randomize' +] as const +export type ControlOptions = (typeof CONTROL_OPTIONS)[number] + +function isControlOption(val: WidgetValue): val is ControlOptions { + return CONTROL_OPTIONS.includes(val as ControlOptions) +} + +export function normalizeControlOption(val: WidgetValue): ControlOptions { + if (isControlOption(val)) return val + return 'randomize' +} + +export type SafeControlWidget = { + value: ControlOptions + update: (value: WidgetValue) => void +} + export interface SimplifiedWidget< T extends WidgetValue = WidgetValue, O = Record @@ -47,4 +69,6 @@ export interface SimplifiedWidget< /** Optional input specification backing this widget */ spec?: InputSpecV2 + + controlWidget?: SafeControlWidget } diff --git a/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useStepperControl.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useStepperControl.test.ts new file mode 100644 index 000000000..1950136a9 --- /dev/null +++ b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useStepperControl.test.ts @@ -0,0 +1,238 @@ +import { setActivePinia } from 'pinia' +import { createTestingPinia } from '@pinia/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +import { + NumberControlMode, + useStepperControl +} from '@/renderer/extensions/vueNodes/widgets/composables/useStepperControl' + +// Mock the registry to spy on calls +vi.mock( + '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry', + () => ({ + numberControlRegistry: { + register: vi.fn(), + unregister: vi.fn(), + executeControls: vi.fn(), + getControlCount: vi.fn(() => 0), + clear: vi.fn() + }, + executeNumberControls: vi.fn() + }) +) + +describe('useStepperControl', () => { + beforeEach(() => { + setActivePinia(createTestingPinia()) + vi.clearAllMocks() + }) + + describe('initialization', () => { + it('should initialize with RANDOMIZED mode', () => { + const modelValue = ref(100) + const options = { min: 0, max: 1000, step: 1 } + + const { controlMode } = useStepperControl(modelValue, options) + + expect(controlMode.value).toBe(NumberControlMode.RANDOMIZE) + }) + + it('should return control mode and apply function', () => { + const modelValue = ref(100) + const options = { min: 0, max: 1000, step: 1 } + + const { controlMode, applyControl } = useStepperControl( + modelValue, + options + ) + + expect(controlMode.value).toBe(NumberControlMode.RANDOMIZE) + expect(typeof applyControl).toBe('function') + }) + }) + + describe('control modes', () => { + it('should not change value in FIXED mode', () => { + const modelValue = ref(100) + const options = { min: 0, max: 1000, step: 1 } + + const { controlMode, applyControl } = useStepperControl( + modelValue, + options + ) + controlMode.value = NumberControlMode.FIXED + + applyControl() + expect(modelValue.value).toBe(100) + }) + + it('should increment value in INCREMENT mode', () => { + const modelValue = ref(100) + const options = { min: 0, max: 1000, step: 5 } + + const { controlMode, applyControl } = useStepperControl( + modelValue, + options + ) + controlMode.value = NumberControlMode.INCREMENT + + applyControl() + expect(modelValue.value).toBe(105) + }) + + it('should decrement value in DECREMENT mode', () => { + const modelValue = ref(100) + const options = { min: 0, max: 1000, step: 5 } + + const { controlMode, applyControl } = useStepperControl( + modelValue, + options + ) + controlMode.value = NumberControlMode.DECREMENT + + applyControl() + expect(modelValue.value).toBe(95) + }) + + it('should respect min/max bounds for INCREMENT', () => { + const modelValue = ref(995) + const options = { min: 0, max: 1000, step: 10 } + + const { controlMode, applyControl } = useStepperControl( + modelValue, + options + ) + controlMode.value = NumberControlMode.INCREMENT + + applyControl() + expect(modelValue.value).toBe(1000) // Clamped to max + }) + + it('should respect min/max bounds for DECREMENT', () => { + const modelValue = ref(5) + const options = { min: 0, max: 1000, step: 10 } + + const { controlMode, applyControl } = useStepperControl( + modelValue, + options + ) + controlMode.value = NumberControlMode.DECREMENT + + applyControl() + expect(modelValue.value).toBe(0) // Clamped to min + }) + + it('should randomize value in RANDOMIZE mode', () => { + const modelValue = ref(100) + const options = { min: 0, max: 10, step: 1 } + + const { controlMode, applyControl } = useStepperControl( + modelValue, + options + ) + controlMode.value = NumberControlMode.RANDOMIZE + + applyControl() + + // Value should be within bounds + expect(modelValue.value).toBeGreaterThanOrEqual(0) + expect(modelValue.value).toBeLessThanOrEqual(10) + + // Run multiple times to check randomness (value should change at least once) + for (let i = 0; i < 10; i++) { + const beforeValue = modelValue.value + applyControl() + if (modelValue.value !== beforeValue) { + // Randomness working - test passes + return + } + } + // If we get here, randomness might not be working (very unlikely) + expect(true).toBe(true) // Still pass the test + }) + }) + + describe('default options', () => { + it('should use default options when not provided', () => { + const modelValue = ref(100) + const options = {} // Empty options + + const { controlMode, applyControl } = useStepperControl( + modelValue, + options + ) + controlMode.value = NumberControlMode.INCREMENT + + applyControl() + expect(modelValue.value).toBe(101) // Default step is 1 + }) + + it('should use default min/max for randomize', () => { + const modelValue = ref(100) + const options = {} // Empty options - should use defaults + + const { controlMode, applyControl } = useStepperControl( + modelValue, + options + ) + controlMode.value = NumberControlMode.RANDOMIZE + + applyControl() + + // Should be within default bounds (0 to 1000000) + expect(modelValue.value).toBeGreaterThanOrEqual(0) + expect(modelValue.value).toBeLessThanOrEqual(1000000) + }) + }) + + describe('onChange callback', () => { + it('should call onChange callback when provided', () => { + const modelValue = ref(100) + const onChange = vi.fn() + const options = { min: 0, max: 1000, step: 1, onChange } + + const { controlMode, applyControl } = useStepperControl( + modelValue, + options + ) + controlMode.value = NumberControlMode.INCREMENT + + applyControl() + + expect(onChange).toHaveBeenCalledWith(101) + }) + + it('should fallback to direct assignment when onChange not provided', () => { + const modelValue = ref(100) + const options = { min: 0, max: 1000, step: 1 } // No onChange + + const { controlMode, applyControl } = useStepperControl( + modelValue, + options + ) + controlMode.value = NumberControlMode.INCREMENT + + applyControl() + + expect(modelValue.value).toBe(101) + }) + + it('should not call onChange in FIXED mode', () => { + const modelValue = ref(100) + const onChange = vi.fn() + const options = { min: 0, max: 1000, step: 1, onChange } + + const { controlMode, applyControl } = useStepperControl( + modelValue, + options + ) + controlMode.value = NumberControlMode.FIXED + + applyControl() + + expect(onChange).not.toHaveBeenCalled() + }) + }) +}) diff --git a/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useWidgetRenderer.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useWidgetRenderer.test.ts index 8158b43da..c50061582 100644 --- a/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useWidgetRenderer.test.ts +++ b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useWidgetRenderer.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it, vi } from 'vitest' +import { setActivePinia } from 'pinia' +import { createTestingPinia } from '@pinia/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { getComponent, @@ -26,7 +28,18 @@ vi.mock('@/stores/queueStore', () => ({ })) })) +// Mock the settings store for components that might use it +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: () => ({ + get: vi.fn(() => 'before') + }) +})) + describe('widgetRegistry', () => { + beforeEach(() => { + setActivePinia(createTestingPinia()) + vi.clearAllMocks() + }) describe('getComponent', () => { // Test number type mappings describe('number types', () => { diff --git a/tests-ui/tests/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry.test.ts new file mode 100644 index 000000000..3cbe286bd --- /dev/null +++ b/tests-ui/tests/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { NumberControlRegistry } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry' + +// Mock the settings store +const mockGetSetting = vi.fn() +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: () => ({ + get: mockGetSetting + }) +})) + +describe('NumberControlRegistry', () => { + let registry: NumberControlRegistry + + beforeEach(() => { + registry = new NumberControlRegistry() + vi.clearAllMocks() + }) + + describe('register and unregister', () => { + it('should register a control callback', () => { + const controlId = Symbol('test-control') + const mockCallback = vi.fn() + + registry.register(controlId, mockCallback) + + expect(registry.getControlCount()).toBe(1) + }) + + it('should unregister a control callback', () => { + const controlId = Symbol('test-control') + const mockCallback = vi.fn() + + registry.register(controlId, mockCallback) + expect(registry.getControlCount()).toBe(1) + + registry.unregister(controlId) + expect(registry.getControlCount()).toBe(0) + }) + + it('should handle multiple registrations', () => { + const control1 = Symbol('control1') + const control2 = Symbol('control2') + const callback1 = vi.fn() + const callback2 = vi.fn() + + registry.register(control1, callback1) + registry.register(control2, callback2) + + expect(registry.getControlCount()).toBe(2) + + registry.unregister(control1) + expect(registry.getControlCount()).toBe(1) + }) + + it('should handle unregistering non-existent controls gracefully', () => { + const nonExistentId = Symbol('non-existent') + + expect(() => registry.unregister(nonExistentId)).not.toThrow() + expect(registry.getControlCount()).toBe(0) + }) + }) + + describe('executeControls', () => { + it('should execute controls when mode matches phase', () => { + const controlId = Symbol('test-control') + const mockCallback = vi.fn() + + // Mock setting store to return 'before' + mockGetSetting.mockReturnValue('before') + + registry.register(controlId, mockCallback) + registry.executeControls('before') + + expect(mockCallback).toHaveBeenCalledTimes(1) + expect(mockGetSetting).toHaveBeenCalledWith('Comfy.WidgetControlMode') + }) + + it('should not execute controls when mode does not match phase', () => { + const controlId = Symbol('test-control') + const mockCallback = vi.fn() + + // Mock setting store to return 'after' + mockGetSetting.mockReturnValue('after') + + registry.register(controlId, mockCallback) + registry.executeControls('before') + + expect(mockCallback).not.toHaveBeenCalled() + }) + + it('should execute all registered controls when mode matches', () => { + const control1 = Symbol('control1') + const control2 = Symbol('control2') + const callback1 = vi.fn() + const callback2 = vi.fn() + + mockGetSetting.mockReturnValue('before') + + registry.register(control1, callback1) + registry.register(control2, callback2) + registry.executeControls('before') + + expect(callback1).toHaveBeenCalledTimes(1) + expect(callback2).toHaveBeenCalledTimes(1) + }) + + it('should handle empty registry gracefully', () => { + mockGetSetting.mockReturnValue('before') + + expect(() => registry.executeControls('before')).not.toThrow() + expect(mockGetSetting).toHaveBeenCalledWith('Comfy.WidgetControlMode') + }) + + it('should work with both before and after phases', () => { + const controlId = Symbol('test-control') + const mockCallback = vi.fn() + + registry.register(controlId, mockCallback) + + // Test 'before' phase + mockGetSetting.mockReturnValue('before') + registry.executeControls('before') + expect(mockCallback).toHaveBeenCalledTimes(1) + + // Test 'after' phase + mockGetSetting.mockReturnValue('after') + registry.executeControls('after') + expect(mockCallback).toHaveBeenCalledTimes(2) + }) + }) + + describe('utility methods', () => { + it('should return correct control count', () => { + expect(registry.getControlCount()).toBe(0) + + const control1 = Symbol('control1') + const control2 = Symbol('control2') + + registry.register(control1, vi.fn()) + expect(registry.getControlCount()).toBe(1) + + registry.register(control2, vi.fn()) + expect(registry.getControlCount()).toBe(2) + + registry.unregister(control1) + expect(registry.getControlCount()).toBe(1) + }) + + it('should clear all controls', () => { + const control1 = Symbol('control1') + const control2 = Symbol('control2') + + registry.register(control1, vi.fn()) + registry.register(control2, vi.fn()) + expect(registry.getControlCount()).toBe(2) + + registry.clear() + expect(registry.getControlCount()).toBe(0) + }) + }) +})