mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-02 21:28:08 +00:00
Compare commits
1 Commits
codex/cove
...
codex/cove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38c6fd9b88 |
180
src/platform/settings/components/ColorPaletteMessage.test.ts
Normal file
180
src/platform/settings/components/ColorPaletteMessage.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import ColorPaletteMessage from './ColorPaletteMessage.vue'
|
||||
|
||||
import type * as Pinia from 'pinia'
|
||||
|
||||
const mockSettingStore = vi.hoisted(() => ({
|
||||
set: vi.fn()
|
||||
}))
|
||||
|
||||
const mockColorPaletteService = vi.hoisted(() => ({
|
||||
exportColorPalette: vi.fn(),
|
||||
importColorPalette: vi.fn(),
|
||||
deleteCustomColorPalette: vi.fn()
|
||||
}))
|
||||
|
||||
const mockColorPaletteState = vi.hoisted(() => ({
|
||||
refs: null as null | {
|
||||
palettes: {
|
||||
value: Array<{ id: string; name: string }>
|
||||
}
|
||||
activePaletteId: {
|
||||
value: string
|
||||
}
|
||||
},
|
||||
customPaletteIds: new Set<string>()
|
||||
}))
|
||||
|
||||
vi.mock('pinia', async (importOriginal: () => Promise<typeof Pinia>) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
storeToRefs: (store: object) => store
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/services/colorPaletteService', () => ({
|
||||
useColorPaletteService: () => mockColorPaletteService
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
|
||||
const palettes = ref([
|
||||
{ id: 'builtin', name: 'Builtin' },
|
||||
{ id: 'custom', name: 'Custom' }
|
||||
])
|
||||
const activePaletteId = ref('builtin')
|
||||
|
||||
mockColorPaletteState.refs = {
|
||||
palettes,
|
||||
activePaletteId
|
||||
}
|
||||
|
||||
return {
|
||||
useColorPaletteStore: () => ({
|
||||
palettes,
|
||||
activePaletteId,
|
||||
isCustomPalette: (paletteId: string) =>
|
||||
mockColorPaletteState.customPaletteIds.has(paletteId)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: {
|
||||
props: ['title', 'disabled'],
|
||||
emits: ['click'],
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
:title="title"
|
||||
:disabled="disabled"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/message', () => ({
|
||||
default: {
|
||||
template: '<section><slot /></section>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
default: {
|
||||
props: ['modelValue', 'options'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
data-testid="palette-select"
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="option in options" :key="option.id" :value="option.id">
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
function renderMessage() {
|
||||
return render(ColorPaletteMessage, {
|
||||
global: {
|
||||
config: {
|
||||
globalProperties: fromAny({
|
||||
$t: (key: string) => key
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ColorPaletteMessage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSettingStore.set.mockResolvedValue(undefined)
|
||||
mockColorPaletteService.importColorPalette.mockResolvedValue(null)
|
||||
mockColorPaletteState.customPaletteIds = new Set(['custom'])
|
||||
if (mockColorPaletteState.refs) {
|
||||
mockColorPaletteState.refs.activePaletteId.value = 'builtin'
|
||||
mockColorPaletteState.refs.palettes.value = [
|
||||
{ id: 'builtin', name: 'Builtin' },
|
||||
{ id: 'custom', name: 'Custom' }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
it('exports and deletes the active custom palette', async () => {
|
||||
renderMessage()
|
||||
|
||||
await userEvent.click(screen.getByTitle('g.export'))
|
||||
expect(mockColorPaletteService.exportColorPalette).toHaveBeenCalledWith(
|
||||
'builtin'
|
||||
)
|
||||
expect(screen.getByTitle('g.delete')).toBeDisabled()
|
||||
|
||||
await fireEvent.update(screen.getByTestId('palette-select'), 'custom')
|
||||
await userEvent.click(screen.getByTitle('g.delete'))
|
||||
|
||||
expect(
|
||||
mockColorPaletteService.deleteCustomColorPalette
|
||||
).toHaveBeenCalledWith('custom')
|
||||
})
|
||||
|
||||
it('persists imported palettes only when import returns a palette', async () => {
|
||||
renderMessage()
|
||||
|
||||
await userEvent.click(screen.getByTitle('g.import'))
|
||||
expect(mockSettingStore.set).not.toHaveBeenCalled()
|
||||
|
||||
mockColorPaletteService.importColorPalette.mockResolvedValue({
|
||||
id: 'imported',
|
||||
name: 'Imported'
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTitle('g.import'))
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.ColorPalette',
|
||||
'imported'
|
||||
)
|
||||
})
|
||||
})
|
||||
312
src/platform/settings/components/ExtensionPanel.test.ts
Normal file
312
src/platform/settings/components/ExtensionPanel.test.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import ExtensionPanel from './ExtensionPanel.vue'
|
||||
|
||||
interface MockExtension {
|
||||
name: string
|
||||
}
|
||||
|
||||
const mockSettingStore = vi.hoisted(() => ({
|
||||
set: vi.fn()
|
||||
}))
|
||||
|
||||
const mockExtensionState = vi.hoisted(() => ({
|
||||
store: {
|
||||
extensions: [
|
||||
{ name: 'core.color' },
|
||||
{ name: 'custom.pack' },
|
||||
{ name: 'readonly.pack' }
|
||||
] as MockExtension[],
|
||||
inactiveDisabledExtensionNames: ['inactive.pack'],
|
||||
hasThirdPartyExtensions: true,
|
||||
enabled: new Set(['core.color', 'custom.pack', 'readonly.pack']),
|
||||
core: new Set(['core.color']),
|
||||
readOnly: new Set(['readonly.pack']),
|
||||
isExtensionEnabled(name: string) {
|
||||
return this.enabled.has(name)
|
||||
},
|
||||
isCoreExtension(name: string) {
|
||||
return this.core.has(name)
|
||||
},
|
||||
isExtensionReadOnly(name: string) {
|
||||
return this.readOnly.has(name)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, string>) =>
|
||||
params ? `${key}:${params.subject}` : key
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@primevue/core/api', () => ({
|
||||
FilterMatchMode: {
|
||||
CONTAINS: 'contains'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/extensionStore', () => ({
|
||||
useExtensionStore: () => mockExtensionState.store
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
|
||||
default: {
|
||||
props: ['modelValue', 'placeholder'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
data-testid="extension-search"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: {
|
||||
props: ['disabled'],
|
||||
emits: ['click'],
|
||||
template: `
|
||||
<button type="button" :disabled="disabled" @click="$emit('click', $event)">
|
||||
<slot />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/message', () => ({
|
||||
default: {
|
||||
template: '<section data-testid="extension-message"><slot /></section>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/selectbutton', () => ({
|
||||
default: {
|
||||
props: ['modelValue', 'options'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<div data-testid="extension-filter">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
@click="$emit('update:modelValue', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/datatable', () => ({
|
||||
default: {
|
||||
props: ['value', 'selection'],
|
||||
emits: ['update:selection'],
|
||||
template: `
|
||||
<section data-testid="extension-table">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="select-visible"
|
||||
@click="$emit('update:selection', value)"
|
||||
>
|
||||
select
|
||||
</button>
|
||||
<div v-for="ext in value" :key="ext.name" data-testid="extension-row">
|
||||
{{ ext.name }}
|
||||
</div>
|
||||
<slot />
|
||||
</section>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/column', () => ({
|
||||
default: {
|
||||
template: '<div><slot name="header" /><slot /></div>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/contextmenu', () => ({
|
||||
default: {
|
||||
props: ['model'],
|
||||
methods: {
|
||||
show: vi.fn()
|
||||
},
|
||||
template: `
|
||||
<div data-testid="extension-menu">
|
||||
<button
|
||||
v-for="item in model.filter((entry) => !entry.separator)"
|
||||
:key="item.label"
|
||||
type="button"
|
||||
:disabled="item.disabled"
|
||||
@click="item.command?.()"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/tag', () => ({
|
||||
default: {
|
||||
props: ['value'],
|
||||
template: '<span>{{ value }}</span>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/toggleswitch', () => ({
|
||||
default: {
|
||||
props: ['modelValue', 'disabled'],
|
||||
emits: ['update:modelValue', 'change'],
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
data-testid="extension-toggle"
|
||||
@click="$emit('update:modelValue', !modelValue); $emit('change')"
|
||||
>
|
||||
{{ String(modelValue) }}
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
function renderPanel() {
|
||||
return render(ExtensionPanel, {
|
||||
global: {
|
||||
config: {
|
||||
globalProperties: fromAny({
|
||||
$t: (key: string, params?: Record<string, string>) =>
|
||||
params ? `${key}:${params.subject}` : key
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ExtensionPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSettingStore.set.mockResolvedValue(undefined)
|
||||
mockExtensionState.store.extensions = [
|
||||
{ name: 'core.color' },
|
||||
{ name: 'custom.pack' },
|
||||
{ name: 'readonly.pack' }
|
||||
]
|
||||
mockExtensionState.store.inactiveDisabledExtensionNames = ['inactive.pack']
|
||||
mockExtensionState.store.hasThirdPartyExtensions = true
|
||||
mockExtensionState.store.enabled = new Set([
|
||||
'core.color',
|
||||
'custom.pack',
|
||||
'readonly.pack'
|
||||
])
|
||||
mockExtensionState.store.core = new Set(['core.color'])
|
||||
mockExtensionState.store.readOnly = new Set(['readonly.pack'])
|
||||
})
|
||||
|
||||
it('filters extensions by all, core, and custom categories', async () => {
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByTestId('extension-table')).toHaveTextContent(
|
||||
'core.color'
|
||||
)
|
||||
expect(screen.getByTestId('extension-table')).toHaveTextContent(
|
||||
'custom.pack'
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'g.core' }))
|
||||
expect(screen.getByTestId('extension-table')).toHaveTextContent(
|
||||
'core.color'
|
||||
)
|
||||
expect(screen.getByTestId('extension-table')).not.toHaveTextContent(
|
||||
'custom.pack'
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'g.custom' }))
|
||||
expect(screen.getByTestId('extension-table')).not.toHaveTextContent(
|
||||
'core.color'
|
||||
)
|
||||
expect(screen.getByTestId('extension-table')).toHaveTextContent(
|
||||
'custom.pack'
|
||||
)
|
||||
expect(screen.getByTestId('extension-table')).toHaveTextContent(
|
||||
'readonly.pack'
|
||||
)
|
||||
})
|
||||
|
||||
it('applies selected extension commands without changing read-only rows', async () => {
|
||||
renderPanel()
|
||||
|
||||
await userEvent.click(screen.getByTestId('select-visible'))
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'g.disableSelected' })
|
||||
)
|
||||
|
||||
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
|
||||
'Comfy.Extension.Disabled',
|
||||
['inactive.pack', 'core.color', 'custom.pack']
|
||||
)
|
||||
expect(screen.getByTestId('extension-message')).toHaveTextContent(
|
||||
'core.color'
|
||||
)
|
||||
expect(screen.getByTestId('extension-message')).toHaveTextContent(
|
||||
'custom.pack'
|
||||
)
|
||||
expect(screen.getByTestId('extension-message')).not.toHaveTextContent(
|
||||
'readonly.pack'
|
||||
)
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'g.enableSelected' })
|
||||
)
|
||||
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
|
||||
'Comfy.Extension.Disabled',
|
||||
['inactive.pack']
|
||||
)
|
||||
})
|
||||
|
||||
it('applies bulk commands and disables third-party command when unavailable', async () => {
|
||||
const { unmount } = renderPanel()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'g.disableAll' }))
|
||||
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
|
||||
'Comfy.Extension.Disabled',
|
||||
['inactive.pack', 'core.color', 'custom.pack']
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'g.enableAll' }))
|
||||
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
|
||||
'Comfy.Extension.Disabled',
|
||||
['inactive.pack']
|
||||
)
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', { name: 'g.disableThirdParty' })
|
||||
)
|
||||
expect(mockSettingStore.set).toHaveBeenLastCalledWith(
|
||||
'Comfy.Extension.Disabled',
|
||||
['inactive.pack', 'custom.pack', 'readonly.pack']
|
||||
)
|
||||
|
||||
unmount()
|
||||
mockExtensionState.store.hasThirdPartyExtensions = false
|
||||
renderPanel()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'g.disableThirdParty' })
|
||||
).toBeDisabled()
|
||||
})
|
||||
})
|
||||
321
src/platform/settings/components/ServerConfigPanel.test.ts
Normal file
321
src/platform/settings/components/ServerConfigPanel.test.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import ServerConfigPanel from './ServerConfigPanel.vue'
|
||||
import type * as Pinia from 'pinia'
|
||||
|
||||
const mockSettingStore = vi.hoisted(() => ({
|
||||
set: vi.fn()
|
||||
}))
|
||||
|
||||
const mockToastStore = vi.hoisted(() => ({
|
||||
add: vi.fn()
|
||||
}))
|
||||
|
||||
const mockCopy = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockElectronAPI = vi.hoisted(() => ({
|
||||
restartApp: vi.fn()
|
||||
}))
|
||||
|
||||
const mockServerConfigStore = vi.hoisted(() => ({
|
||||
refs: null as null | {
|
||||
serverConfigsByCategory: {
|
||||
value: Record<
|
||||
string,
|
||||
Array<{
|
||||
id: string
|
||||
name: string
|
||||
value: string
|
||||
initialValue: string
|
||||
tooltip?: string
|
||||
}>
|
||||
>
|
||||
}
|
||||
serverConfigValues: { value: Record<string, string> }
|
||||
launchArgs: { value: string[] }
|
||||
commandLineArgs: { value: string }
|
||||
modifiedConfigs: {
|
||||
value: Array<{
|
||||
id: string
|
||||
name: string
|
||||
value: string
|
||||
initialValue: string
|
||||
}>
|
||||
}
|
||||
},
|
||||
revertChanges: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('pinia', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof Pinia>()
|
||||
return {
|
||||
...actual,
|
||||
storeToRefs: (store: object) => store
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, fallback?: string) => fallback ?? key
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => mockToastStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/serverConfigStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
|
||||
const serverConfigsByCategory = ref({
|
||||
general: [
|
||||
{
|
||||
id: 'listen',
|
||||
name: 'Listen',
|
||||
value: 'true',
|
||||
initialValue: 'false',
|
||||
tooltip: 'Enable listen mode'
|
||||
},
|
||||
{
|
||||
id: 'preview',
|
||||
name: 'Preview',
|
||||
value: 'auto',
|
||||
initialValue: 'auto'
|
||||
}
|
||||
]
|
||||
})
|
||||
const serverConfigValues = ref({ listen: 'true' })
|
||||
const launchArgs = ref(['--listen'])
|
||||
const commandLineArgs = ref('python main.py --listen')
|
||||
const modifiedConfigs = ref([
|
||||
{
|
||||
id: 'listen',
|
||||
name: 'Listen',
|
||||
value: 'true',
|
||||
initialValue: 'false'
|
||||
}
|
||||
])
|
||||
|
||||
mockServerConfigStore.refs = {
|
||||
serverConfigsByCategory,
|
||||
serverConfigValues,
|
||||
launchArgs,
|
||||
commandLineArgs,
|
||||
modifiedConfigs
|
||||
}
|
||||
|
||||
return {
|
||||
useServerConfigStore: () => ({
|
||||
serverConfigsByCategory,
|
||||
serverConfigValues,
|
||||
launchArgs,
|
||||
commandLineArgs,
|
||||
modifiedConfigs,
|
||||
revertChanges: mockServerConfigStore.revertChanges
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
useCopyToClipboard: () => ({
|
||||
copyToClipboard: mockCopy
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: () => mockElectronAPI
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/FormItem.vue', () => ({
|
||||
default: {
|
||||
props: ['id', 'item', 'labelClass'],
|
||||
template: `
|
||||
<label
|
||||
:data-testid="'server-config-' + id"
|
||||
:data-highlighted="String(Boolean(labelClass?.['text-highlight']))"
|
||||
:title="item.tooltip"
|
||||
>
|
||||
{{ item.name }}={{ item.value }}
|
||||
</label>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: {
|
||||
props: ['ariaLabel'],
|
||||
emits: ['click'],
|
||||
template: `
|
||||
<button type="button" :aria-label="ariaLabel" @click="$emit('click')">
|
||||
<slot />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/divider', () => ({
|
||||
default: {
|
||||
template: '<hr />'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/message', () => ({
|
||||
default: {
|
||||
template: '<section><slot name="icon" /><slot /></section>'
|
||||
}
|
||||
}))
|
||||
|
||||
function renderPanel() {
|
||||
return render(ServerConfigPanel, {
|
||||
global: {
|
||||
config: {
|
||||
globalProperties: fromAny({
|
||||
$t: (key: string, fallback?: string) => fallback ?? key
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ServerConfigPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSettingStore.set.mockResolvedValue(undefined)
|
||||
mockCopy.mockResolvedValue(undefined)
|
||||
mockElectronAPI.restartApp.mockResolvedValue(undefined)
|
||||
mockServerConfigStore.revertChanges.mockReset()
|
||||
if (mockServerConfigStore.refs) {
|
||||
mockServerConfigStore.refs.serverConfigsByCategory.value = {
|
||||
general: [
|
||||
{
|
||||
id: 'listen',
|
||||
name: 'Listen',
|
||||
value: 'true',
|
||||
initialValue: 'false',
|
||||
tooltip: 'Enable listen mode'
|
||||
},
|
||||
{
|
||||
id: 'preview',
|
||||
name: 'Preview',
|
||||
value: 'auto',
|
||||
initialValue: 'auto'
|
||||
}
|
||||
]
|
||||
}
|
||||
mockServerConfigStore.refs.serverConfigValues.value = { listen: 'true' }
|
||||
mockServerConfigStore.refs.launchArgs.value = ['--listen']
|
||||
mockServerConfigStore.refs.commandLineArgs.value =
|
||||
'python main.py --listen'
|
||||
mockServerConfigStore.refs.modifiedConfigs.value = [
|
||||
{
|
||||
id: 'listen',
|
||||
name: 'Listen',
|
||||
value: 'true',
|
||||
initialValue: 'false'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
it('renders modified configs, translates form items, and copies command line args', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByText('serverConfig.modifiedConfigs')).toBeInTheDocument()
|
||||
expect(screen.getByText('Listen: false → true')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('server-config-listen')).toHaveAttribute(
|
||||
'data-highlighted',
|
||||
'true'
|
||||
)
|
||||
expect(screen.getByTestId('server-config-listen')).toHaveAttribute(
|
||||
'title',
|
||||
'Enable listen mode'
|
||||
)
|
||||
expect(screen.getByTestId('server-config-preview')).toHaveAttribute(
|
||||
'data-highlighted',
|
||||
'false'
|
||||
)
|
||||
|
||||
await user.click(screen.getByLabelText('g.copyToClipboard'))
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('python main.py --listen')
|
||||
})
|
||||
|
||||
it('reverts, restarts, and suppresses the unmount warning after restart', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { unmount } = renderPanel()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'serverConfig.revertChanges' })
|
||||
)
|
||||
expect(mockServerConfigStore.revertChanges).toHaveBeenCalledTimes(1)
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'serverConfig.restart' })
|
||||
)
|
||||
expect(mockElectronAPI.restartApp).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockToastStore.add).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists launch args and server config values through watchers', async () => {
|
||||
renderPanel()
|
||||
|
||||
if (!mockServerConfigStore.refs) {
|
||||
throw new Error('server config refs were not initialized')
|
||||
}
|
||||
|
||||
mockServerConfigStore.refs.launchArgs.value = ['--cpu']
|
||||
await nextTick()
|
||||
mockServerConfigStore.refs.serverConfigValues.value = { listen: 'false' }
|
||||
await nextTick()
|
||||
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Server.LaunchArgs',
|
||||
['--cpu']
|
||||
)
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Server.ServerConfigValues',
|
||||
{ listen: 'false' }
|
||||
)
|
||||
})
|
||||
|
||||
it('warns on unmount only when modified configs remain', () => {
|
||||
if (!mockServerConfigStore.refs) {
|
||||
throw new Error('server config refs were not initialized')
|
||||
}
|
||||
|
||||
mockServerConfigStore.refs.modifiedConfigs.value = []
|
||||
const empty = renderPanel()
|
||||
empty.unmount()
|
||||
expect(mockToastStore.add).not.toHaveBeenCalled()
|
||||
|
||||
mockServerConfigStore.refs.modifiedConfigs.value = [
|
||||
{
|
||||
id: 'listen',
|
||||
name: 'Listen',
|
||||
value: 'true',
|
||||
initialValue: 'false'
|
||||
}
|
||||
]
|
||||
const modified = renderPanel()
|
||||
modified.unmount()
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'warn',
|
||||
summary: 'serverConfig.restartRequiredToastSummary',
|
||||
detail: 'serverConfig.restartRequiredToastDetail',
|
||||
life: 10_000
|
||||
})
|
||||
})
|
||||
})
|
||||
547
src/platform/settings/components/SettingDialog.test.ts
Normal file
547
src/platform/settings/components/SettingDialog.test.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import SettingDialog from './SettingDialog.vue'
|
||||
|
||||
interface MockSettingTreeNode {
|
||||
key: string
|
||||
label: string
|
||||
leaf?: boolean
|
||||
sortOrder?: number
|
||||
data?: { id: string; name: string; sortOrder?: number }
|
||||
children?: MockSettingTreeNode[]
|
||||
}
|
||||
|
||||
const mockFetchBalance = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockSettingUI = vi.hoisted(() => ({
|
||||
defaultPanel: undefined as string | undefined,
|
||||
refs: null as null | {
|
||||
settingCategories: {
|
||||
value: MockSettingTreeNode[]
|
||||
}
|
||||
navGroups: {
|
||||
value: Array<{
|
||||
title: string
|
||||
items: Array<{
|
||||
id: string
|
||||
label: string
|
||||
icon?: string
|
||||
badge?: string
|
||||
}>
|
||||
}>
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const mockSettingSearch = vi.hoisted(() => ({
|
||||
refs: null as null | {
|
||||
searchQuery: { value: string }
|
||||
inSearch: { value: boolean }
|
||||
searchResultsCategories: { value: Set<string> }
|
||||
matchedNavItemKeys: { value: Set<string> }
|
||||
results: {
|
||||
value: Array<{
|
||||
label: string
|
||||
category?: string
|
||||
settings: Array<{ id: string; name: string; sortOrder?: number }>
|
||||
}>
|
||||
}
|
||||
},
|
||||
handleSearch: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
fetchBalance: mockFetchBalance
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/searchQuery/useSearchQueryTracking', () => ({
|
||||
useSearchQueryTracking: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/widget/layout/BaseModalLayout.vue', () => ({
|
||||
default: {
|
||||
template: `
|
||||
<section data-testid="settings-dialog">
|
||||
<header data-testid="left-title"><slot name="leftPanelHeaderTitle" /></header>
|
||||
<aside data-testid="left-panel"><slot name="leftPanel" /></aside>
|
||||
<div data-testid="header"><slot name="header" /></div>
|
||||
<div data-testid="header-actions"><slot name="header-right-area" /></div>
|
||||
<main data-testid="content"><slot name="content" /></main>
|
||||
</section>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
|
||||
default: {
|
||||
props: ['modelValue', 'placeholder', 'autofocus'],
|
||||
emits: ['update:modelValue', 'search'],
|
||||
template: `
|
||||
<input
|
||||
data-testid="settings-search"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:data-autofocus="String(autofocus)"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
@change="$emit('search', $event.target.value)"
|
||||
/>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/widget/nav/NavTitle.vue', () => ({
|
||||
default: {
|
||||
props: ['title'],
|
||||
template: '<h3>{{ title }}</h3>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/widget/nav/NavItem.vue', () => ({
|
||||
default: {
|
||||
props: ['icon', 'badge', 'active'],
|
||||
emits: ['click'],
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
:data-nav-id="$attrs['data-nav-id']"
|
||||
:data-active="String(active)"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/dialog/content/setting/CurrentUserMessage.vue', () => ({
|
||||
default: {
|
||||
template: '<p data-testid="current-user-message">current user</p>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/components/ColorPaletteMessage.vue', () => ({
|
||||
default: {
|
||||
template: '<p data-testid="color-palette-message">palette</p>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/components/SettingsPanel.vue', () => ({
|
||||
default: {
|
||||
props: ['settingGroups'],
|
||||
template: `
|
||||
<div data-testid="settings-panel">
|
||||
<section v-for="group in settingGroups" :key="group.label">
|
||||
<h4>{{ group.label }}</h4>
|
||||
<span v-for="setting in group.settings" :key="setting.id">
|
||||
{{ setting.id }}
|
||||
</span>
|
||||
</section>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/composables/useSettingUI', async () => {
|
||||
const { computed, defineComponent, h, ref } = await import('vue')
|
||||
|
||||
const settingCategories = ref([
|
||||
{
|
||||
key: 'Comfy',
|
||||
label: 'Comfy',
|
||||
children: [
|
||||
{
|
||||
key: 'General',
|
||||
label: 'General',
|
||||
children: [
|
||||
{
|
||||
key: 'Comfy.High',
|
||||
label: 'High',
|
||||
leaf: true,
|
||||
data: { id: 'Comfy.High', name: 'High', sortOrder: 30 }
|
||||
},
|
||||
{
|
||||
key: 'Comfy.Low',
|
||||
label: 'Low',
|
||||
leaf: true,
|
||||
data: { id: 'Comfy.Low', name: 'Low', sortOrder: 10 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'Advanced',
|
||||
label: 'Advanced',
|
||||
children: [
|
||||
{
|
||||
key: 'Comfy.Advanced',
|
||||
label: 'Advanced',
|
||||
leaf: true,
|
||||
data: { id: 'Comfy.Advanced', name: 'Advanced' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'Appearance',
|
||||
label: 'Appearance',
|
||||
children: [
|
||||
{
|
||||
key: 'Palette',
|
||||
label: 'Palette',
|
||||
children: [
|
||||
{
|
||||
key: 'Appearance.Palette',
|
||||
label: 'Palette',
|
||||
leaf: true,
|
||||
data: {
|
||||
id: 'Appearance.Palette',
|
||||
name: 'Palette',
|
||||
sortOrder: 20
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const navGroups = ref([
|
||||
{
|
||||
title: 'Core',
|
||||
items: [
|
||||
{ id: 'Comfy', label: 'Comfy', icon: 'settings' },
|
||||
{ id: 'Appearance', label: 'Appearance', icon: 'palette' },
|
||||
{ id: 'keybinding', label: 'Keybinding', icon: 'keyboard' },
|
||||
{ id: 'credits', label: 'Credits', icon: 'coins' }
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const keybindingPanel = {
|
||||
node: { key: 'keybinding', label: 'Keybinding', children: [] },
|
||||
component: defineComponent({
|
||||
name: 'MockKeybindingPanel',
|
||||
setup: () => () => h('div', { 'data-testid': 'keybinding-panel' }, 'keys')
|
||||
})
|
||||
}
|
||||
|
||||
mockSettingUI.refs = {
|
||||
settingCategories,
|
||||
navGroups
|
||||
}
|
||||
|
||||
return {
|
||||
useSettingUI: vi.fn((defaultPanel?: string) => ({
|
||||
defaultCategory: computed(
|
||||
() =>
|
||||
settingCategories.value.find((c) => c.key === defaultPanel) ??
|
||||
settingCategories.value[0]
|
||||
),
|
||||
settingCategories,
|
||||
navGroups,
|
||||
findCategoryByKey: (key: string) =>
|
||||
settingCategories.value.find((c) => c.key === key) ?? null,
|
||||
findPanelByKey: (key: string) =>
|
||||
key === 'keybinding' ? keybindingPanel : null
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/composables/useSettingSearch', async () => {
|
||||
const { computed, ref } = await import('vue')
|
||||
|
||||
const searchQuery = ref('')
|
||||
const inSearch = ref(false)
|
||||
const searchResultsCategories = ref(new Set<string>())
|
||||
const matchedNavItemKeys = ref(new Set<string>())
|
||||
const results = ref([
|
||||
{
|
||||
label: 'Search Group',
|
||||
category: 'Comfy',
|
||||
settings: [{ id: 'Comfy.SearchResult', name: 'Search Result' }]
|
||||
}
|
||||
])
|
||||
|
||||
mockSettingSearch.refs = {
|
||||
searchQuery,
|
||||
inSearch,
|
||||
searchResultsCategories,
|
||||
matchedNavItemKeys,
|
||||
results
|
||||
}
|
||||
|
||||
mockSettingSearch.handleSearch.mockImplementation(
|
||||
(query: string, navItems: Array<{ key: string; label: string }> = []) => {
|
||||
searchQuery.value = query
|
||||
inSearch.value = query.length > 0
|
||||
searchResultsCategories.value = query.includes('appearance')
|
||||
? new Set(['Appearance'])
|
||||
: new Set()
|
||||
matchedNavItemKeys.value = new Set(
|
||||
navItems
|
||||
.filter((item) => item.label.toLowerCase().includes(query))
|
||||
.map((item) => item.key)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
useSettingSearch: vi.fn(() => ({
|
||||
searchQuery,
|
||||
inSearch,
|
||||
searchResultsCategories: computed(() => searchResultsCategories.value),
|
||||
matchedNavItemKeys: computed(() => matchedNavItemKeys.value),
|
||||
handleSearch: mockSettingSearch.handleSearch,
|
||||
getSearchResults: vi.fn(() => results.value)
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
function renderDialog(
|
||||
props: Partial<InstanceType<typeof SettingDialog>['$props']> = {}
|
||||
) {
|
||||
return render(SettingDialog, {
|
||||
props: {
|
||||
onClose: vi.fn(),
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
config: {
|
||||
globalProperties: fromAny({
|
||||
$t: (key: string, params?: Record<string, string>) =>
|
||||
params ? `${key}:${params.panel}` : key
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('SettingDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchBalance.mockReset()
|
||||
if (mockSettingSearch.refs) {
|
||||
mockSettingSearch.refs.searchQuery.value = ''
|
||||
mockSettingSearch.refs.inSearch.value = false
|
||||
mockSettingSearch.refs.searchResultsCategories.value = new Set()
|
||||
mockSettingSearch.refs.matchedNavItemKeys.value = new Set()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the default category panel with sorted groups and settings', () => {
|
||||
renderDialog()
|
||||
|
||||
expect(screen.getByTestId('current-user-message')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('settings-panel')).toHaveTextContent('General')
|
||||
expect(screen.getByTestId('settings-panel')).toHaveTextContent('Advanced')
|
||||
expect(screen.getByTestId('settings-panel').textContent).toMatch(
|
||||
/Comfy\.High.*Comfy\.Low/
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Comfy' })).toHaveAttribute(
|
||||
'data-active',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('switches category from the nav and fetches credits balance for credits', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Appearance' }))
|
||||
expect(screen.getByTestId('color-palette-message')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Appearance' })).toHaveAttribute(
|
||||
'data-active',
|
||||
'true'
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Credits' }))
|
||||
await nextTick()
|
||||
|
||||
expect(mockFetchBalance).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders panel header slots and disables search autofocus for keybindings', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Keybinding' }))
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('keybinding-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('header')).not.toBeEmptyDOMElement()
|
||||
expect(screen.getByTestId('header-actions')).not.toBeEmptyDOMElement()
|
||||
expect(screen.getByTestId('settings-search')).toHaveAttribute(
|
||||
'data-autofocus',
|
||||
'false'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders search results and activates the first matching nav item', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
const input = screen.getByTestId('settings-search')
|
||||
await user.type(input, 'appearance')
|
||||
await user.tab()
|
||||
await nextTick()
|
||||
|
||||
expect(mockSettingSearch.handleSearch).toHaveBeenCalledWith(
|
||||
'appearance',
|
||||
expect.arrayContaining([{ key: 'Appearance', label: 'Appearance' }])
|
||||
)
|
||||
expect(screen.getByTestId('settings-panel')).toHaveTextContent(
|
||||
'Comfy.SearchResult'
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Appearance' })).toHaveAttribute(
|
||||
'data-active',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps search mode active when no nav item or category matches', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
const input = screen.getByTestId('settings-search')
|
||||
await user.type(input, 'unmatched')
|
||||
await user.tab()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('settings-panel')).toHaveTextContent(
|
||||
'Comfy.SearchResult'
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Comfy' })).toHaveAttribute(
|
||||
'data-active',
|
||||
'false'
|
||||
)
|
||||
})
|
||||
|
||||
it('restores the default category after clearing search', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
const input = screen.getByTestId('settings-search')
|
||||
await user.type(input, 'unmatched')
|
||||
await user.tab()
|
||||
await nextTick()
|
||||
await user.clear(input)
|
||||
await user.tab()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('current-user-message')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Comfy' })).toHaveAttribute(
|
||||
'data-active',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('sorts groups by label when group sort order ties', async () => {
|
||||
const refs = mockSettingUI.refs
|
||||
if (!refs) throw new Error('Expected setting UI refs')
|
||||
|
||||
const originalCategories = refs.settingCategories.value
|
||||
const originalNavGroups = refs.navGroups.value
|
||||
refs.settingCategories.value = [
|
||||
...originalCategories,
|
||||
{
|
||||
key: 'Tie',
|
||||
label: 'Tie',
|
||||
children: [
|
||||
{
|
||||
key: 'Beta',
|
||||
label: 'Beta',
|
||||
children: [
|
||||
{
|
||||
key: 'Tie.Beta',
|
||||
label: 'Beta',
|
||||
leaf: true,
|
||||
data: { id: 'Tie.Beta', name: 'Beta', sortOrder: 5 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'Alpha',
|
||||
label: 'Alpha',
|
||||
children: [
|
||||
{
|
||||
key: 'Tie.Alpha',
|
||||
label: 'Alpha',
|
||||
leaf: true,
|
||||
data: { id: 'Tie.Alpha', name: 'Alpha', sortOrder: 5 }
|
||||
},
|
||||
{
|
||||
key: 'Tie.NoSort',
|
||||
label: 'NoSort',
|
||||
leaf: true,
|
||||
data: { id: 'Tie.NoSort', name: 'NoSort' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
refs.navGroups.value = [
|
||||
{
|
||||
title: 'Core',
|
||||
items: [
|
||||
...originalNavGroups[0].items,
|
||||
{ id: 'Tie', label: 'Tie', icon: 'settings' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
try {
|
||||
renderDialog()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Tie' }))
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('settings-panel').textContent).toMatch(
|
||||
/Alpha.*Beta/
|
||||
)
|
||||
expect(screen.getByTestId('settings-panel').textContent).toMatch(
|
||||
/Tie\.Alpha.*Tie\.NoSort/
|
||||
)
|
||||
} finally {
|
||||
refs.settingCategories.value = originalCategories
|
||||
refs.navGroups.value = originalNavGroups
|
||||
}
|
||||
})
|
||||
|
||||
it('scrolls to a target setting and removes its highlight after animation', async () => {
|
||||
const target = document.createElement('div')
|
||||
target.dataset.settingId = 'Comfy.Target'
|
||||
const scrollIntoView = vi.fn()
|
||||
target.scrollIntoView = scrollIntoView
|
||||
document.body.appendChild(target)
|
||||
|
||||
try {
|
||||
renderDialog({ scrollToSettingId: 'Comfy.Target' })
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
})
|
||||
expect(target.classList.contains('setting-highlight')).toBe(true)
|
||||
|
||||
target.dispatchEvent(new Event('animationend'))
|
||||
|
||||
expect(target.classList.contains('setting-highlight')).toBe(false)
|
||||
} finally {
|
||||
target.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
199
src/platform/settings/composables/useLitegraphSettings.test.ts
Normal file
199
src/platform/settings/composables/useLitegraphSettings.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { nextTick, reactive } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
|
||||
import {
|
||||
CanvasPointer,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
type SettingValue = boolean | number | string
|
||||
|
||||
// The real canvasStore exposes `canvas` via a shallowRef, so the mock must be
|
||||
// reactive for the composable's watchEffects to re-run when the canvas mounts
|
||||
// after setup. `vi.hoisted` runs before imports, hence the dynamic import.
|
||||
const { canvasStore, settings } = await vi.hoisted(async () => {
|
||||
const { reactive } = await import('vue')
|
||||
return {
|
||||
canvasStore: reactive({
|
||||
canvas: undefined as
|
||||
| undefined
|
||||
| {
|
||||
show_info?: SettingValue
|
||||
zoom_speed?: SettingValue
|
||||
auto_pan_speed?: SettingValue
|
||||
links_render_mode?: SettingValue
|
||||
min_font_size_for_lod?: SettingValue
|
||||
linkMarkerShape?: SettingValue
|
||||
maximumFps?: SettingValue
|
||||
dragZoomEnabled?: SettingValue
|
||||
liveSelection?: SettingValue
|
||||
groupSelectChildren?: SettingValue
|
||||
draw: ReturnType<typeof vi.fn>
|
||||
setDirty: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}),
|
||||
settings: {
|
||||
current: {} as Record<string, SettingValue>
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => {
|
||||
class MockCanvasPointer {
|
||||
static doubleClickTime = 0
|
||||
static bufferTime = 0
|
||||
static maxClickDrift = 0
|
||||
}
|
||||
|
||||
class MockLGraphNode {
|
||||
static keepAllLinksOnBypass = false
|
||||
}
|
||||
|
||||
return {
|
||||
CanvasPointer: MockCanvasPointer,
|
||||
LGraphNode: MockLGraphNode,
|
||||
LiteGraph: {
|
||||
Reroute: {},
|
||||
snaps_for_comfy: false,
|
||||
snap_highlights_node: false,
|
||||
middle_click_slot_add_default_node: false,
|
||||
CANVAS_GRID_SIZE: 0,
|
||||
alwaysSnapToGrid: false,
|
||||
context_menu_scaling: 1,
|
||||
canvasNavigationMode: 'legacy',
|
||||
macTrackpadGestures: false,
|
||||
leftMouseClickBehavior: 'select',
|
||||
mouseWheelScroll: 'zoom',
|
||||
saveViewportWithGraph: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => settings.current[key]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => canvasStore
|
||||
}))
|
||||
|
||||
function makeCanvas() {
|
||||
return {
|
||||
draw: vi.fn(),
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
settings.current = reactive({
|
||||
'Comfy.Graph.CanvasInfo': true,
|
||||
'Comfy.Graph.ZoomSpeed': 1.25,
|
||||
'Comfy.Graph.AutoPanSpeed': 0.75,
|
||||
'Comfy.Node.AutoSnapLinkToSlot': true,
|
||||
'Comfy.Node.SnapHighlightsNode': true,
|
||||
'Comfy.Node.BypassAllLinksOnDelete': true,
|
||||
'Comfy.Node.MiddleClickRerouteNode': true,
|
||||
'Comfy.LinkRenderMode': 2,
|
||||
'LiteGraph.Canvas.MinFontSizeForLOD': 9,
|
||||
'Comfy.Graph.LinkMarkers': 'arrow',
|
||||
'LiteGraph.Canvas.MaximumFps': 42,
|
||||
'Comfy.Graph.CtrlShiftZoom': true,
|
||||
'Comfy.Graph.LiveSelection': true,
|
||||
'Comfy.Pointer.DoubleClickTime': 250,
|
||||
'Comfy.Pointer.ClickBufferTime': 80,
|
||||
'Comfy.Pointer.ClickDrift': 4,
|
||||
'Comfy.SnapToGrid.GridSize': 16,
|
||||
'pysssss.SnapToGrid': true,
|
||||
'LiteGraph.ContextMenu.Scaling': 1.5,
|
||||
'LiteGraph.Reroute.SplineOffset': 32,
|
||||
'Comfy.Canvas.NavigationMode': 'standard',
|
||||
'Comfy.Canvas.LeftMouseClickBehavior': 'panning',
|
||||
'Comfy.Canvas.MouseWheelScroll': 'panning',
|
||||
'Comfy.EnableWorkflowViewRestore': true,
|
||||
'LiteGraph.Group.SelectChildrenOnClick': true
|
||||
})
|
||||
canvasStore.canvas = reactive(makeCanvas())
|
||||
})
|
||||
|
||||
describe('useLitegraphSettings', () => {
|
||||
it('applies canvas settings and marks affected layers dirty', () => {
|
||||
useLitegraphSettings()
|
||||
|
||||
expect(canvasStore.canvas?.show_info).toBe(true)
|
||||
expect(canvasStore.canvas?.zoom_speed).toBe(1.25)
|
||||
expect(canvasStore.canvas?.auto_pan_speed).toBe(0.75)
|
||||
expect(canvasStore.canvas?.links_render_mode).toBe(2)
|
||||
expect(canvasStore.canvas?.min_font_size_for_lod).toBe(9)
|
||||
expect(canvasStore.canvas?.linkMarkerShape).toBe('arrow')
|
||||
expect(canvasStore.canvas?.maximumFps).toBe(42)
|
||||
expect(canvasStore.canvas?.dragZoomEnabled).toBe(true)
|
||||
expect(canvasStore.canvas?.liveSelection).toBe(true)
|
||||
expect(canvasStore.canvas?.groupSelectChildren).toBe(true)
|
||||
expect(canvasStore.canvas?.draw).toHaveBeenCalledWith(false, true)
|
||||
expect(canvasStore.canvas?.setDirty).toHaveBeenCalledWith(false, true)
|
||||
expect(canvasStore.canvas?.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('applies global LiteGraph and pointer settings', () => {
|
||||
useLitegraphSettings()
|
||||
|
||||
expect(LiteGraph.snaps_for_comfy).toBe(true)
|
||||
expect(LiteGraph.snap_highlights_node).toBe(true)
|
||||
expect(LGraphNode.keepAllLinksOnBypass).toBe(true)
|
||||
expect(LiteGraph.middle_click_slot_add_default_node).toBe(true)
|
||||
expect(CanvasPointer.doubleClickTime).toBe(250)
|
||||
expect(CanvasPointer.bufferTime).toBe(80)
|
||||
expect(CanvasPointer.maxClickDrift).toBe(4)
|
||||
expect(LiteGraph.CANVAS_GRID_SIZE).toBe(16)
|
||||
expect(LiteGraph.alwaysSnapToGrid).toBe(true)
|
||||
expect(LiteGraph.context_menu_scaling).toBe(1.5)
|
||||
expect(LiteGraph.Reroute.maxSplineOffset).toBe(32)
|
||||
expect(LiteGraph.canvasNavigationMode).toBe('standard')
|
||||
expect(LiteGraph.macTrackpadGestures).toBe(true)
|
||||
expect(LiteGraph.leftMouseClickBehavior).toBe('panning')
|
||||
expect(LiteGraph.mouseWheelScroll).toBe('panning')
|
||||
expect(LiteGraph.saveViewportWithGraph).toBe(true)
|
||||
})
|
||||
|
||||
it('responds when reactive settings change', async () => {
|
||||
useLitegraphSettings()
|
||||
|
||||
settings.current['Comfy.Graph.CanvasInfo'] = false
|
||||
settings.current['Comfy.Canvas.NavigationMode'] = 'custom'
|
||||
settings.current['LiteGraph.Group.SelectChildrenOnClick'] = false
|
||||
await nextTick()
|
||||
|
||||
expect(canvasStore.canvas?.show_info).toBe(false)
|
||||
expect(canvasStore.canvas?.groupSelectChildren).toBe(false)
|
||||
expect(LiteGraph.canvasNavigationMode).toBe('custom')
|
||||
expect(LiteGraph.macTrackpadGestures).toBe(false)
|
||||
})
|
||||
|
||||
it('updates global settings when the canvas is not mounted yet', () => {
|
||||
canvasStore.canvas = undefined
|
||||
|
||||
useLitegraphSettings()
|
||||
|
||||
expect(LiteGraph.snaps_for_comfy).toBe(true)
|
||||
expect(CanvasPointer.doubleClickTime).toBe(250)
|
||||
})
|
||||
|
||||
it('applies canvas settings once the canvas mounts after setup', async () => {
|
||||
canvasStore.canvas = undefined
|
||||
|
||||
useLitegraphSettings()
|
||||
|
||||
canvasStore.canvas = reactive(makeCanvas())
|
||||
await nextTick()
|
||||
|
||||
expect(canvasStore.canvas?.show_info).toBe(true)
|
||||
expect(canvasStore.canvas?.zoom_speed).toBe(1.25)
|
||||
expect(canvasStore.canvas?.links_render_mode).toBe(2)
|
||||
expect(canvasStore.canvas?.draw).toHaveBeenCalledWith(false, true)
|
||||
expect(canvasStore.canvas?.setDirty).toHaveBeenCalledWith(false, true)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
getSettingInfo,
|
||||
@@ -11,31 +10,47 @@ import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
|
||||
import { useSettingUI } from './useSettingUI'
|
||||
|
||||
const { auth, billing, dist, featureFlags, vueFlags } = vi.hoisted(() => ({
|
||||
auth: { isLoggedIn: { value: false } },
|
||||
billing: { isActiveSubscription: { value: false } },
|
||||
dist: { isCloud: false, isDesktop: false },
|
||||
featureFlags: { teamWorkspacesEnabled: false, userSecretsEnabled: false },
|
||||
vueFlags: { shouldRenderVueNodes: { value: false } }
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (_: string, fallback: string) => fallback })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({ isLoggedIn: ref(false) })
|
||||
useCurrentUser: () => ({ isLoggedIn: auth.isLoggedIn })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({ isActiveSubscription: ref(false) })
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: billing.isActiveSubscription
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({
|
||||
flags: { teamWorkspacesEnabled: false, userSecretsEnabled: false }
|
||||
flags: featureFlags
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useVueFeatureFlags', () => ({
|
||||
useVueFeatureFlags: () => ({ shouldRenderVueNodes: ref(false) })
|
||||
useVueFeatureFlags: () => ({
|
||||
shouldRenderVueNodes: vueFlags.shouldRenderVueNodes
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false,
|
||||
isDesktop: false
|
||||
get isCloud() {
|
||||
return dist.isCloud
|
||||
},
|
||||
get isDesktop() {
|
||||
return dist.isDesktop
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
@@ -49,6 +64,7 @@ interface MockSettingParams {
|
||||
type: string
|
||||
defaultValue: unknown
|
||||
category?: string[]
|
||||
hideInVueNodes?: boolean
|
||||
}
|
||||
|
||||
describe('useSettingUI', () => {
|
||||
@@ -72,13 +88,23 @@ describe('useSettingUI', () => {
|
||||
defaultValue: 'dark'
|
||||
}
|
||||
}
|
||||
let settingsById: Record<string, MockSettingParams>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
vi.clearAllMocks()
|
||||
auth.isLoggedIn.value = false
|
||||
billing.isActiveSubscription.value = false
|
||||
dist.isCloud = false
|
||||
dist.isDesktop = false
|
||||
featureFlags.teamWorkspacesEnabled = false
|
||||
featureFlags.userSecretsEnabled = false
|
||||
vueFlags.shouldRenderVueNodes.value = false
|
||||
Object.assign(window, { __CONFIG__: {} })
|
||||
|
||||
settingsById = mockSettings
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
settingsById: mockSettings
|
||||
settingsById
|
||||
} as ReturnType<typeof useSettingStore>)
|
||||
|
||||
vi.mocked(getSettingInfo).mockImplementation((setting) => {
|
||||
@@ -107,9 +133,9 @@ describe('useSettingUI', () => {
|
||||
undefined,
|
||||
'Comfy.Locale'
|
||||
)
|
||||
const comfyCategory = findCategory(settingCategories.value, 'Comfy')
|
||||
expect(comfyCategory).toBeDefined()
|
||||
expect(defaultCategory.value).toBe(comfyCategory)
|
||||
expect(defaultCategory.value).toBe(
|
||||
findCategory(settingCategories.value, 'Comfy')
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves different category from scrollToSettingId', () => {
|
||||
@@ -121,7 +147,6 @@ describe('useSettingUI', () => {
|
||||
settingCategories.value,
|
||||
'Appearance'
|
||||
)
|
||||
expect(appearanceCategory).toBeDefined()
|
||||
expect(defaultCategory.value).toBe(appearanceCategory)
|
||||
})
|
||||
|
||||
@@ -137,4 +162,192 @@ describe('useSettingUI', () => {
|
||||
const { defaultCategory } = useSettingUI('about', 'Comfy.Locale')
|
||||
expect(defaultCategory.value.key).toBe('about')
|
||||
})
|
||||
|
||||
it('falls back when defaultPanel is not in the menu', () => {
|
||||
const missingPanel = 'missing' as unknown as Parameters<
|
||||
typeof useSettingUI
|
||||
>[0]
|
||||
const { defaultCategory, settingCategories } = useSettingUI(missingPanel)
|
||||
expect(defaultCategory.value).toBe(settingCategories.value[0])
|
||||
})
|
||||
|
||||
it('moves floating settings into Other and hides Vue-node-only settings', () => {
|
||||
settingsById = {
|
||||
Floating: {
|
||||
id: 'Floating',
|
||||
name: 'Floating',
|
||||
type: 'boolean',
|
||||
defaultValue: false
|
||||
},
|
||||
'Hidden.Setting': {
|
||||
id: 'Hidden.Setting',
|
||||
name: 'Hidden',
|
||||
type: 'hidden',
|
||||
defaultValue: false
|
||||
},
|
||||
'Vue.Hidden': {
|
||||
id: 'Vue.Hidden',
|
||||
name: 'Vue Hidden',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
hideInVueNodes: true
|
||||
}
|
||||
}
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
settingsById
|
||||
} as ReturnType<typeof useSettingStore>)
|
||||
vueFlags.shouldRenderVueNodes.value = true
|
||||
|
||||
const { settingCategories } = useSettingUI()
|
||||
|
||||
expect(settingCategories.value.map((category) => category.label)).toEqual([
|
||||
'Other'
|
||||
])
|
||||
expect(
|
||||
settingCategories.value[0].children?.map((node) => node.key)
|
||||
).toEqual(['root/Floating'])
|
||||
})
|
||||
|
||||
it('adds gated cloud, desktop, workspace, and secrets panels', () => {
|
||||
auth.isLoggedIn.value = true
|
||||
billing.isActiveSubscription.value = true
|
||||
dist.isCloud = true
|
||||
dist.isDesktop = true
|
||||
featureFlags.teamWorkspacesEnabled = true
|
||||
featureFlags.userSecretsEnabled = true
|
||||
Object.assign(window, { __CONFIG__: { subscription_required: true } })
|
||||
|
||||
const { findCategoryByKey, findPanelByKey, navGroups, panels } =
|
||||
useSettingUI()
|
||||
|
||||
expect(panels.value.map((panel) => panel.node.key)).toEqual([
|
||||
'about',
|
||||
'credits',
|
||||
'user',
|
||||
'workspace',
|
||||
'keybinding',
|
||||
'extension',
|
||||
'server-config',
|
||||
'subscription',
|
||||
'secrets'
|
||||
])
|
||||
expect(navGroups.value.map((group) => group.title)).toEqual([
|
||||
'Workspace',
|
||||
'General'
|
||||
])
|
||||
expect(findCategoryByKey('secrets')?.key).toBe('secrets')
|
||||
expect(findCategoryByKey('missing')).toBeNull()
|
||||
expect(findPanelByKey('subscription')?.node.key).toBe('subscription')
|
||||
expect(findPanelByKey('missing')).toBeNull()
|
||||
})
|
||||
|
||||
it('builds the legacy account menu from auth and subscription gates', () => {
|
||||
auth.isLoggedIn.value = true
|
||||
billing.isActiveSubscription.value = true
|
||||
dist.isCloud = true
|
||||
featureFlags.userSecretsEnabled = true
|
||||
Object.assign(window, { __CONFIG__: { subscription_required: true } })
|
||||
|
||||
const { navGroups, panels } = useSettingUI()
|
||||
|
||||
expect(panels.value.map((panel) => panel.node.key)).toEqual([
|
||||
'about',
|
||||
'credits',
|
||||
'user',
|
||||
'keybinding',
|
||||
'extension',
|
||||
'subscription',
|
||||
'secrets'
|
||||
])
|
||||
expect(navGroups.value[0]).toEqual({
|
||||
title: 'Account',
|
||||
items: [
|
||||
{
|
||||
id: 'user',
|
||||
label: 'User',
|
||||
icon: 'icon-[lucide--user]'
|
||||
},
|
||||
{
|
||||
id: 'subscription',
|
||||
label: 'PlanCredits',
|
||||
icon: 'icon-[lucide--credit-card]'
|
||||
},
|
||||
{
|
||||
id: 'secrets',
|
||||
label: 'Secrets',
|
||||
icon: 'icon-[lucide--key-round]'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('includes credits in legacy account settings when login is not subscription-gated', () => {
|
||||
auth.isLoggedIn.value = true
|
||||
dist.isCloud = true
|
||||
|
||||
const { navGroups } = useSettingUI()
|
||||
|
||||
expect(navGroups.value[0].items.map((item) => item.id)).toEqual([
|
||||
'user',
|
||||
'credits'
|
||||
])
|
||||
})
|
||||
|
||||
it('builds workspace menus without optional children when gates are closed', () => {
|
||||
dist.isCloud = true
|
||||
featureFlags.teamWorkspacesEnabled = true
|
||||
|
||||
const { navGroups, panels } = useSettingUI()
|
||||
|
||||
expect(panels.value.map((panel) => panel.node.key)).toEqual([
|
||||
'about',
|
||||
'credits',
|
||||
'user',
|
||||
'keybinding',
|
||||
'extension'
|
||||
])
|
||||
expect(navGroups.value.map((group) => group.title)).toEqual([
|
||||
'Workspace',
|
||||
'General'
|
||||
])
|
||||
expect(navGroups.value[0].items).toEqual([])
|
||||
})
|
||||
|
||||
it('uses label and fallback icons for custom categories', () => {
|
||||
settingsById = {
|
||||
'Acme.Tools.Toggle': {
|
||||
id: 'Acme.Tools.Toggle',
|
||||
name: 'Toggle',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
category: ['Acme Tools', 'Toggles']
|
||||
},
|
||||
PlanSetting: {
|
||||
id: 'PlanSetting',
|
||||
name: 'Plan Setting',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
category: ['PlanCredits', 'Credits']
|
||||
}
|
||||
}
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
settingsById
|
||||
} as ReturnType<typeof useSettingStore>)
|
||||
|
||||
const { navGroups } = useSettingUI()
|
||||
const settingsItems = navGroups.value[1].items
|
||||
|
||||
expect(settingsItems).toEqual([
|
||||
{
|
||||
id: 'root/Acme Tools',
|
||||
label: 'Acme Tools',
|
||||
icon: 'icon-[lucide--plug]'
|
||||
},
|
||||
{
|
||||
id: 'root/PlanCredits',
|
||||
label: 'PlanCredits',
|
||||
icon: 'icon-[lucide--credit-card]'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
200
src/platform/settings/constants/coreSettings.test.ts
Normal file
200
src/platform/settings/constants/coreSettings.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
import type { Keybinding } from '@/platform/keybindings/types'
|
||||
|
||||
const mockSettingStore = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
setMany: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false,
|
||||
isDesktop: false,
|
||||
isNightly: false
|
||||
}))
|
||||
|
||||
vi.mock('@/locales/localeConfig', () => ({
|
||||
getDefaultLocale: () => 'en',
|
||||
SUPPORTED_LOCALE_OPTIONS: [{ value: 'en', text: 'English' }]
|
||||
}))
|
||||
|
||||
function setting<T = unknown>(id: string): SettingParams<T> {
|
||||
const result = CORE_SETTINGS.find((item) => item.id === id)
|
||||
if (!result) throw new Error(`Missing setting ${id}`)
|
||||
return result as SettingParams<T>
|
||||
}
|
||||
|
||||
describe('CORE_SETTINGS', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.className = ''
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('uses compact sidebar size below the wide breakpoint', () => {
|
||||
vi.stubGlobal('innerWidth', 1200)
|
||||
|
||||
const defaultValue = setting('Comfy.Sidebar.Size').defaultValue
|
||||
|
||||
expect(typeof defaultValue).toBe('function')
|
||||
expect((defaultValue as () => string)()).toBe('small')
|
||||
})
|
||||
|
||||
it('uses normal sidebar size above the wide breakpoint', () => {
|
||||
vi.stubGlobal('innerWidth', 1600)
|
||||
|
||||
const defaultValue = setting('Comfy.Sidebar.Size').defaultValue
|
||||
|
||||
expect((defaultValue as () => string)()).toBe('normal')
|
||||
})
|
||||
|
||||
it('updates dependent canvas settings when navigation mode changes', async () => {
|
||||
const navigation = setting<string>('Comfy.Canvas.NavigationMode')
|
||||
|
||||
await navigation.onChange?.('standard', 'legacy')
|
||||
expect(mockSettingStore.setMany).toHaveBeenLastCalledWith({
|
||||
'Comfy.Canvas.LeftMouseClickBehavior': 'select',
|
||||
'Comfy.Canvas.MouseWheelScroll': 'panning'
|
||||
})
|
||||
|
||||
await navigation.onChange?.('legacy', 'standard')
|
||||
expect(mockSettingStore.setMany).toHaveBeenLastCalledWith({
|
||||
'Comfy.Canvas.LeftMouseClickBehavior': 'panning',
|
||||
'Comfy.Canvas.MouseWheelScroll': 'zoom'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not update dependent canvas settings on initial navigation setup', async () => {
|
||||
await setting<string>('Comfy.Canvas.NavigationMode').onChange?.('standard')
|
||||
|
||||
expect(mockSettingStore.setMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps preset navigation mode when left-click behavior still matches it', async () => {
|
||||
mockSettingStore.get.mockReturnValue('standard')
|
||||
|
||||
await setting<string>('Comfy.Canvas.LeftMouseClickBehavior').onChange?.(
|
||||
'select'
|
||||
)
|
||||
|
||||
expect(mockSettingStore.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks navigation mode custom when left-click behavior diverges from the preset', async () => {
|
||||
mockSettingStore.get.mockReturnValue('standard')
|
||||
|
||||
await setting<string>('Comfy.Canvas.LeftMouseClickBehavior').onChange?.(
|
||||
'panning'
|
||||
)
|
||||
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.NavigationMode',
|
||||
'custom'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not rewrite custom navigation mode from left-click behavior', async () => {
|
||||
mockSettingStore.get.mockReturnValue('custom')
|
||||
|
||||
await setting<string>('Comfy.Canvas.LeftMouseClickBehavior').onChange?.(
|
||||
'select'
|
||||
)
|
||||
|
||||
expect(mockSettingStore.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps preset navigation mode when wheel behavior still matches it', async () => {
|
||||
mockSettingStore.get.mockReturnValue('legacy')
|
||||
|
||||
await setting<string>('Comfy.Canvas.MouseWheelScroll').onChange?.('zoom')
|
||||
|
||||
expect(mockSettingStore.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks navigation mode custom when wheel behavior diverges from the preset', async () => {
|
||||
mockSettingStore.get.mockReturnValue('legacy')
|
||||
|
||||
await setting<string>('Comfy.Canvas.MouseWheelScroll').onChange?.('panning')
|
||||
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.NavigationMode',
|
||||
'custom'
|
||||
)
|
||||
})
|
||||
|
||||
it('toggles the dev-mode API save button when present', () => {
|
||||
const button = document.createElement('button')
|
||||
button.id = 'comfy-dev-save-api-button'
|
||||
document.body.append(button)
|
||||
|
||||
const devMode = setting<boolean>('Comfy.DevMode')
|
||||
devMode.onChange?.(true)
|
||||
expect(button.style.display).toBe('flex')
|
||||
|
||||
devMode.onChange?.(false)
|
||||
expect(button.style.display).toBe('none')
|
||||
})
|
||||
|
||||
it('ignores the dev-mode button handler when the element is absent', () => {
|
||||
expect(() =>
|
||||
setting<boolean>('Comfy.DevMode').onChange?.(true)
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('toggles the disabled animations body class', () => {
|
||||
const animations = setting<boolean>('Comfy.Appearance.DisableAnimations')
|
||||
|
||||
animations.onChange?.(true)
|
||||
expect(document.body.classList.contains('disable-animations')).toBe(true)
|
||||
|
||||
animations.onChange?.(false)
|
||||
expect(document.body.classList.contains('disable-animations')).toBe(false)
|
||||
})
|
||||
|
||||
it('migrates deprecated menu and workflow tab values', () => {
|
||||
expect(
|
||||
setting<string>('Comfy.UseNewMenu').migrateDeprecatedValue?.('Floating')
|
||||
).toBe('Top')
|
||||
expect(
|
||||
setting<string>('Comfy.UseNewMenu').migrateDeprecatedValue?.('Bottom')
|
||||
).toBe('Top')
|
||||
expect(
|
||||
setting<string>('Comfy.UseNewMenu').migrateDeprecatedValue?.('Top')
|
||||
).toBe('Top')
|
||||
expect(
|
||||
setting<string>(
|
||||
'Comfy.Workflow.WorkflowTabsPosition'
|
||||
).migrateDeprecatedValue?.('Topbar (2nd-row)')
|
||||
).toBe('Topbar')
|
||||
})
|
||||
|
||||
it('migrates graph-canvas keybinding target selectors', () => {
|
||||
const bindings = [
|
||||
{
|
||||
combo: { key: 'a' },
|
||||
commandId: 'test.command',
|
||||
targetSelector: '#graph-canvas'
|
||||
},
|
||||
{
|
||||
combo: { key: 'b' },
|
||||
commandId: 'other.command',
|
||||
targetSelector: '#other'
|
||||
}
|
||||
] as unknown as Keybinding[]
|
||||
|
||||
const migrated =
|
||||
setting<Keybinding[]>(
|
||||
'Comfy.Keybinding.UnsetBindings'
|
||||
).migrateDeprecatedValue?.(bindings) ?? []
|
||||
|
||||
expect(migrated[0].targetElementId).toBe('graph-canvas-container')
|
||||
expect(migrated[1].targetElementId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,225 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
trackNodePrice,
|
||||
usePartitionedBadges
|
||||
} from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
const { settings, nodeDefs, pricing, getNodeRevisionRefMock, getWidgetMock } =
|
||||
vi.hoisted(() => ({
|
||||
settings: {} as Record<string, unknown>,
|
||||
nodeDefs: {} as Record<string, unknown>,
|
||||
pricing: {
|
||||
dynamic: false,
|
||||
widgets: [] as string[],
|
||||
inputs: [] as string[],
|
||||
groups: [] as string[]
|
||||
},
|
||||
getNodeRevisionRefMock: vi.fn(() => ({ value: 0 })),
|
||||
getWidgetMock: vi.fn(() => ({ value: 'widget-value' }))
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: { graph: { getNodeById: () => null, rootGraph: { id: 'g1' } } }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodePricing', () => ({
|
||||
useNodePricing: () => ({
|
||||
getRelevantWidgetNames: () => pricing.widgets,
|
||||
hasDynamicPricing: () => pricing.dynamic,
|
||||
getInputGroupPrefixes: () => pricing.groups,
|
||||
getInputNames: () => pricing.inputs,
|
||||
getNodeRevisionRef: getNodeRevisionRefMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/usePriceBadge', () => ({
|
||||
usePriceBadge: () => ({
|
||||
isCreditsBadge: (b: { text?: string }) => b.text?.startsWith('$') ?? false
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: (key: string) => settings[key] })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({ nodeDefsByName: nodeDefs })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => ({
|
||||
useWidgetValueStore: () => ({ getWidget: getWidgetMock })
|
||||
}))
|
||||
|
||||
function nodeData(overrides: Partial<VueNodeData> = {}): VueNodeData {
|
||||
return {
|
||||
executing: false,
|
||||
id: toNodeId(1),
|
||||
mode: 0,
|
||||
selected: false,
|
||||
title: 'Test node',
|
||||
type: 'TestNode',
|
||||
apiNode: false,
|
||||
badges: [],
|
||||
inputs: [],
|
||||
...overrides
|
||||
} satisfies VueNodeData
|
||||
}
|
||||
|
||||
function inputSlot(
|
||||
name: string,
|
||||
readLink: () => number | null
|
||||
): INodeInputSlot {
|
||||
return {
|
||||
name,
|
||||
type: '*',
|
||||
boundingRect: [0, 0, 0, 0],
|
||||
get link() {
|
||||
return readLink()
|
||||
},
|
||||
set link(_value: number | null) {}
|
||||
} as INodeInputSlot
|
||||
}
|
||||
|
||||
function badge(text: string): LGraphBadge {
|
||||
return new LGraphBadge({ text })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.None
|
||||
for (const k of Object.keys(nodeDefs)) delete nodeDefs[k]
|
||||
nodeDefs['TestNode'] = { isCoreNode: false }
|
||||
pricing.dynamic = false
|
||||
pricing.widgets = []
|
||||
pricing.inputs = []
|
||||
pricing.groups = []
|
||||
getNodeRevisionRefMock.mockClear()
|
||||
getWidgetMock.mockClear()
|
||||
})
|
||||
|
||||
describe('usePartitionedBadges', () => {
|
||||
it('emits no core badges when every badge mode is None', () => {
|
||||
const result = usePartitionedBadges(nodeData()).value
|
||||
expect(result.core).toEqual([])
|
||||
})
|
||||
|
||||
it('tracks dynamic-pricing dependencies for an api node without throwing', () => {
|
||||
pricing.dynamic = true
|
||||
pricing.widgets = ['seed']
|
||||
pricing.inputs = ['model']
|
||||
pricing.groups = ['lora']
|
||||
const result = usePartitionedBadges(
|
||||
nodeData({
|
||||
apiNode: true,
|
||||
inputs: [
|
||||
inputSlot('model', () => 1),
|
||||
inputSlot('lora.0', () => 2),
|
||||
inputSlot('unrelated', () => null)
|
||||
]
|
||||
})
|
||||
).value
|
||||
|
||||
expect(result).toHaveProperty('core')
|
||||
expect(result).toHaveProperty('extension')
|
||||
})
|
||||
|
||||
it('adds an id badge when the id mode is enabled', () => {
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
const result = usePartitionedBadges(nodeData({ id: toNodeId(7) })).value
|
||||
expect(result.core).toContainEqual({ text: '#7' })
|
||||
})
|
||||
|
||||
it('adds a lifecycle badge, trimmed of brackets', () => {
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
nodeDefs['TestNode'] = {
|
||||
isCoreNode: false,
|
||||
nodeLifeCycleBadgeText: '[BETA]'
|
||||
}
|
||||
const result = usePartitionedBadges(nodeData()).value
|
||||
expect(result.core).toContainEqual({ text: 'BETA' })
|
||||
})
|
||||
|
||||
it('adds a source badge for non-core nodes when source mode is on', () => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
nodeDefs['TestNode'] = {
|
||||
isCoreNode: false,
|
||||
nodeSource: { badgeText: 'my-pack' }
|
||||
}
|
||||
const result = usePartitionedBadges(nodeData()).value
|
||||
expect(result.core).toContainEqual({ text: 'my-pack' })
|
||||
})
|
||||
|
||||
it('partitions extension badges (skipping the first) from credits badges', () => {
|
||||
const result = usePartitionedBadges(
|
||||
nodeData({
|
||||
badges: [badge('skipped'), badge('ext-badge'), badge('$5 per run')]
|
||||
})
|
||||
).value
|
||||
|
||||
expect(result.extension.map((badge) => badge.text)).toEqual(['ext-badge'])
|
||||
expect(result.pricing).toEqual([{ required: '$5', rest: 'per run' }])
|
||||
})
|
||||
|
||||
it('flags hasComfyBadge for a core node with source ShowAll and no pricing', () => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
nodeDefs['TestNode'] = { isCoreNode: true }
|
||||
const result = usePartitionedBadges(
|
||||
nodeData({ badges: [badge('x')] })
|
||||
).value
|
||||
expect(result.hasComfyBadge).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('trackNodePrice', () => {
|
||||
it('no-ops for a node without dynamic pricing', () => {
|
||||
pricing.dynamic = false
|
||||
trackNodePrice({ id: '1', type: 'Static', inputs: [] })
|
||||
|
||||
expect(getNodeRevisionRefMock).toHaveBeenCalledWith(toNodeId('1'))
|
||||
expect(getWidgetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('touches widget, input, and input-group pricing dependencies', () => {
|
||||
pricing.dynamic = true
|
||||
pricing.widgets = ['seed']
|
||||
pricing.inputs = ['model']
|
||||
pricing.groups = ['lora']
|
||||
let modelReads = 0
|
||||
let groupReads = 0
|
||||
let unrelatedReads = 0
|
||||
|
||||
trackNodePrice({
|
||||
id: '2',
|
||||
type: 'Dynamic',
|
||||
inputs: [
|
||||
inputSlot('model', () => {
|
||||
modelReads += 1
|
||||
return 1
|
||||
}),
|
||||
inputSlot('lora.0', () => {
|
||||
groupReads += 1
|
||||
return 2
|
||||
}),
|
||||
inputSlot('unrelated', () => {
|
||||
unrelatedReads += 1
|
||||
return null
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
expect(getNodeRevisionRefMock).toHaveBeenCalledWith(toNodeId('2'))
|
||||
expect(getWidgetMock).toHaveBeenCalled()
|
||||
expect(modelReads).toBe(1)
|
||||
expect(groupReads).toBe(1)
|
||||
expect(unrelatedReads).toBe(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user