mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-17 21:21:06 +00:00
Compare commits
11 Commits
fix/subgra
...
austin/rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
574a3c21c9 | ||
|
|
87c3ba636f | ||
|
|
48962a4970 | ||
|
|
c4efedfa1d | ||
|
|
1c75a49978 | ||
|
|
090ee7a981 | ||
|
|
a07097e25d | ||
|
|
948320137e | ||
|
|
a50eb8dd57 | ||
|
|
47650bebf8 | ||
|
|
3eece91eb6 |
@@ -3,7 +3,8 @@
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { reactiveComputed } from '@vueuse/core'
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
import { reactive, ref, shallowReactive, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type {
|
||||
@@ -20,7 +21,7 @@ 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, SafeControlWidget } from '@/types/simplifiedWidget'
|
||||
import type { WidgetValue, ControlOptions } from '@/types/simplifiedWidget'
|
||||
import { normalizeControlOption } from '@/types/simplifiedWidget'
|
||||
|
||||
import type {
|
||||
@@ -41,15 +42,15 @@ export interface WidgetSlotMetadata {
|
||||
export interface SafeWidgetData {
|
||||
name: string
|
||||
type: string
|
||||
value: WidgetValue
|
||||
value: () => Ref<WidgetValue>
|
||||
borderStyle?: string
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
controlWidget?: () => Ref<ControlOptions>
|
||||
isDOMWidget?: boolean
|
||||
label?: string
|
||||
options?: IWidgetOptions<unknown>
|
||||
callback?: ((value: unknown) => void) | undefined
|
||||
spec?: InputSpec
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
isDOMWidget?: boolean
|
||||
controlWidget?: SafeControlWidget
|
||||
borderStyle?: string
|
||||
spec?: InputSpec
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
@@ -86,15 +87,47 @@ export interface GraphNodeManager {
|
||||
cleanup(): void
|
||||
}
|
||||
|
||||
function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
|
||||
function normalizeWidgetValue(value: unknown): WidgetValue {
|
||||
if (value === null || value === undefined || value === void 0) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
// Check if it's a File array
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((item): item is File => item instanceof File)
|
||||
) {
|
||||
return value
|
||||
}
|
||||
// Otherwise it's a generic object
|
||||
return value
|
||||
}
|
||||
// If none of the above, return undefined
|
||||
console.warn(`Invalid widget value type: ${typeof value}`, value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getControlWidget(
|
||||
widget: IBaseWidget
|
||||
): (() => Ref<ControlOptions>) | 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))
|
||||
}
|
||||
const cagRef = ref<ControlOptions>(normalizeControlOption(cagWidget.value))
|
||||
watch(cagRef, (value) => {
|
||||
cagWidget.value = normalizeControlOption(value)
|
||||
cagWidget.callback?.(cagWidget.value)
|
||||
})
|
||||
return () => cagRef
|
||||
}
|
||||
|
||||
export function safeWidgetMapper(
|
||||
@@ -104,18 +137,27 @@ export function safeWidgetMapper(
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
return function (widget) {
|
||||
try {
|
||||
// TODO: Use widget.getReactiveData() once TypeScript types are updated
|
||||
let value = widget.value
|
||||
|
||||
// For combo widgets, if value is undefined, use the first option as default
|
||||
if (
|
||||
value === undefined &&
|
||||
widget.value === undefined &&
|
||||
widget.type === 'combo' &&
|
||||
widget.options?.values &&
|
||||
Array.isArray(widget.options.values) &&
|
||||
widget.options.values.length > 0
|
||||
) {
|
||||
value = widget.options.values[0]
|
||||
widget.value = widget.options.values[0]
|
||||
}
|
||||
if (!widget.valueRef) {
|
||||
const valueRef = ref(widget.value)
|
||||
watch(valueRef, (newValue) => {
|
||||
widget.value = newValue
|
||||
widget.callback?.(newValue)
|
||||
})
|
||||
widget.callback = useChainCallback(widget.callback, () => {
|
||||
if (valueRef.value !== widget.value)
|
||||
valueRef.value = normalizeWidgetValue(widget.value) ?? undefined
|
||||
})
|
||||
widget.valueRef = () => valueRef
|
||||
}
|
||||
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
@@ -128,9 +170,8 @@ export function safeWidgetMapper(
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: value,
|
||||
value: widget.valueRef,
|
||||
borderStyle,
|
||||
callback: widget.callback,
|
||||
isDOMWidget: isDOMWidget(widget),
|
||||
label: widget.label,
|
||||
options: widget.options,
|
||||
@@ -142,7 +183,7 @@ export function safeWidgetMapper(
|
||||
return {
|
||||
name: widget.name || 'unknown',
|
||||
type: widget.type || 'text',
|
||||
value: undefined
|
||||
value: () => ref()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,6 +259,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
|
||||
}
|
||||
})
|
||||
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
|
||||
Object.defineProperty(node, 'inputs', {
|
||||
get() {
|
||||
return reactiveInputs
|
||||
},
|
||||
set(v) {
|
||||
reactiveInputs.splice(0, reactiveInputs.length, ...v)
|
||||
}
|
||||
})
|
||||
|
||||
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
|
||||
node.inputs?.forEach((input, index) => {
|
||||
@@ -252,7 +302,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
badges,
|
||||
hasErrors: !!node.has_errors,
|
||||
widgets: safeWidgets,
|
||||
inputs: node.inputs ? [...node.inputs] : undefined,
|
||||
inputs: reactiveInputs,
|
||||
outputs: node.outputs ? [...node.outputs] : undefined,
|
||||
flags: node.flags ? { ...node.flags } : undefined,
|
||||
color: node.color || undefined,
|
||||
@@ -266,128 +316,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
return nodeRefs.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a value is a valid WidgetValue type
|
||||
*/
|
||||
const validateWidgetValue = (value: unknown): WidgetValue => {
|
||||
if (value === null || value === undefined || value === void 0) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
// Check if it's a File array
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((item): item is File => item instanceof File)
|
||||
) {
|
||||
return value
|
||||
}
|
||||
// Otherwise it's a generic object
|
||||
return value
|
||||
}
|
||||
// If none of the above, return undefined
|
||||
console.warn(`Invalid widget value type: ${typeof value}`, value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates Vue state when widget values change
|
||||
*/
|
||||
const updateVueWidgetState = (
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
value: unknown
|
||||
): void => {
|
||||
try {
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
if (!currentData?.widgets) return
|
||||
|
||||
const updatedWidgets = currentData.widgets.map((w) =>
|
||||
w.name === widgetName ? { ...w, value: validateWidgetValue(value) } : w
|
||||
)
|
||||
// Create a completely new object to ensure Vue reactivity triggers
|
||||
const updatedData = {
|
||||
...currentData,
|
||||
widgets: updatedWidgets
|
||||
}
|
||||
|
||||
vueNodeData.set(nodeId, updatedData)
|
||||
} catch (error) {
|
||||
// Ignore widget update errors to prevent cascade failures
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync
|
||||
*/
|
||||
const createWrappedWidgetCallback = (
|
||||
widget: { value?: unknown; name: string }, // LiteGraph widget with minimal typing
|
||||
originalCallback: ((value: unknown) => void) | undefined,
|
||||
nodeId: string
|
||||
) => {
|
||||
let updateInProgress = false
|
||||
|
||||
return (value: unknown) => {
|
||||
if (updateInProgress) return
|
||||
updateInProgress = true
|
||||
|
||||
try {
|
||||
// 1. Update the widget value in LiteGraph (critical for LiteGraph state)
|
||||
// Validate that the value is of an acceptable type
|
||||
if (
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
typeof value !== 'string' &&
|
||||
typeof value !== 'number' &&
|
||||
typeof value !== 'boolean' &&
|
||||
typeof value !== 'object'
|
||||
) {
|
||||
console.warn(`Invalid widget value type: ${typeof value}`)
|
||||
updateInProgress = false
|
||||
return
|
||||
}
|
||||
|
||||
// Always update widget.value to ensure sync
|
||||
widget.value = value
|
||||
|
||||
// 2. Call the original callback if it exists
|
||||
if (originalCallback) {
|
||||
originalCallback.call(widget, value)
|
||||
}
|
||||
|
||||
// 3. Update Vue state to maintain synchronization
|
||||
updateVueWidgetState(nodeId, widget.name, value)
|
||||
} finally {
|
||||
updateInProgress = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up widget callbacks for a node
|
||||
*/
|
||||
const setupNodeWidgetCallbacks = (node: LGraphNode) => {
|
||||
if (!node.widgets) return
|
||||
|
||||
const nodeId = String(node.id)
|
||||
|
||||
node.widgets.forEach((widget) => {
|
||||
const originalCallback = widget.callback
|
||||
widget.callback = createWrappedWidgetCallback(
|
||||
widget,
|
||||
originalCallback,
|
||||
nodeId
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const syncWithGraph = () => {
|
||||
if (!graph?._nodes) return
|
||||
|
||||
@@ -408,9 +336,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Store non-reactive reference
|
||||
nodeRefs.set(id, node)
|
||||
|
||||
// Set up widget callbacks BEFORE extracting data (critical order)
|
||||
setupNodeWidgetCallbacks(node)
|
||||
|
||||
// Extract and store safe data for Vue
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
})
|
||||
@@ -429,9 +354,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Store non-reactive reference to original node
|
||||
nodeRefs.set(id, node)
|
||||
|
||||
// Set up widget callbacks BEFORE extracting data (critical order)
|
||||
setupNodeWidgetCallbacks(node)
|
||||
|
||||
// Extract initial data for Vue (may be incomplete during graph configure)
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
|
||||
|
||||
@@ -257,6 +257,7 @@ export class PrimitiveNode extends LGraphNode {
|
||||
undefined,
|
||||
inputData
|
||||
)
|
||||
if (this.widgets?.[1]) widget.linkedWidgets = [this.widgets[1]]
|
||||
let filter = this.widgets_values?.[2]
|
||||
if (filter && this.widgets && this.widgets.length === 3) {
|
||||
this.widgets[2].value = filter
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { CanvasColour, Point, RequiredProps, Size } from '../interfaces'
|
||||
import type { CanvasPointer, LGraphCanvas, LGraphNode } from '../litegraph'
|
||||
import type { CanvasPointerEvent } from './events'
|
||||
@@ -284,6 +286,7 @@ export interface IBaseWidget<
|
||||
/** Widget type (see {@link TWidgetType}) */
|
||||
type: TType
|
||||
value?: TValue
|
||||
valueRef?: () => Ref<boolean | number | string | object | undefined>
|
||||
|
||||
/**
|
||||
* Whether the widget value should be serialized on node serialization.
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import type {
|
||||
@@ -51,14 +51,16 @@ const nodeData = computed<VueNodeData>(() => {
|
||||
.map(([name, input]) => ({
|
||||
name,
|
||||
type: input.widgetType || input.type,
|
||||
value:
|
||||
input.default !== undefined
|
||||
? input.default
|
||||
: input.type === 'COMBO' &&
|
||||
Array.isArray(input.options) &&
|
||||
input.options.length > 0
|
||||
? input.options[0]
|
||||
: '',
|
||||
value: () =>
|
||||
ref(
|
||||
input.default !== undefined
|
||||
? input.default
|
||||
: input.type === 'COMBO' &&
|
||||
Array.isArray(input.options) &&
|
||||
input.options.length > 0
|
||||
? input.options[0]
|
||||
: ''
|
||||
),
|
||||
options: {
|
||||
hidden: input.hidden,
|
||||
advanced: input.advanced,
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
|
||||
:node-type="nodeType"
|
||||
class="col-span-2"
|
||||
@update:model-value="widget.updateHandler"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -69,7 +68,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipOptions } from 'primevue'
|
||||
import { computed, onErrorCaptured, ref, toValue } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import type { Component, Ref } from 'vue'
|
||||
|
||||
import type {
|
||||
VueNodeData,
|
||||
@@ -136,8 +135,7 @@ interface ProcessedWidget {
|
||||
type: string
|
||||
vueComponent: Component
|
||||
simplified: SimplifiedWidget
|
||||
value: WidgetValue
|
||||
updateHandler: (value: WidgetValue) => void
|
||||
value: () => Ref<WidgetValue>
|
||||
tooltipConfig: TooltipOptions
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
}
|
||||
@@ -170,23 +168,11 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
value: widget.value,
|
||||
label: widget.label,
|
||||
options: widgetOptions,
|
||||
callback: widget.callback,
|
||||
spec: widget.spec,
|
||||
borderStyle: widget.borderStyle,
|
||||
controlWidget: widget.controlWidget
|
||||
}
|
||||
|
||||
function updateHandler(value: WidgetValue) {
|
||||
// Update the widget value directly
|
||||
widget.value = value
|
||||
|
||||
// Skip callback for asset widgets - their callback opens the modal,
|
||||
// but Vue asset mode handles selection through the dropdown
|
||||
if (widget.type !== 'asset') {
|
||||
widget.callback?.(value)
|
||||
}
|
||||
}
|
||||
|
||||
const tooltipText = getWidgetTooltip(widget)
|
||||
const tooltipConfig = createTooltipConfig(tooltipText)
|
||||
|
||||
@@ -196,7 +182,6 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
vueComponent,
|
||||
simplified,
|
||||
value: widget.value,
|
||||
updateHandler,
|
||||
tooltipConfig,
|
||||
slotMetadata
|
||||
})
|
||||
|
||||
@@ -3,15 +3,15 @@ import Button from 'primevue/button'
|
||||
import Popover from 'primevue/popover'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
import { NumberControlMode } from '../composables/useStepperControl'
|
||||
import type { ControlOptions } from '@/types/simplifiedWidget'
|
||||
|
||||
type ControlOption = {
|
||||
description: string
|
||||
mode: NumberControlMode
|
||||
mode: ControlOptions
|
||||
icon?: string
|
||||
text?: string
|
||||
title: string
|
||||
@@ -26,33 +26,21 @@ const toggle = (event: Event) => {
|
||||
}
|
||||
defineExpose({ toggle })
|
||||
|
||||
const ENABLE_LINK_TO_GLOBAL = false
|
||||
|
||||
const controlOptions: ControlOption[] = [
|
||||
...(ENABLE_LINK_TO_GLOBAL
|
||||
? ([
|
||||
{
|
||||
mode: NumberControlMode.LINK_TO_GLOBAL,
|
||||
icon: 'pi pi-link',
|
||||
title: 'linkToGlobal',
|
||||
description: 'linkToGlobalDesc'
|
||||
} satisfies ControlOption
|
||||
] as ControlOption[])
|
||||
: []),
|
||||
{
|
||||
mode: NumberControlMode.RANDOMIZE,
|
||||
mode: 'randomize',
|
||||
icon: 'icon-[lucide--shuffle]',
|
||||
title: 'randomize',
|
||||
description: 'randomizeDesc'
|
||||
},
|
||||
{
|
||||
mode: NumberControlMode.INCREMENT,
|
||||
mode: 'increment',
|
||||
text: '+1',
|
||||
title: 'increment',
|
||||
description: 'incrementDesc'
|
||||
},
|
||||
{
|
||||
mode: NumberControlMode.DECREMENT,
|
||||
mode: 'decrement',
|
||||
text: '-1',
|
||||
title: 'decrement',
|
||||
description: 'decrementDesc'
|
||||
@@ -64,20 +52,16 @@ const widgetControlMode = computed(() =>
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
controlMode: NumberControlMode
|
||||
controlWidget: () => Ref<ControlOptions>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:controlMode': [mode: NumberControlMode]
|
||||
}>()
|
||||
|
||||
const handleToggle = (mode: NumberControlMode) => {
|
||||
if (props.controlMode === mode) return
|
||||
emit('update:controlMode', mode)
|
||||
const handleToggle = (mode: ControlOptions) => {
|
||||
if (props.controlWidget().value === mode) return
|
||||
props.controlWidget().value = mode
|
||||
}
|
||||
|
||||
const isActive = (mode: NumberControlMode) => {
|
||||
return props.controlMode === mode
|
||||
const isActive = (mode: ControlOptions) => {
|
||||
return props.controlWidget().value === mode
|
||||
}
|
||||
|
||||
const handleEditSettings = () => {
|
||||
@@ -131,11 +115,7 @@ const handleEditSettings = () => {
|
||||
<div
|
||||
class="text-sm font-normal text-base-foreground leading-tight"
|
||||
>
|
||||
<span v-if="option.mode === NumberControlMode.LINK_TO_GLOBAL">
|
||||
{{ $t('widgets.numberControl.linkToGlobal') }}
|
||||
<em>{{ $t('widgets.numberControl.linkToGlobalSeed') }}</em>
|
||||
</span>
|
||||
<span v-else>
|
||||
<span>
|
||||
{{ $t(`widgets.numberControl.${option.title}`) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -150,7 +130,9 @@ const handleEditSettings = () => {
|
||||
<ToggleSwitch
|
||||
:model-value="isActive(option.mode)"
|
||||
class="flex-shrink-0"
|
||||
@update:model-value="handleToggle(option.mode)"
|
||||
@update:model-value="
|
||||
(v) => (v ? handleToggle(option.mode) : handleToggle('fixed'))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,11 +31,7 @@ const props = defineProps<{
|
||||
nodeId: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>('modelValue')
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
const modelValue = props.widget.value()
|
||||
|
||||
// Get litegraph node
|
||||
const litegraphNode = computed(() => {
|
||||
@@ -50,7 +46,7 @@ const isOutputNodeRef = computed(() => {
|
||||
return isOutputNode(node)
|
||||
})
|
||||
|
||||
const audioFilePath = computed(() => props.widget.value as string)
|
||||
const audioFilePath = props.widget.value()
|
||||
|
||||
// Computed audio URL from widget value (for input files)
|
||||
const audioUrlFromWidget = computed(() => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import Button from 'primevue/button'
|
||||
import type { ButtonProps } from 'primevue/button'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import WidgetButton from '@/renderer/extensions/vueNodes/widgets/components/WidgetButton.vue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
@@ -12,13 +13,16 @@ describe('WidgetButton Interactions', () => {
|
||||
options: Partial<ButtonProps> = {},
|
||||
callback?: () => void,
|
||||
name: string = 'test_button'
|
||||
): SimplifiedWidget<void> => ({
|
||||
name,
|
||||
type: 'button',
|
||||
value: undefined,
|
||||
options,
|
||||
callback
|
||||
})
|
||||
): SimplifiedWidget<void> => {
|
||||
const valueRef = ref()
|
||||
if (callback) watch(valueRef, callback)
|
||||
return {
|
||||
name,
|
||||
type: 'button',
|
||||
value: () => valueRef,
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
const mountComponent = (widget: SimplifiedWidget<void>, readonly = false) => {
|
||||
return mount(WidgetButton, {
|
||||
@@ -195,11 +199,7 @@ describe('WidgetButton Interactions', () => {
|
||||
const widget = createMockWidget({}, mockCallback)
|
||||
const wrapper = mountComponent(widget)
|
||||
|
||||
// Simulate rapid clicks
|
||||
const clickPromises = Array.from({ length: 16 }, () =>
|
||||
clickButton(wrapper)
|
||||
)
|
||||
await Promise.all(clickPromises)
|
||||
for (let i = 0; i < 16; i++) await clickButton(wrapper)
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledTimes(16)
|
||||
})
|
||||
|
||||
@@ -37,8 +37,8 @@ const filteredProps = computed(() =>
|
||||
)
|
||||
|
||||
const handleClick = () => {
|
||||
if (props.widget.callback) {
|
||||
props.widget.callback()
|
||||
}
|
||||
const ref = props.widget.value()
|
||||
//@ts-expect-error - need to actually assign value, can't use triggerRef :(
|
||||
ref.value = !ref.value
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -21,12 +21,12 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
type ChartWidgetOptions = NonNullable<ChartInputSpec['options']>
|
||||
|
||||
const value = defineModel<ChartData>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<ChartData, ChartWidgetOptions>
|
||||
}>()
|
||||
|
||||
const value = props.widget.value()
|
||||
|
||||
const chartType = computed(() => props.widget.options?.type ?? 'line')
|
||||
|
||||
const chartData = computed(() => value.value || { labels: [], datasets: [] })
|
||||
|
||||
@@ -3,6 +3,7 @@ import ColorPicker from 'primevue/colorpicker'
|
||||
import type { ColorPickerProps } from 'primevue/colorpicker'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -14,13 +15,16 @@ describe('WidgetColorPicker Value Binding', () => {
|
||||
value: string = '#000000',
|
||||
options: Partial<ColorPickerProps> = {},
|
||||
callback?: (value: string) => void
|
||||
): SimplifiedWidget<string> => ({
|
||||
name: 'test_color_picker',
|
||||
type: 'color',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
})
|
||||
): SimplifiedWidget<string> => {
|
||||
const valueRef = ref(value)
|
||||
if (callback) watch(valueRef, (v) => callback(v))
|
||||
return {
|
||||
name: 'test_color_picker',
|
||||
type: 'color',
|
||||
value: () => valueRef,
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string>,
|
||||
@@ -49,80 +53,61 @@ describe('WidgetColorPicker Value Binding', () => {
|
||||
) => {
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
await colorPicker.setValue(value)
|
||||
return wrapper.emitted('update:modelValue')
|
||||
}
|
||||
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when color changes', async () => {
|
||||
const widget = createMockWidget('#ff0000')
|
||||
describe('Value Binding', () => {
|
||||
it('triggers callback when color changes', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('#ff0000', {}, callback)
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '#00ff00')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#00ff00')
|
||||
await setColorPickerValue(wrapper, '#00ff00')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('#00ff00')
|
||||
})
|
||||
|
||||
it('handles different color formats', async () => {
|
||||
const widget = createMockWidget('#ffffff')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('#ffffff', {}, callback)
|
||||
const wrapper = mountComponent(widget, '#ffffff')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '#123abc')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#123abc')
|
||||
await setColorPickerValue(wrapper, '#123abc')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('#123abc')
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createMockWidget('#000000', {}, undefined)
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '#ff00ff')
|
||||
|
||||
// Should still emit Vue event
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#ff00ff')
|
||||
})
|
||||
|
||||
it('normalizes bare hex without # to #hex on emit', async () => {
|
||||
const widget = createMockWidget('ff0000')
|
||||
it('normalizes bare hex without # to #hex', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('ff0000', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'ff0000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '00ff00')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#00ff00')
|
||||
await setColorPickerValue(wrapper, '00ff00')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('#00ff00')
|
||||
})
|
||||
|
||||
it('normalizes rgb() strings to #hex on emit', async (context) => {
|
||||
context.skip('needs diagnosis')
|
||||
const widget = createMockWidget('#000000')
|
||||
it('normalizes rgb() strings to #hex', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('#000000', { format: 'rgb' }, callback)
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, 'rgb(255, 0, 0)')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#ff0000')
|
||||
await setColorPickerValue(wrapper, 'rgb(255, 0, 0)')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('#ff0000')
|
||||
})
|
||||
|
||||
it('normalizes hsb() strings to #hex on emit', async () => {
|
||||
const widget = createMockWidget('#000000', { format: 'hsb' })
|
||||
it('normalizes hsb() strings to #hex', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('#000000', { format: 'hsb' }, callback)
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, 'hsb(120, 100, 100)')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#00ff00')
|
||||
await setColorPickerValue(wrapper, 'hsb(120, 100, 100)')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('#00ff00')
|
||||
})
|
||||
|
||||
it('normalizes HSB object values to #hex on emit', async () => {
|
||||
const widget = createMockWidget('#000000', { format: 'hsb' })
|
||||
it('normalizes HSB object values to #hex', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('#000000', { format: 'hsb' }, callback)
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, {
|
||||
h: 240,
|
||||
s: 100,
|
||||
b: 100
|
||||
})
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#0000ff')
|
||||
await setColorPickerValue(wrapper, { h: 240, s: 100, b: 100 })
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('#0000ff')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -265,15 +250,15 @@ describe('WidgetColorPicker Value Binding', () => {
|
||||
})
|
||||
|
||||
it('handles invalid color formats gracefully', async () => {
|
||||
const widget = createMockWidget('invalid-color')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('invalid-color', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'invalid-color')
|
||||
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect(colorText.text()).toBe('#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, 'invalid-color')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#000000')
|
||||
await setColorPickerValue(wrapper, 'invalid-color')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('#000000')
|
||||
})
|
||||
|
||||
it('handles widget with no options', () => {
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
:pt="{
|
||||
preview: '!w-full !h-full !border-none'
|
||||
}"
|
||||
@update:model-value="onPickerUpdate"
|
||||
/>
|
||||
<span
|
||||
class="text-xs truncate min-w-[4ch]"
|
||||
@@ -27,11 +26,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { isColorFormat, toHexFromFormat } from '@/utils/colorUtil'
|
||||
import type { ColorFormat, HSB } from '@/utils/colorUtil'
|
||||
import type { ColorFormat } from '@/utils/colorUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
@@ -45,39 +44,23 @@ type WidgetOptions = { format?: ColorFormat } & Record<string, unknown>
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string, WidgetOptions>
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
const modelValue = props.widget.value()
|
||||
|
||||
const format = computed<ColorFormat>(() => {
|
||||
const optionFormat = props.widget.options?.format
|
||||
return isColorFormat(optionFormat) ? optionFormat : 'hex'
|
||||
})
|
||||
|
||||
type PickerValue = string | HSB
|
||||
const localValue = ref<PickerValue>(
|
||||
toHexFromFormat(
|
||||
props.modelValue || '#000000',
|
||||
isColorFormat(props.widget.options?.format)
|
||||
? props.widget.options.format
|
||||
: 'hex'
|
||||
)
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
localValue.value = toHexFromFormat(newVal || '#000000', format.value)
|
||||
const localValue = computed({
|
||||
get() {
|
||||
return toHexFromFormat(modelValue.value || '#000000', format.value)
|
||||
},
|
||||
set(v) {
|
||||
modelValue.value = toHexFromFormat(v, format.value)
|
||||
}
|
||||
)
|
||||
|
||||
function onPickerUpdate(val: unknown) {
|
||||
localValue.value = val as PickerValue
|
||||
emit('update:modelValue', toHexFromFormat(val, format.value))
|
||||
}
|
||||
})
|
||||
|
||||
// ColorPicker specific excluded props include panel/overlay classes
|
||||
const COLOR_PICKER_EXCLUDED_PROPS = [...PANEL_EXCLUDED_PROPS] as const
|
||||
|
||||
@@ -4,6 +4,7 @@ import Galleria from 'primevue/galleria'
|
||||
import type { GalleriaProps } from 'primevue/galleria'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -52,7 +53,7 @@ function createMockWidget(
|
||||
return {
|
||||
name: 'test_galleria',
|
||||
type: 'array',
|
||||
value,
|
||||
value: () => ref(value),
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,12 +68,12 @@ export interface GalleryImage {
|
||||
|
||||
export type GalleryValue = string[] | GalleryImage[]
|
||||
|
||||
const value = defineModel<GalleryValue>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<GalleryValue>
|
||||
}>()
|
||||
|
||||
const value = props.widget.value()
|
||||
|
||||
const activeIndex = ref(0)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ImageCompare from 'primevue/imagecompare'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -15,7 +16,7 @@ describe('WidgetImageCompare Display', () => {
|
||||
): SimplifiedWidget<ImageCompareValue | string> => ({
|
||||
name: 'test_imagecompare',
|
||||
type: 'object',
|
||||
value,
|
||||
value: () => ref(value),
|
||||
options
|
||||
})
|
||||
|
||||
|
||||
@@ -44,24 +44,24 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const beforeImage = computed(() => {
|
||||
const value = props.widget.value
|
||||
const value = props.widget.value().value
|
||||
return typeof value === 'string' ? value : value?.before || ''
|
||||
})
|
||||
|
||||
const afterImage = computed(() => {
|
||||
const value = props.widget.value
|
||||
const value = props.widget.value().value
|
||||
return typeof value === 'string' ? '' : value?.after || ''
|
||||
})
|
||||
|
||||
const beforeAlt = computed(() => {
|
||||
const value = props.widget.value
|
||||
const value = props.widget.value().value
|
||||
return typeof value === 'object' && value?.beforeAlt
|
||||
? value.beforeAlt
|
||||
: 'Before image'
|
||||
})
|
||||
|
||||
const afterAlt = computed(() => {
|
||||
const value = props.widget.value
|
||||
const value = props.widget.value().value
|
||||
return typeof value === 'object' && value?.afterAlt
|
||||
? value.afterAlt
|
||||
: 'After image'
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type {
|
||||
SimplifiedControlWidget,
|
||||
SimplifiedWidget
|
||||
} from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
|
||||
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
|
||||
import WidgetInputNumberWithControl from './WidgetInputNumberWithControl.vue'
|
||||
import WidgetWithControl from './WidgetWithControl.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ default: 0 })
|
||||
const modelValue = props.widget.value()
|
||||
|
||||
const hasControlAfterGenerate = computed(() => {
|
||||
return !!props.widget.controlWidget
|
||||
@@ -19,14 +22,22 @@ const hasControlAfterGenerate = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WidgetWithControl
|
||||
v-if="hasControlAfterGenerate"
|
||||
:widget="widget as SimplifiedControlWidget<number>"
|
||||
:comp="
|
||||
widget.type === 'slider'
|
||||
? WidgetInputNumberSlider
|
||||
: WidgetInputNumberInput
|
||||
"
|
||||
/>
|
||||
<component
|
||||
:is="
|
||||
hasControlAfterGenerate
|
||||
? WidgetInputNumberWithControl
|
||||
: widget.type === 'slider'
|
||||
? WidgetInputNumberSlider
|
||||
: WidgetInputNumberInput
|
||||
widget.type === 'slider'
|
||||
? WidgetInputNumberSlider
|
||||
: WidgetInputNumberInput
|
||||
"
|
||||
v-else
|
||||
v-model="modelValue"
|
||||
:widget="widget"
|
||||
v-bind="$attrs"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -13,12 +14,13 @@ function createMockWidget(
|
||||
options: SimplifiedWidget['options'] = {},
|
||||
callback?: (value: number) => void
|
||||
): SimplifiedWidget<number> {
|
||||
const valueRef = ref(value)
|
||||
if (callback) watch(valueRef, (v) => callback(v))
|
||||
return {
|
||||
name: 'test_input_number',
|
||||
type,
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
value: () => valueRef,
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,16 +51,15 @@ describe('WidgetInputNumberInput Value Binding', () => {
|
||||
expect(input.value).toBe('42')
|
||||
})
|
||||
|
||||
it('emits update:modelValue when value changes', async () => {
|
||||
const widget = createMockWidget(10, 'int')
|
||||
it('triggers callback when value changes', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget(10, 'int', {}, callback)
|
||||
const wrapper = mountComponent(widget, 10)
|
||||
|
||||
const inputNumber = wrapper.findComponent(InputNumber)
|
||||
await inputNumber.vm.$emit('update:modelValue', 20)
|
||||
await inputNumber.setValue(20)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain(20)
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(20)
|
||||
})
|
||||
|
||||
it('handles negative values', () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ const props = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ default: 0 })
|
||||
const modelValue = props.widget.value()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
@@ -13,12 +14,13 @@ function createMockWidget(
|
||||
options: SimplifiedWidget['options'] = {},
|
||||
callback?: (value: number) => void
|
||||
): SimplifiedWidget<number> {
|
||||
const valueRef = ref(value)
|
||||
if (callback) watch(valueRef, callback)
|
||||
return {
|
||||
name: 'test_slider',
|
||||
type: 'float',
|
||||
value,
|
||||
options: { min: 0, max: 100, step: 1, precision: 0, ...options },
|
||||
callback
|
||||
value: () => valueRef,
|
||||
options: { min: 0, max: 100, step: 1, precision: 0, ...options }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ default: 0 })
|
||||
const modelValue = widget.value()
|
||||
|
||||
const timesEmptied = ref(0)
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { defineAsyncComponent, ref } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import type { NumberControlMode } from '../composables/useStepperControl'
|
||||
import { useStepperControl } from '../composables/useStepperControl'
|
||||
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
|
||||
|
||||
const NumberControlPopover = defineAsyncComponent(
|
||||
() => import('./NumberControlPopover.vue')
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ default: 0 })
|
||||
const popover = ref()
|
||||
|
||||
const handleControlChange = (newValue: number) => {
|
||||
modelValue.value = newValue
|
||||
}
|
||||
|
||||
const { controlMode, controlButtonIcon } = useStepperControl(
|
||||
modelValue,
|
||||
{
|
||||
...props.widget.options,
|
||||
onChange: handleControlChange
|
||||
},
|
||||
props.widget.controlWidget!.value
|
||||
)
|
||||
|
||||
const setControlMode = (mode: NumberControlMode) => {
|
||||
controlMode.value = mode
|
||||
props.widget.controlWidget!.update(mode)
|
||||
}
|
||||
|
||||
const togglePopover = (event: Event) => {
|
||||
popover.value.toggle(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative grid grid-cols-subgrid">
|
||||
<WidgetInputNumberInput
|
||||
v-model="modelValue"
|
||||
:widget
|
||||
class="grid grid-cols-subgrid col-span-2"
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
size="small"
|
||||
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
|
||||
@click="togglePopover"
|
||||
>
|
||||
<i :class="`${controlButtonIcon} text-blue-100 text-xs`" />
|
||||
</Button>
|
||||
</WidgetInputNumberInput>
|
||||
<NumberControlPopover
|
||||
ref="popover"
|
||||
:control-mode
|
||||
@update:control-mode="setControlMode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import InputText from 'primevue/inputtext'
|
||||
import type { InputTextProps } from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -14,13 +15,16 @@ describe('WidgetInputText Value Binding', () => {
|
||||
value: string = 'default',
|
||||
options: Partial<InputTextProps> = {},
|
||||
callback?: (value: string) => void
|
||||
): SimplifiedWidget<string> => ({
|
||||
name: 'test_input',
|
||||
type: 'string',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
})
|
||||
): SimplifiedWidget<string> => {
|
||||
const valueRef = ref(value)
|
||||
if (callback) watch(valueRef, (v) => callback(v))
|
||||
return {
|
||||
name: 'test_input',
|
||||
type: 'string',
|
||||
value: () => valueRef,
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string>,
|
||||
@@ -54,86 +58,26 @@ describe('WidgetInputText Value Binding', () => {
|
||||
return input
|
||||
}
|
||||
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when input value changes on blur', async () => {
|
||||
const widget = createMockWidget('hello')
|
||||
const wrapper = mountComponent(widget, 'hello')
|
||||
|
||||
await setInputValueAndTrigger(wrapper, 'world', 'blur')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('world')
|
||||
})
|
||||
|
||||
it('emits Vue event when enter key is pressed', async () => {
|
||||
const widget = createMockWidget('initial')
|
||||
const wrapper = mountComponent(widget, 'initial')
|
||||
|
||||
await setInputValueAndTrigger(wrapper, 'new value', 'keydown.enter')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('new value')
|
||||
})
|
||||
|
||||
describe('Widget Value Callbacks', () => {
|
||||
it('handles empty string values', async () => {
|
||||
const widget = createMockWidget('something')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('something', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'something')
|
||||
|
||||
await setInputValueAndTrigger(wrapper, '')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('')
|
||||
})
|
||||
|
||||
it('handles special characters correctly', async () => {
|
||||
const widget = createMockWidget('normal')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('normal', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'normal')
|
||||
|
||||
const specialText = 'special @#$%^&*()[]{}|\\:";\'<>?,./'
|
||||
await setInputValueAndTrigger(wrapper, specialText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain(specialText)
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createMockWidget('test', {}, undefined)
|
||||
const wrapper = mountComponent(widget, 'test')
|
||||
|
||||
await setInputValueAndTrigger(wrapper, 'new value')
|
||||
|
||||
// Should still emit Vue event
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('new value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('emits update:modelValue on blur', async () => {
|
||||
const widget = createMockWidget('original')
|
||||
const wrapper = mountComponent(widget, 'original')
|
||||
|
||||
await setInputValueAndTrigger(wrapper, 'updated')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('updated')
|
||||
})
|
||||
|
||||
it('emits update:modelValue on enter key', async () => {
|
||||
const widget = createMockWidget('start')
|
||||
const wrapper = mountComponent(widget, 'start')
|
||||
|
||||
await setInputValueAndTrigger(wrapper, 'finish', 'keydown.enter')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('finish')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(specialText)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -154,27 +98,25 @@ describe('WidgetInputText Value Binding', () => {
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles very long strings', async () => {
|
||||
const widget = createMockWidget('short')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('short', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'short')
|
||||
|
||||
const longString = 'a'.repeat(10000)
|
||||
await setInputValueAndTrigger(wrapper, longString)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain(longString)
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(longString)
|
||||
})
|
||||
|
||||
it('handles unicode characters', async () => {
|
||||
const widget = createMockWidget('ascii')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('ascii', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'ascii')
|
||||
|
||||
const unicodeText = '🎨 Unicode: αβγ 中文 العربية 🚀'
|
||||
await setInputValueAndTrigger(wrapper, unicodeText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain(unicodeText)
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(unicodeText)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ const props = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '' })
|
||||
const modelValue = props.widget.value()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
@@ -28,13 +28,16 @@ describe('WidgetMarkdown Dual Mode Display', () => {
|
||||
value: string = '# Default Heading\nSome **bold** text.',
|
||||
options: Record<string, unknown> = {},
|
||||
callback?: (value: string) => void
|
||||
): SimplifiedWidget<string> => ({
|
||||
name: 'test_markdown',
|
||||
type: 'string',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
})
|
||||
): SimplifiedWidget<string> => {
|
||||
const valueRef = ref(value)
|
||||
if (callback) watch(valueRef, (v) => callback(v))
|
||||
return {
|
||||
name: 'test_markdown',
|
||||
type: 'string',
|
||||
value: () => valueRef,
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string>,
|
||||
@@ -209,8 +212,9 @@ describe('WidgetMarkdown Dual Mode Display', () => {
|
||||
})
|
||||
|
||||
describe('Value Updates', () => {
|
||||
it('emits update:modelValue when textarea content changes', async () => {
|
||||
const widget = createMockWidget('# Original')
|
||||
it('triggers callback when textarea content changes', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('# Original', {}, callback)
|
||||
const wrapper = mountComponent(widget, '# Original')
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
@@ -219,9 +223,7 @@ describe('WidgetMarkdown Dual Mode Display', () => {
|
||||
await textarea.setValue('# Updated Content')
|
||||
await textarea.trigger('input')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![emitted!.length - 1]).toEqual(['# Updated Content'])
|
||||
expect(callback).toHaveBeenLastCalledWith('# Updated Content')
|
||||
})
|
||||
|
||||
it('renders updated HTML after value change and blur', async () => {
|
||||
@@ -239,38 +241,6 @@ describe('WidgetMarkdown Dual Mode Display', () => {
|
||||
expect(displayDiv.html()).toContain('<h2>New Heading</h2>')
|
||||
expect(displayDiv.html()).toContain('<strong>bold</strong>')
|
||||
})
|
||||
|
||||
it('emits update:modelValue for callback handling at parent level', async () => {
|
||||
const widget = createMockWidget('# Test', {})
|
||||
const wrapper = mountComponent(widget, '# Test')
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('# Changed')
|
||||
await textarea.trigger('input')
|
||||
|
||||
// The widget should emit the change for parent (NodeWidgets) to handle callbacks
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![emitted!.length - 1]).toEqual(['# Changed'])
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createMockWidget('# Test', {}, undefined)
|
||||
const wrapper = mountComponent(widget, '# Test')
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('# Changed')
|
||||
|
||||
// Should not throw error and should still emit Vue event
|
||||
await expect(textarea.trigger('input')).resolves.not.toThrow()
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Complex Markdown Rendering', () => {
|
||||
@@ -337,8 +307,9 @@ Another line with more content.`
|
||||
})
|
||||
|
||||
it('handles unicode characters', async () => {
|
||||
const callback = vi.fn()
|
||||
const unicode = '# Unicode: 🎨 αβγ 中文 العربية 🚀'
|
||||
const widget = createMockWidget(unicode)
|
||||
const widget = createMockWidget(unicode, {}, callback)
|
||||
const wrapper = mountComponent(widget, unicode)
|
||||
|
||||
await clickToEdit(wrapper)
|
||||
@@ -348,9 +319,7 @@ Another line with more content.`
|
||||
await textarea.setValue(unicode + ' more unicode')
|
||||
await textarea.trigger('input')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![emitted!.length - 1]).toEqual([unicode + ' more unicode'])
|
||||
expect(callback).toHaveBeenLastCalledWith(unicode + ' more unicode')
|
||||
})
|
||||
|
||||
it('handles rapid edit mode toggling', async () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '' })
|
||||
const modelValue = widget.value()
|
||||
|
||||
// State
|
||||
const isEditing = ref(false)
|
||||
|
||||
@@ -92,6 +92,7 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAudioService } from '@/services/audioService'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import { useAudioPlayback } from '../composables/audio/useAudioPlayback'
|
||||
import { useAudioRecorder } from '../composables/audio/useAudioRecorder'
|
||||
@@ -99,8 +100,9 @@ import { useAudioWaveform } from '../composables/audio/useAudioWaveform'
|
||||
import { formatTime } from '../utils/audioUtils'
|
||||
|
||||
const props = defineProps<{
|
||||
readonly?: boolean
|
||||
nodeId: string
|
||||
widget: SimplifiedWidget<string>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
// Audio element ref
|
||||
@@ -152,7 +154,7 @@ const { isPlaying, audioElementKey } = playback
|
||||
// Computed for waveform animation
|
||||
const isWaveformActive = computed(() => isRecording.value || isPlaying.value)
|
||||
|
||||
const modelValue = defineModel<string>({ default: '' })
|
||||
const modelValue = props.widget.value()
|
||||
|
||||
const litegraphNode = computed(() => {
|
||||
if (!props.nodeId || !app.canvas.graph) return null
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
:is-asset-mode="isAssetMode"
|
||||
:default-layout-mode="defaultLayoutMode"
|
||||
/>
|
||||
<WidgetWithControl
|
||||
v-else-if="widget.controlWidget"
|
||||
:comp="WidgetSelectDefault"
|
||||
:widget="widget as SimplifiedControlWidget<string>"
|
||||
/>
|
||||
<WidgetSelectDefault v-else v-model="modelValue" :widget />
|
||||
</template>
|
||||
|
||||
@@ -20,11 +25,15 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
|
||||
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
||||
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
|
||||
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type {
|
||||
SimplifiedControlWidget,
|
||||
SimplifiedWidget
|
||||
} from '@/types/simplifiedWidget'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -32,7 +41,7 @@ const props = defineProps<{
|
||||
nodeType?: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string | undefined>()
|
||||
const modelValue = props.widget.value()
|
||||
|
||||
const comboSpec = computed<ComboInputSpec | undefined>(() => {
|
||||
if (props.widget.spec && isComboInputSpec(props.widget.spec)) {
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget>
|
||||
<Select
|
||||
v-model="modelValue"
|
||||
:invalid
|
||||
:filter="selectOptions.length > 4"
|
||||
:auto-filter-focus="selectOptions.length > 4"
|
||||
:options="selectOptions"
|
||||
v-bind="combinedProps"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
|
||||
:aria-label="widget.name"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs',
|
||||
dropdown: 'w-8',
|
||||
label: 'truncate min-w-[4ch]',
|
||||
overlay: 'w-fit min-w-full'
|
||||
}"
|
||||
data-capture-wheel="true"
|
||||
/>
|
||||
<div class="relative">
|
||||
<Select
|
||||
v-model="modelValue"
|
||||
:invalid
|
||||
:filter="selectOptions.length > 4"
|
||||
:auto-filter-focus="selectOptions.length > 4"
|
||||
:options="selectOptions"
|
||||
v-bind="combinedProps"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
|
||||
:aria-label="widget.name"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs',
|
||||
dropdown: 'w-8',
|
||||
label: cn('truncate min-w-[4ch]', slots.default && 'mr-5'),
|
||||
overlay: 'w-fit min-w-full'
|
||||
}"
|
||||
data-capture-wheel="true"
|
||||
/>
|
||||
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
@@ -36,17 +41,15 @@ import {
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
interface Props {
|
||||
widget: SimplifiedWidget<string | undefined>
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const modelValue = defineModel<string | undefined>({
|
||||
default(props: Props) {
|
||||
return props.widget.options?.values?.[0] || ''
|
||||
}
|
||||
})
|
||||
const modelValue = props.widget.value()
|
||||
|
||||
// Transform compatibility props for overlay positioning
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
@@ -43,11 +43,7 @@ provide(
|
||||
computed(() => props.assetKind)
|
||||
)
|
||||
|
||||
const modelValue = defineModel<string | undefined>({
|
||||
default(props: Props) {
|
||||
return props.widget.options?.values?.[0] || ''
|
||||
}
|
||||
})
|
||||
const modelValue = props.widget.value()
|
||||
|
||||
const toastStore = useToastStore()
|
||||
const queueStore = useQueueStore()
|
||||
@@ -146,9 +142,11 @@ const outputItems = computed<DropdownItem[]>(() => {
|
||||
})
|
||||
|
||||
const allItems = computed<DropdownItem[]>(() => {
|
||||
if (props.isAssetMode && assetData) {
|
||||
return assetData.dropdownItems.value
|
||||
}
|
||||
return [...inputItems.value, ...outputItems.value]
|
||||
})
|
||||
|
||||
const dropdownItems = computed<DropdownItem[]>(() => {
|
||||
if (props.isAssetMode) {
|
||||
return allItems.value
|
||||
@@ -161,7 +159,7 @@ const dropdownItems = computed<DropdownItem[]>(() => {
|
||||
return outputItems.value
|
||||
case 'all':
|
||||
default:
|
||||
return [...inputItems.value, ...outputItems.value]
|
||||
return allItems.value
|
||||
}
|
||||
})
|
||||
|
||||
@@ -311,11 +309,6 @@ async function handleFilesUpdate(files: File[]) {
|
||||
|
||||
// 3. Update widget value to the first uploaded file
|
||||
modelValue.value = uploadedPaths[0]
|
||||
|
||||
// 4. Trigger callback to notify underlying LiteGraph widget
|
||||
if (props.widget.callback) {
|
||||
props.widget.callback(uploadedPaths[0])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
toastStore.addAlert(`Upload failed: ${error}`)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -12,12 +13,13 @@ function createMockWidget(
|
||||
options: SimplifiedWidget['options'] = {},
|
||||
callback?: (value: string) => void
|
||||
): SimplifiedWidget<string> {
|
||||
const valueRef = ref(value)
|
||||
if (callback) watch(valueRef, (v) => callback(v))
|
||||
return {
|
||||
name: 'test_textarea',
|
||||
type: 'string',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
value: () => valueRef,
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,98 +60,47 @@ async function setTextareaValueAndTrigger(
|
||||
}
|
||||
|
||||
describe('WidgetTextarea Value Binding', () => {
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when textarea value changes on blur', async () => {
|
||||
const widget = createMockWidget('hello')
|
||||
const wrapper = mountComponent(widget, 'hello')
|
||||
|
||||
await setTextareaValueAndTrigger(wrapper, 'world', 'blur')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain('world')
|
||||
})
|
||||
|
||||
it('emits Vue event when textarea value changes on input', async () => {
|
||||
const widget = createMockWidget('initial')
|
||||
describe('Widget Value Callbacks', () => {
|
||||
it('triggers callback when textarea value changes on input', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('initial', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'initial')
|
||||
|
||||
await setTextareaValueAndTrigger(wrapper, 'new content', 'input')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain('new content')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('new content')
|
||||
})
|
||||
|
||||
it('handles empty string values', async () => {
|
||||
const widget = createMockWidget('something')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('something', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'something')
|
||||
|
||||
await setTextareaValueAndTrigger(wrapper, '')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain('')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('')
|
||||
})
|
||||
|
||||
it('handles multiline text correctly', async () => {
|
||||
const widget = createMockWidget('single line')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('single line', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'single line')
|
||||
|
||||
const multilineText = 'Line 1\nLine 2\nLine 3'
|
||||
await setTextareaValueAndTrigger(wrapper, multilineText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain(multilineText)
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(multilineText)
|
||||
})
|
||||
|
||||
it('handles special characters correctly', async () => {
|
||||
const widget = createMockWidget('normal')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('normal', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'normal')
|
||||
|
||||
const specialText = 'special @#$%^&*()[]{}|\\:";\'<>?,./'
|
||||
await setTextareaValueAndTrigger(wrapper, specialText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain(specialText)
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createMockWidget('test', {}, undefined)
|
||||
const wrapper = mountComponent(widget, 'test')
|
||||
|
||||
await setTextareaValueAndTrigger(wrapper, 'new value')
|
||||
|
||||
// Should still emit Vue event
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain('new value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('emits update:modelValue on blur', async () => {
|
||||
const widget = createMockWidget('original')
|
||||
const wrapper = mountComponent(widget, 'original')
|
||||
|
||||
await setTextareaValueAndTrigger(wrapper, 'updated')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain('updated')
|
||||
})
|
||||
|
||||
it('emits update:modelValue on input', async () => {
|
||||
const widget = createMockWidget('start')
|
||||
const wrapper = mountComponent(widget, 'start')
|
||||
|
||||
await setTextareaValueAndTrigger(wrapper, 'finish', 'input')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain('finish')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(specialText)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -199,39 +150,36 @@ describe('WidgetTextarea Value Binding', () => {
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles very long text', async () => {
|
||||
const widget = createMockWidget('short')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('short', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'short')
|
||||
|
||||
const longText = 'a'.repeat(10000)
|
||||
await setTextareaValueAndTrigger(wrapper, longText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain(longText)
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(longText)
|
||||
})
|
||||
|
||||
it('handles unicode characters', async () => {
|
||||
const widget = createMockWidget('ascii')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('ascii', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'ascii')
|
||||
|
||||
const unicodeText = '🎨 Unicode: αβγ 中文 العربية 🚀'
|
||||
await setTextareaValueAndTrigger(wrapper, unicodeText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain(unicodeText)
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(unicodeText)
|
||||
})
|
||||
|
||||
it('handles text with tabs and spaces', async () => {
|
||||
const widget = createMockWidget('normal')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('normal', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'normal')
|
||||
|
||||
const formattedText = '\tIndented line\n Spaced line\n\t\tDouble indent'
|
||||
await setTextareaValueAndTrigger(wrapper, formattedText)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toContain(formattedText)
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(formattedText)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@ const { widget, placeholder = '' } = defineProps<{
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '' })
|
||||
const modelValue = widget.value()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(widget.options, INPUT_EXCLUDED_PROPS)
|
||||
|
||||
@@ -3,6 +3,7 @@ import PrimeVue from 'primevue/config'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import type { ToggleSwitchProps } from 'primevue/toggleswitch'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -13,13 +14,16 @@ describe('WidgetToggleSwitch Value Binding', () => {
|
||||
value: boolean = false,
|
||||
options: Partial<ToggleSwitchProps> = {},
|
||||
callback?: (value: boolean) => void
|
||||
): SimplifiedWidget<boolean> => ({
|
||||
name: 'test_toggle',
|
||||
type: 'boolean',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
})
|
||||
): SimplifiedWidget<boolean> => {
|
||||
const valueRef = ref(value)
|
||||
if (callback) watch(valueRef, (v) => callback(v))
|
||||
return {
|
||||
name: 'test_toggle',
|
||||
type: 'boolean',
|
||||
value: () => valueRef,
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<boolean>,
|
||||
@@ -39,48 +43,6 @@ describe('WidgetToggleSwitch Value Binding', () => {
|
||||
})
|
||||
}
|
||||
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when toggled from false to true', async () => {
|
||||
const widget = createMockWidget(false)
|
||||
const wrapper = mountComponent(widget, false)
|
||||
|
||||
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
|
||||
await toggle.setValue(true)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain(true)
|
||||
})
|
||||
|
||||
it('emits Vue event when toggled from true to false', async () => {
|
||||
const widget = createMockWidget(true)
|
||||
const wrapper = mountComponent(widget, true)
|
||||
|
||||
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
|
||||
await toggle.setValue(false)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain(false)
|
||||
})
|
||||
|
||||
it('handles value changes gracefully', async () => {
|
||||
const widget = createMockWidget(false)
|
||||
const wrapper = mountComponent(widget, false)
|
||||
|
||||
// Should not throw when changing values
|
||||
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
|
||||
await toggle.setValue(true)
|
||||
await toggle.setValue(false)
|
||||
|
||||
// Should emit events for all changes
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toHaveLength(2)
|
||||
expect(emitted![0]).toContain(true)
|
||||
expect(emitted![1]).toContain(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('renders toggle switch component', () => {
|
||||
const widget = createMockWidget(false)
|
||||
@@ -109,27 +71,9 @@ describe('WidgetToggleSwitch Value Binding', () => {
|
||||
})
|
||||
|
||||
describe('Multiple Value Changes', () => {
|
||||
it('handles rapid toggling correctly', async () => {
|
||||
const widget = createMockWidget(false)
|
||||
const wrapper = mountComponent(widget, false)
|
||||
|
||||
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
|
||||
|
||||
// Rapid toggle sequence
|
||||
await toggle.setValue(true)
|
||||
await toggle.setValue(false)
|
||||
await toggle.setValue(true)
|
||||
|
||||
// Should have emitted 3 Vue events with correct values
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toHaveLength(3)
|
||||
expect(emitted![0]).toContain(true)
|
||||
expect(emitted![1]).toContain(false)
|
||||
expect(emitted![2]).toContain(true)
|
||||
})
|
||||
|
||||
it('maintains state consistency during multiple changes', async () => {
|
||||
const widget = createMockWidget(false)
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget(false, {}, callback)
|
||||
const wrapper = mountComponent(widget, false)
|
||||
|
||||
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
|
||||
@@ -140,13 +84,12 @@ describe('WidgetToggleSwitch Value Binding', () => {
|
||||
await toggle.setValue(true)
|
||||
await toggle.setValue(false)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toHaveLength(4)
|
||||
expect(callback).toHaveBeenCalledTimes(4)
|
||||
// Verify alternating pattern
|
||||
expect(emitted![0]).toContain(true)
|
||||
expect(emitted![1]).toContain(false)
|
||||
expect(emitted![2]).toContain(true)
|
||||
expect(emitted![3]).toContain(false)
|
||||
expect(callback).toHaveBeenNthCalledWith(1, true)
|
||||
expect(callback).toHaveBeenNthCalledWith(2, false)
|
||||
expect(callback).toHaveBeenNthCalledWith(3, true)
|
||||
expect(callback).toHaveBeenNthCalledWith(4, false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<boolean>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<boolean>()
|
||||
const modelValue = widget.value()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts" generic="T extends WidgetValue">
|
||||
import Button from 'primevue/button'
|
||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type {
|
||||
ControlOptions,
|
||||
SimplifiedControlWidget,
|
||||
WidgetValue
|
||||
} from '@/types/simplifiedWidget'
|
||||
|
||||
const NumberControlPopover = defineAsyncComponent(
|
||||
() => import('./NumberControlPopover.vue')
|
||||
)
|
||||
|
||||
function useControlButtonIcon(controlMode: Ref<ControlOptions>) {
|
||||
return computed(() => {
|
||||
switch (controlMode.value) {
|
||||
case 'increment':
|
||||
return 'pi pi-plus'
|
||||
case 'decrement':
|
||||
return 'pi pi-minus'
|
||||
case 'fixed':
|
||||
return 'icon-[lucide--pencil-off]'
|
||||
default:
|
||||
return 'icon-[lucide--shuffle]'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedControlWidget<T>
|
||||
comp: unknown
|
||||
}>()
|
||||
|
||||
const popover = ref()
|
||||
|
||||
const controlButtonIcon = useControlButtonIcon(props.widget.controlWidget())
|
||||
|
||||
const togglePopover = (event: Event) => {
|
||||
popover.value.toggle(event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="comp" v-bind="$attrs" :widget>
|
||||
<Button
|
||||
variant="link"
|
||||
size="small"
|
||||
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
|
||||
@pointerdown.stop.prevent="togglePopover"
|
||||
>
|
||||
<i :class="`${controlButtonIcon} text-blue-100 text-xs size-3.5`" />
|
||||
</Button>
|
||||
</component>
|
||||
<NumberControlPopover ref="popover" :control-widget="widget.controlWidget" />
|
||||
</template>
|
||||
@@ -1,111 +0,0 @@
|
||||
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<NumberControlMode>) {
|
||||
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<number>,
|
||||
options: StepperControlOptions,
|
||||
defaultValue?: ControlOptions
|
||||
) {
|
||||
const controlMode = ref<NumberControlMode>(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
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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<symbol, () => 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)
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
type NodeId,
|
||||
isSubgraphDefinition
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { executeNumberControls } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry'
|
||||
import type {
|
||||
ExecutionErrorWsMessage,
|
||||
NodeError,
|
||||
@@ -1359,7 +1358,6 @@ 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)
|
||||
@@ -1404,7 +1402,6 @@ 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()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Simplified widget interface for Vue-based node rendering
|
||||
* Removes all DOM manipulation and positioning concerns
|
||||
*/
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
/** Valid types for widget values */
|
||||
@@ -32,11 +34,6 @@ export function normalizeControlOption(val: WidgetValue): ControlOptions {
|
||||
return 'randomize'
|
||||
}
|
||||
|
||||
export type SafeControlWidget = {
|
||||
value: ControlOptions
|
||||
update: (value: WidgetValue) => void
|
||||
}
|
||||
|
||||
export interface SimplifiedWidget<
|
||||
T extends WidgetValue = WidgetValue,
|
||||
O = Record<string, any>
|
||||
@@ -48,7 +45,7 @@ export interface SimplifiedWidget<
|
||||
type: string
|
||||
|
||||
/** Current value of the widget */
|
||||
value: T
|
||||
value: () => Ref<T>
|
||||
|
||||
borderStyle?: string
|
||||
|
||||
@@ -70,5 +67,7 @@ export interface SimplifiedWidget<
|
||||
/** Optional input specification backing this widget */
|
||||
spec?: InputSpecV2
|
||||
|
||||
controlWidget?: SafeControlWidget
|
||||
controlWidget?: () => Ref<ControlOptions>
|
||||
}
|
||||
export type SimplifiedControlWidget<T extends WidgetValue = WidgetValue> =
|
||||
SimplifiedWidget<T> & Required<Pick<SimplifiedWidget<T>, 'controlWidget'>>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memoize } from 'es-toolkit/compat'
|
||||
|
||||
type RGB = { r: number; g: number; b: number }
|
||||
export interface HSB {
|
||||
interface HSB {
|
||||
h: number
|
||||
s: number
|
||||
b: number
|
||||
|
||||
@@ -3,7 +3,7 @@ import { storeToRefs } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import Splitter from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
@@ -64,7 +64,7 @@ const isDesktop = isElectron()
|
||||
|
||||
const batchCountWidget = {
|
||||
options: { step2: 1, precision: 1, min: 1, max: 100 },
|
||||
value: 1,
|
||||
value: () => ref(1),
|
||||
name: t('Number of generations'),
|
||||
type: 'number'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type {
|
||||
SafeWidgetData,
|
||||
@@ -15,11 +16,10 @@ describe('NodeWidgets', () => {
|
||||
): SafeWidgetData => ({
|
||||
name: 'test_widget',
|
||||
type: 'combo',
|
||||
value: 'test_value',
|
||||
value: () => ref('test_value'),
|
||||
options: {
|
||||
values: ['option1', 'option2']
|
||||
},
|
||||
callback: undefined,
|
||||
spec: undefined,
|
||||
label: undefined,
|
||||
isDOMWidget: false,
|
||||
|
||||
@@ -4,6 +4,7 @@ import PrimeVue from 'primevue/config'
|
||||
import Select from 'primevue/select'
|
||||
import type { SelectProps } from 'primevue/select'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
@@ -51,17 +52,20 @@ describe('WidgetSelect Value Binding', () => {
|
||||
> = {},
|
||||
callback?: (value: string | undefined) => void,
|
||||
spec?: ComboInputSpec
|
||||
): SimplifiedWidget<string | undefined> => ({
|
||||
name: 'test_select',
|
||||
type: 'combo',
|
||||
value,
|
||||
options: {
|
||||
values: ['option1', 'option2', 'option3'],
|
||||
...options
|
||||
},
|
||||
callback,
|
||||
spec
|
||||
})
|
||||
): SimplifiedWidget<string | undefined> => {
|
||||
const valueRef = ref(value)
|
||||
if (callback) watch(valueRef, (v) => callback(v))
|
||||
return {
|
||||
name: 'test_select',
|
||||
type: 'combo',
|
||||
value: () => valueRef,
|
||||
options: {
|
||||
values: ['option1', 'option2', 'option3'],
|
||||
...options
|
||||
},
|
||||
spec
|
||||
}
|
||||
}
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
@@ -81,67 +85,57 @@ describe('WidgetSelect Value Binding', () => {
|
||||
})
|
||||
}
|
||||
|
||||
const setSelectValueAndEmit = async (
|
||||
const setSelectValue = async (
|
||||
wrapper: ReturnType<typeof mount>,
|
||||
value: string
|
||||
) => {
|
||||
const select = wrapper.findComponent({ name: 'Select' })
|
||||
await select.setValue(value)
|
||||
return wrapper.emitted('update:modelValue')
|
||||
}
|
||||
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when selection changes', async () => {
|
||||
const widget = createMockWidget('option1')
|
||||
describe('Widget Value Callbacks', () => {
|
||||
it('triggers callback when selection changes', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('option1', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, 'option2')
|
||||
await setSelectValue(wrapper, 'option2')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('option2')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('option2')
|
||||
})
|
||||
|
||||
it('emits string value for different options', async () => {
|
||||
const widget = createMockWidget('option1')
|
||||
it('handles string value for different options', async () => {
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('option1', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, 'option3')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
// Should emit the string value
|
||||
expect(emitted![0]).toContain('option3')
|
||||
await setSelectValue(wrapper, 'option3')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('option3')
|
||||
})
|
||||
|
||||
it('handles custom option values', async () => {
|
||||
const customOptions = ['custom_a', 'custom_b', 'custom_c']
|
||||
const widget = createMockWidget('custom_a', { values: customOptions })
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget(
|
||||
'custom_a',
|
||||
{ values: customOptions },
|
||||
callback
|
||||
)
|
||||
const wrapper = mountComponent(widget, 'custom_a')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, 'custom_b')
|
||||
await setSelectValue(wrapper, 'custom_b')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('custom_b')
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createMockWidget('option1', {}, undefined)
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, 'option2')
|
||||
|
||||
// Should emit Vue event
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('option2')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('custom_b')
|
||||
})
|
||||
|
||||
it('handles value changes gracefully', async () => {
|
||||
const widget = createMockWidget('option1')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('option1', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, 'option2')
|
||||
await setSelectValue(wrapper, 'option2')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('option2')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('option2')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -172,43 +166,43 @@ describe('WidgetSelect Value Binding', () => {
|
||||
'option@#$%',
|
||||
'option/with\\slashes'
|
||||
]
|
||||
const widget = createMockWidget(specialOptions[0], {
|
||||
values: specialOptions
|
||||
})
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget(
|
||||
specialOptions[0],
|
||||
{
|
||||
values: specialOptions
|
||||
},
|
||||
callback
|
||||
)
|
||||
const wrapper = mountComponent(widget, specialOptions[0])
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, specialOptions[1])
|
||||
await setSelectValue(wrapper, specialOptions[1])
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain(specialOptions[1])
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(specialOptions[1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles selection of non-existent option gracefully', async () => {
|
||||
const widget = createMockWidget('option1')
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget('option1', {}, callback)
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(
|
||||
wrapper,
|
||||
'non_existent_option'
|
||||
)
|
||||
await setSelectValue(wrapper, 'non_existent_option')
|
||||
|
||||
// Should still emit Vue event with the value
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('non_existent_option')
|
||||
// Should still trigger callback with the value
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('non_existent_option')
|
||||
})
|
||||
|
||||
it('handles numeric string options correctly', async () => {
|
||||
const callback = vi.fn()
|
||||
const numericOptions = ['1', '2', '10', '100']
|
||||
const widget = createMockWidget('1', { values: numericOptions })
|
||||
const widget = createMockWidget('1', { values: numericOptions }, callback)
|
||||
const wrapper = mountComponent(widget, '1')
|
||||
|
||||
const emitted = await setSelectValueAndEmit(wrapper, '100')
|
||||
await setSelectValue(wrapper, '100')
|
||||
|
||||
// Should maintain string type in emitted event
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('100')
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('100')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
@@ -34,7 +35,7 @@ describe('WidgetSelect asset mode', () => {
|
||||
const createWidget = (): SimplifiedWidget<string | undefined> => ({
|
||||
name: 'ckpt_name',
|
||||
type: 'combo',
|
||||
value: undefined,
|
||||
value: () => ref(),
|
||||
options: {
|
||||
values: []
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
@@ -24,17 +25,22 @@ describe('WidgetSelectDropdown custom label mapping', () => {
|
||||
values?: string[]
|
||||
getOptionLabel?: (value: string | null) => string
|
||||
} = {},
|
||||
spec?: ComboInputSpec
|
||||
): SimplifiedWidget<string | undefined> => ({
|
||||
name: 'test_image_select',
|
||||
type: 'combo',
|
||||
value,
|
||||
options: {
|
||||
values: ['img_001.png', 'photo_abc.jpg', 'hash789.png'],
|
||||
...options
|
||||
},
|
||||
spec
|
||||
})
|
||||
spec?: ComboInputSpec,
|
||||
callback?: (value: string | undefined) => void
|
||||
): SimplifiedWidget<string | undefined> => {
|
||||
const valueRef = ref(value)
|
||||
if (callback) watch(valueRef, (v) => callback(v))
|
||||
return {
|
||||
name: 'test_image_select',
|
||||
type: 'combo',
|
||||
value: () => valueRef,
|
||||
options: {
|
||||
values: ['img_001.png', 'photo_abc.jpg', 'hash789.png'],
|
||||
...options
|
||||
},
|
||||
spec
|
||||
}
|
||||
}
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
@@ -102,26 +108,29 @@ describe('WidgetSelectDropdown custom label mapping', () => {
|
||||
expect(getOptionLabel).toHaveBeenCalledWith('hash789.png')
|
||||
})
|
||||
|
||||
it('emits original values when items with custom labels are selected', async () => {
|
||||
it('triggers callback with original values when items with custom labels are selected', async () => {
|
||||
const getOptionLabel = vi.fn((value: string | null) => {
|
||||
if (!value) return 'No file'
|
||||
return `Custom: ${value}`
|
||||
})
|
||||
|
||||
const widget = createMockWidget('img_001.png', {
|
||||
getOptionLabel
|
||||
})
|
||||
const callback = vi.fn()
|
||||
const widget = createMockWidget(
|
||||
'img_001.png',
|
||||
{
|
||||
getOptionLabel
|
||||
},
|
||||
undefined,
|
||||
callback
|
||||
)
|
||||
const wrapper = mountComponent(widget, 'img_001.png')
|
||||
|
||||
// Simulate selecting an item
|
||||
const selectedSet = new Set(['input-1']) // index 1 = photo_abc.jpg
|
||||
wrapper.vm.updateSelectedItems(selectedSet)
|
||||
|
||||
// Should emit the original value, not the custom label
|
||||
expect(wrapper.emitted('update:modelValue')).toBeDefined()
|
||||
expect(wrapper.emitted('update:modelValue')![0]).toEqual([
|
||||
'photo_abc.jpg'
|
||||
])
|
||||
await nextTick()
|
||||
expect(callback).toHaveBeenCalledWith('photo_abc.jpg')
|
||||
})
|
||||
|
||||
it('falls back to original value when label mapping fails', () => {
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,163 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user