mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 21:58:32 +00:00
Compare commits
6 Commits
synap5e/fi
...
codex/cove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6e9e087f7 | ||
|
|
d6c582c399 | ||
|
|
a6db1ab3d6 | ||
|
|
2ec2a0e091 | ||
|
|
9cf5c9a93f | ||
|
|
9e5fb67b76 |
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,3 +1,3 @@
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
|
||||
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 380 B After Width: | Height: | Size: 279 B |
@@ -15,7 +15,7 @@ const { categories } = defineProps<{
|
||||
|
||||
const activeSection = ref(categories[0]?.value ?? '')
|
||||
|
||||
const HEADER_OFFSET = -144
|
||||
const HEADER_OFFSET_PX = -144
|
||||
const BOTTOM_THRESHOLD_PX = 4
|
||||
const SCROLL_SAFETY_MS = 1500
|
||||
|
||||
@@ -52,7 +52,7 @@ function scrollToSection(id: string) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET,
|
||||
offset: HEADER_OFFSET_PX,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: clearScrollLock
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<li
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow before:content-['']"
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow"
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
|
||||
45
browser_tests/assets/linear-validation-warning.json
Normal file
45
browser_tests/assets/linear-validation-warning.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": {
|
||||
"0": 64,
|
||||
"1": 104
|
||||
},
|
||||
"size": {
|
||||
"0": 210,
|
||||
"1": 58
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"linearData": {
|
||||
"inputs": [],
|
||||
"outputs": ["9"]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -34,6 +34,10 @@ export class AppModeHelper {
|
||||
public readonly outputPlaceholder: Locator
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
public readonly linearWidgets: Locator
|
||||
/** The validation warning shown above the app mode run button. */
|
||||
public readonly validationWarning: Locator
|
||||
/** The action that opens graph mode errors from the validation warning. */
|
||||
public readonly viewErrorsInGraphButton: Locator
|
||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||
public readonly imagePickerPopover: Locator
|
||||
/** The Run button in the app mode footer. */
|
||||
@@ -92,13 +96,19 @@ export class AppModeHelper {
|
||||
this.outputPlaceholder = this.page.getByTestId(
|
||||
TestIds.builder.outputPlaceholder
|
||||
)
|
||||
this.linearWidgets = this.page.getByTestId('linear-widgets')
|
||||
this.linearWidgets = this.page.getByTestId(TestIds.linear.widgetContainer)
|
||||
this.validationWarning = this.page.getByTestId(
|
||||
TestIds.linear.validationWarning
|
||||
)
|
||||
this.viewErrorsInGraphButton = this.validationWarning.getByTestId(
|
||||
TestIds.linear.viewErrorsInGraph
|
||||
)
|
||||
this.imagePickerPopover = this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
this.runButton = this.page
|
||||
.getByTestId('linear-run-button')
|
||||
.getByTestId(TestIds.linear.runButton)
|
||||
.getByRole('button', { name: /run/i })
|
||||
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
|
||||
this.emptyWorkflowText = this.page.getByTestId(
|
||||
|
||||
@@ -172,6 +172,9 @@ export const TestIds = {
|
||||
mobileNavigation: 'linear-mobile-navigation',
|
||||
mobileWorkflows: 'linear-mobile-workflows',
|
||||
outputInfo: 'linear-output-info',
|
||||
runButton: 'linear-run-button',
|
||||
validationWarning: 'linear-validation-warning',
|
||||
viewErrorsInGraph: 'linear-view-errors',
|
||||
widgetContainer: 'linear-widgets'
|
||||
},
|
||||
builder: {
|
||||
|
||||
106
browser_tests/tests/appModeValidationWarning.spec.ts
Normal file
106
browser_tests/tests/appModeValidationWarning.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const SAVE_IMAGE_NODE_ID = '9'
|
||||
|
||||
function buildSaveImageRequiredInputError(): NodeError {
|
||||
return {
|
||||
class_type: 'SaveImage',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing: images',
|
||||
details: '',
|
||||
extra_info: { input_name: 'images' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'App mode validation warning',
|
||||
{ tag: ['@ui', '@workflow'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsOverlay(comfyPage)
|
||||
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens graph errors from the app mode validation warning', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(comfyPage.appMode.validationWarning).toBeHidden()
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
const appModeOverlay = comfyPage.appMode.centerPanel.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(appModeOverlay).toBeHidden()
|
||||
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.validationWarning).toContainText(
|
||||
/Required input missing/i
|
||||
)
|
||||
await expect(comfyPage.appMode.viewErrorsInGraphButton).toBeVisible()
|
||||
|
||||
await comfyPage.appMode.viewErrorsInGraphButton.click()
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps the app mode run button enabled when the warning is visible', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.runButton).toBeEnabled()
|
||||
|
||||
let promptQueued = false
|
||||
const mockResponse: PromptResponse = {
|
||||
prompt_id: 'test-id',
|
||||
node_errors: {},
|
||||
error: ''
|
||||
}
|
||||
await comfyPage.page.route(
|
||||
'**/api/prompt',
|
||||
async (route) => {
|
||||
promptQueued = true
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
},
|
||||
{ times: 1 }
|
||||
)
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
|
||||
await expect.poll(() => promptQueued).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -28,7 +28,12 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
// matches it against the members self-row.
|
||||
const SELF_EMAIL = 'e2e@test.comfy.org'
|
||||
|
||||
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
|
||||
// consolidated_billing_enabled routes personal workspaces to the unified
|
||||
// pricing table asserted here; without it they fall back to the legacy table.
|
||||
const BOOT_FEATURES = {
|
||||
team_workspaces_enabled: true,
|
||||
consolidated_billing_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
// Disable the experimental Asset API: with it on (cloud default) the unmocked
|
||||
// asset endpoints 403 and workflow restore throws uncaught, aborting the
|
||||
// GraphCanvas onMounted chain before the deep-link loader.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -15,9 +16,10 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.links.get(1)?.target_slot
|
||||
})
|
||||
comfyPage.page.evaluate(
|
||||
(linkId) => window.app!.graph!.links.get(linkId)?.target_slot,
|
||||
toLinkId(1)
|
||||
)
|
||||
)
|
||||
.toBe(1)
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Displays linear controls when app mode active', async ({
|
||||
@@ -16,7 +17,9 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Run button visible in linear mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.linear.runButton)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Workflow info section visible', async ({ comfyPage }) => {
|
||||
|
||||
75
src/base/common/async.test.ts
Normal file
75
src/base/common/async.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('runWhenGlobalIdle', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('falls back to a timeout when idle callbacks are unavailable', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('requestIdleCallback', undefined)
|
||||
vi.stubGlobal('cancelIdleCallback', undefined)
|
||||
const { runWhenGlobalIdle } = await import('./async')
|
||||
const runner = vi.fn()
|
||||
|
||||
const disposable = runWhenGlobalIdle(runner)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(runner).toHaveBeenCalledOnce()
|
||||
const deadline = runner.mock.calls[0][0]
|
||||
expect(deadline.didTimeout).toBe(true)
|
||||
expect(deadline.timeRemaining()).toBeGreaterThanOrEqual(0)
|
||||
|
||||
disposable.dispose()
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
it('cancels fallback idle work before it runs', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('requestIdleCallback', undefined)
|
||||
vi.stubGlobal('cancelIdleCallback', undefined)
|
||||
const { runWhenGlobalIdle } = await import('./async')
|
||||
const runner = vi.fn()
|
||||
|
||||
runWhenGlobalIdle(runner).dispose()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(runner).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses native idle callbacks when available', async () => {
|
||||
const requestIdleCallback = vi.fn(() => 42)
|
||||
const cancelIdleCallback = vi.fn()
|
||||
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
|
||||
vi.stubGlobal('cancelIdleCallback', cancelIdleCallback)
|
||||
const { runWhenGlobalIdle } = await import('./async')
|
||||
const runner = vi.fn()
|
||||
|
||||
const disposable = runWhenGlobalIdle(runner, 250)
|
||||
|
||||
expect(requestIdleCallback).toHaveBeenCalledWith(runner, { timeout: 250 })
|
||||
|
||||
disposable.dispose()
|
||||
disposable.dispose()
|
||||
|
||||
expect(cancelIdleCallback).toHaveBeenCalledOnce()
|
||||
expect(cancelIdleCallback).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('omits native idle timeout options when no timeout is supplied', async () => {
|
||||
const requestIdleCallback = vi.fn(() => 7)
|
||||
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
|
||||
vi.stubGlobal('cancelIdleCallback', vi.fn())
|
||||
const { runWhenGlobalIdle } = await import('./async')
|
||||
const runner = vi.fn()
|
||||
|
||||
runWhenGlobalIdle(runner)
|
||||
|
||||
expect(requestIdleCallback).toHaveBeenCalledWith(runner, undefined)
|
||||
})
|
||||
})
|
||||
@@ -122,6 +122,22 @@ describe('downloadUtil', () => {
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws for an empty URL', () => {
|
||||
expect(() => downloadFile('')).toThrow(
|
||||
'Invalid URL provided for download'
|
||||
)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws for a whitespace URL', () => {
|
||||
expect(() => downloadFile(' ')).toThrow(
|
||||
'Invalid URL provided for download'
|
||||
)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should prefer custom filename over extracted filename', () => {
|
||||
const testUrl =
|
||||
'https://example.com/api/file?filename=extracted-image.jpg'
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CREDITS_PER_USD,
|
||||
COMFY_CREDIT_RATE_CENTS,
|
||||
centsToCredits,
|
||||
clampUsd,
|
||||
creditsToCents,
|
||||
creditsToUsd,
|
||||
formatCredits,
|
||||
@@ -43,4 +44,21 @@ describe('comfyCredits helpers', () => {
|
||||
expect(formatCreditsFromUsd({ usd: 1, locale })).toBe('211.00')
|
||||
expect(formatUsd({ value: 4.2, locale })).toBe('4.20')
|
||||
})
|
||||
|
||||
test('formats with compatible fraction digit bounds', () => {
|
||||
expect(
|
||||
formatCredits({
|
||||
value: 12.345,
|
||||
locale: 'en-US',
|
||||
numberOptions: { minimumFractionDigits: 4, maximumFractionDigits: 2 }
|
||||
})
|
||||
).toBe('12.35')
|
||||
})
|
||||
|
||||
test('clamps USD purchase values into the supported range', () => {
|
||||
expect(clampUsd(Number.NaN)).toBe(0)
|
||||
expect(clampUsd(-5)).toBe(1)
|
||||
expect(clampUsd(42)).toBe(42)
|
||||
expect(clampUsd(5000)).toBe(1000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -158,8 +158,8 @@ import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
|
||||
@@ -178,7 +178,7 @@ const settingsDialog = useSettingsDialog()
|
||||
const telemetry = useTelemetry()
|
||||
const toast = useToast()
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
const { isSubscriptionEnabled } = useSubscription()
|
||||
// Constants
|
||||
@@ -260,9 +260,9 @@ async function handleBuy() {
|
||||
// Close top-up dialog (keep tracking) and open credits panel to show updated balance
|
||||
handleClose(false)
|
||||
|
||||
// In workspace mode (personal workspace), show workspace settings panel
|
||||
// Otherwise, show legacy subscription/credits panel
|
||||
const settingsPanel = flags.teamWorkspacesEnabled
|
||||
// On the consolidated (workspace) billing flow, show the workspace settings
|
||||
// panel; otherwise show the legacy subscription/credits panel.
|
||||
const settingsPanel = shouldUseWorkspaceBilling.value
|
||||
? 'workspace'
|
||||
: isSubscriptionEnabled()
|
||||
? 'subscription'
|
||||
|
||||
@@ -2,12 +2,11 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, onMounted, ref } from 'vue'
|
||||
import { defineComponent, nextTick, onMounted, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
|
||||
import type * as DistributionTypes from '@/platform/distribution/types'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import { EventType } from '@/services/customerEventsService'
|
||||
|
||||
@@ -35,19 +34,29 @@ vi.mock('@/services/customerEventsService', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
const mockTelemetry = vi.hoisted(() => ({
|
||||
checkForCompletedTopup: vi.fn()
|
||||
}))
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
useTelemetry: () => mockTelemetry
|
||||
}))
|
||||
|
||||
const mockFlags = vi.hoisted(() => ({ teamWorkspacesEnabled: false }))
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof DistributionTypes>()),
|
||||
isCloud: true
|
||||
const mockBillingRouting = vi.hoisted(() => ({
|
||||
shouldUseWorkspaceBilling: false
|
||||
}))
|
||||
vi.mock('@/composables/billing/useBillingRouting', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const shouldUseWorkspaceBilling = ref(false)
|
||||
Object.defineProperty(mockBillingRouting, 'shouldUseWorkspaceBilling', {
|
||||
get: () => shouldUseWorkspaceBilling.value,
|
||||
set: (value: boolean) => {
|
||||
shouldUseWorkspaceBilling.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
useBillingRouting: () => ({ shouldUseWorkspaceBilling })
|
||||
}
|
||||
})
|
||||
|
||||
const mockWorkspaceApi = vi.hoisted(() => ({
|
||||
getBillingEvents: vi.fn()
|
||||
@@ -68,7 +77,10 @@ const i18n = createI18n({
|
||||
additionalInfo: 'Additional Info',
|
||||
added: 'Added',
|
||||
accountInitialized: 'Account initialized',
|
||||
model: 'Model'
|
||||
model: 'Model',
|
||||
loadEventsError: 'Failed to load activity. Please try again.',
|
||||
loadEventsUnknownError:
|
||||
'Something went wrong while loading activity. Please refresh and try again.'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +107,11 @@ const AutoRefreshWrapper = defineComponent({
|
||||
template: '<UsageLogsTable ref="tableRef" />'
|
||||
})
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function makeEventsResponse(
|
||||
events: Partial<AuditLog>[],
|
||||
overrides: Record<string, unknown> = {}
|
||||
@@ -137,7 +154,7 @@ describe('UsageLogsTable', () => {
|
||||
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = false
|
||||
mockCustomerEventsService.formatEventType.mockImplementation(
|
||||
(type: string) => {
|
||||
switch (type) {
|
||||
@@ -228,7 +245,7 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error message when service throws', async () => {
|
||||
it('shows a localized fallback instead of a raw Error message', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockRejectedValue(
|
||||
new Error('Network error')
|
||||
)
|
||||
@@ -236,7 +253,25 @@ describe('UsageLogsTable', () => {
|
||||
renderWithAutoRefresh()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Something went wrong while loading activity. Please refresh and try again.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('Network error')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a localized fallback when the service reports no message', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(null)
|
||||
mockCustomerEventsService.error.value = null
|
||||
|
||||
renderWithAutoRefresh()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Failed to load activity. Please try again.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -341,8 +376,8 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
|
||||
describe('billing events source', () => {
|
||||
it('uses workspaceApi.getBillingEvents when teamWorkspacesEnabled is on', async () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
it('uses workspaceApi.getBillingEvents on the workspace billing flow', async () => {
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = true
|
||||
|
||||
await renderLoaded()
|
||||
|
||||
@@ -352,6 +387,90 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
expect(mockCustomerEventsService.getMyEvents).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discards a stale legacy response when routing flips mid-fetch', async () => {
|
||||
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
|
||||
mockCustomerEventsService.getMyEvents.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveLegacy = resolve
|
||||
})
|
||||
)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'workspace-1',
|
||||
event_type: EventType.API_USAGE_COMPLETED,
|
||||
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
|
||||
createdAt: '2024-02-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
renderWithAutoRefresh()
|
||||
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = true
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
resolveLegacy(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'legacy-1',
|
||||
event_type: EventType.API_USAGE_COMPLETED,
|
||||
params: { api_name: 'LegacyAPI', model: 'legacy-model' },
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
||||
expect(screen.queryByText('LegacyAPI')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('runs top-up completion telemetry for a superseded response', async () => {
|
||||
let resolveLegacy!: (value: ReturnType<typeof makeEventsResponse>) => void
|
||||
mockCustomerEventsService.getMyEvents.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveLegacy = resolve
|
||||
})
|
||||
)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'workspace-1',
|
||||
event_type: EventType.API_USAGE_COMPLETED,
|
||||
params: { api_name: 'WorkspaceAPI', model: 'workspace-model' },
|
||||
createdAt: '2024-02-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
renderWithAutoRefresh()
|
||||
|
||||
mockBillingRouting.shouldUseWorkspaceBilling = true
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WorkspaceAPI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const legacyResponse = makeEventsResponse([
|
||||
{
|
||||
event_id: 'legacy-1',
|
||||
event_type: EventType.CREDIT_ADDED,
|
||||
params: { amount: 1000 },
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
resolveLegacy(legacyResponse)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTelemetry.checkForCompletedTopup).toHaveBeenCalledWith(
|
||||
legacyResponse.events
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventType integration', () => {
|
||||
|
||||
@@ -96,11 +96,11 @@ import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Message from 'primevue/message'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
@@ -109,14 +109,15 @@ import {
|
||||
useCustomerEventsService
|
||||
} from '@/services/customerEventsService'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const events = ref<AuditLog[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const customerEventService = useCustomerEventsService()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const useBillingApi = computed(() => isCloud && flags.teamWorkspacesEnabled)
|
||||
const { shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
@@ -139,7 +140,12 @@ const tooltipContentMap = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
// A billing-route flip can overlap two loads against different backends; only
|
||||
// the latest may mutate state, so a superseded response is discarded.
|
||||
let latestLoadToken = 0
|
||||
|
||||
const loadEvents = async () => {
|
||||
const loadToken = ++latestLoadToken
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
@@ -148,10 +154,17 @@ const loadEvents = async () => {
|
||||
page: pagination.value.page,
|
||||
limit: pagination.value.limit
|
||||
}
|
||||
const response = useBillingApi.value
|
||||
const response = shouldUseWorkspaceBilling.value
|
||||
? await workspaceApi.getBillingEvents(params)
|
||||
: await customerEventService.getMyEvents(params)
|
||||
|
||||
// Completion telemetry must run even when a mid-checkout route flip
|
||||
// supersedes this load, since legacy and workspace backends emit different
|
||||
// top-up events and the winning fetch may not carry the completion yet.
|
||||
useTelemetry()?.checkForCompletedTopup(response?.events)
|
||||
|
||||
if (loadToken !== latestLoadToken) return
|
||||
|
||||
if (response) {
|
||||
if (response.events) {
|
||||
events.value = response.events
|
||||
@@ -165,24 +178,25 @@ const loadEvents = async () => {
|
||||
pagination.value.limit = response.limit
|
||||
}
|
||||
|
||||
if (response.total) {
|
||||
if (response.total != null) {
|
||||
pagination.value.total = response.total
|
||||
}
|
||||
|
||||
if (response.totalPages) {
|
||||
if (response.totalPages != null) {
|
||||
pagination.value.totalPages = response.totalPages
|
||||
}
|
||||
|
||||
// Check if a pending top-up has completed
|
||||
useTelemetry()?.checkForCompletedTopup(response.events)
|
||||
} else {
|
||||
error.value = customerEventService.error.value || 'Failed to load events'
|
||||
const legacyError = shouldUseWorkspaceBilling.value
|
||||
? null
|
||||
: customerEventService.error.value
|
||||
error.value = legacyError || t('credits.loadEventsError')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error'
|
||||
if (loadToken !== latestLoadToken) return
|
||||
error.value = t('credits.loadEventsUnknownError')
|
||||
console.error('Error loading events:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (loadToken === latestLoadToken) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +212,12 @@ const refresh = async () => {
|
||||
await loadEvents()
|
||||
}
|
||||
|
||||
watch(shouldUseWorkspaceBilling, () => {
|
||||
refresh().catch((error) => {
|
||||
console.error('Error loading events:', error)
|
||||
})
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
refresh
|
||||
})
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
size="unset"
|
||||
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
|
||||
data-testid="error-overlay-see-errors"
|
||||
@click="seeErrors"
|
||||
@click="viewErrorsInGraph"
|
||||
>
|
||||
{{
|
||||
appMode
|
||||
@@ -67,31 +67,18 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
|
||||
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
|
||||
|
||||
const { appMode = false } = defineProps<{ appMode?: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { viewErrorsInGraph } = useViewErrorsInGraph()
|
||||
|
||||
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
|
||||
|
||||
function dismiss() {
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -224,7 +224,7 @@ const handleOpenUserSettings = () => {
|
||||
}
|
||||
|
||||
const handleOpenPlansAndPricing = () => {
|
||||
subscriptionDialog.showPricingTable()
|
||||
subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' })
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -239,8 +239,7 @@ const handleOpenPlanAndCreditsSettings = () => {
|
||||
}
|
||||
|
||||
const handleTopUp = () => {
|
||||
// Track purchase credits entry from avatar popover
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
emit('close')
|
||||
}
|
||||
@@ -254,7 +253,7 @@ const handleOpenPartnerNodesInfo = () => {
|
||||
}
|
||||
|
||||
const handleUpgradeToAddCredits = () => {
|
||||
subscriptionDialog.showPricingTable()
|
||||
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
|
||||
emit('close')
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,6 @@ const { isFreeTier } = useBillingContext()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
function handleClick() {
|
||||
subscriptionDialog.showPricingTable()
|
||||
subscriptionDialog.showPricingTable({ reason: 'subscribe_now_button' })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -8,7 +8,16 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
|
||||
type ModifiedWorkflow = Pick<ComfyWorkflow, 'path' | 'isModified'>
|
||||
|
||||
const mockAuthStore = vi.hoisted(() => ({
|
||||
logout: vi.fn().mockResolvedValue(undefined)
|
||||
logout: vi.fn().mockResolvedValue(undefined),
|
||||
sendPasswordReset: vi.fn().mockResolvedValue(undefined),
|
||||
initiateCreditPurchase: vi.fn(),
|
||||
accessBillingPortal: vi.fn(),
|
||||
fetchBalance: vi.fn(),
|
||||
loginWithGoogle: vi.fn(),
|
||||
loginWithGithub: vi.fn(),
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
updatePassword: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
const mockToastStore = vi.hoisted(() => ({
|
||||
@@ -29,6 +38,16 @@ const mockDialogService = vi.hoisted(() => ({
|
||||
|
||||
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockBillingContext = vi.hoisted(() => ({
|
||||
isActiveSubscription: { value: false },
|
||||
isFreeTier: { value: true },
|
||||
type: { value: 'free' }
|
||||
}))
|
||||
|
||||
const mockTelemetry = vi.hoisted(() => ({
|
||||
startTopupTracking: vi.fn()
|
||||
}))
|
||||
|
||||
const knownAuthErrorCodes = new Set([
|
||||
'auth/invalid-credential',
|
||||
'auth/email-already-in-use'
|
||||
@@ -48,7 +67,7 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => undefined)
|
||||
useTelemetry: vi.fn(() => mockTelemetry)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
@@ -72,11 +91,7 @@ vi.mock('@/stores/authStore', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
isActiveSubscription: { value: false },
|
||||
isFreeTier: { value: true },
|
||||
type: { value: 'free' }
|
||||
}))
|
||||
useBillingContext: vi.fn(() => mockBillingContext)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
@@ -97,6 +112,7 @@ describe('useAuthActions.logout', () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockWorkflowStore.modifiedWorkflows = []
|
||||
mockBillingContext.isActiveSubscription.value = false
|
||||
})
|
||||
|
||||
it('logs out without prompting when no workflows are modified', async () => {
|
||||
@@ -281,4 +297,158 @@ describe('useAuthActions.reportError', () => {
|
||||
expect(mockToastErrorHandler).toHaveBeenCalledWith(networkError)
|
||||
expect(mockToastStore.add).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the unauthorized-domain access error message', () => {
|
||||
const { reportError, accessError } = useAuthActions()
|
||||
|
||||
reportError(new FirebaseError('auth/unauthorized-domain', 'blocked'))
|
||||
|
||||
expect(accessError.value).toBe(true)
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'toastMessages.unauthorizedDomain'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAuthActions account actions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockBillingContext.isActiveSubscription.value = false
|
||||
vi.stubGlobal(
|
||||
'open',
|
||||
vi.fn(() => ({}))
|
||||
)
|
||||
})
|
||||
|
||||
it('sends password reset emails and shows success toast', async () => {
|
||||
const { sendPasswordReset } = useAuthActions()
|
||||
|
||||
await sendPasswordReset('user@example.com')
|
||||
|
||||
expect(mockAuthStore.sendPasswordReset).toHaveBeenCalledWith(
|
||||
'user@example.com'
|
||||
)
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'success',
|
||||
summary: 'auth.login.passwordResetSent'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not purchase credits without an active subscription', async () => {
|
||||
const { purchaseCredits } = useAuthActions()
|
||||
|
||||
await purchaseCredits(25)
|
||||
|
||||
expect(mockAuthStore.initiateCreditPurchase).not.toHaveBeenCalled()
|
||||
expect(window.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens checkout and tracks top-up starts for credit purchases', async () => {
|
||||
mockBillingContext.isActiveSubscription.value = true
|
||||
mockAuthStore.initiateCreditPurchase.mockResolvedValueOnce({
|
||||
checkout_url: 'https://checkout.example.test'
|
||||
})
|
||||
const { purchaseCredits } = useAuthActions()
|
||||
|
||||
await purchaseCredits(25)
|
||||
|
||||
expect(mockAuthStore.initiateCreditPurchase).toHaveBeenCalledWith({
|
||||
amount_micros: 25000000,
|
||||
currency: 'usd'
|
||||
})
|
||||
expect(mockTelemetry.startTopupTracking).toHaveBeenCalledOnce()
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://checkout.example.test',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when credit checkout URL is missing', async () => {
|
||||
mockBillingContext.isActiveSubscription.value = true
|
||||
mockAuthStore.initiateCreditPurchase.mockResolvedValueOnce({})
|
||||
const { purchaseCredits } = useAuthActions()
|
||||
|
||||
await expect(purchaseCredits(10)).rejects.toThrow(
|
||||
'toastMessages.failedToPurchaseCredits'
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the billing portal in a new tab by default', async () => {
|
||||
mockAuthStore.accessBillingPortal.mockResolvedValueOnce({
|
||||
billing_portal_url: 'https://billing.example.test'
|
||||
})
|
||||
const { accessBillingPortal } = useAuthActions()
|
||||
|
||||
await expect(accessBillingPortal('pro')).resolves.toBe(true)
|
||||
|
||||
expect(mockAuthStore.accessBillingPortal).toHaveBeenCalledWith('pro')
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://billing.example.test',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when billing portal URL is missing', async () => {
|
||||
mockAuthStore.accessBillingPortal.mockResolvedValueOnce({})
|
||||
const { accessBillingPortal } = useAuthActions()
|
||||
|
||||
await expect(accessBillingPortal()).rejects.toThrow(
|
||||
'toastMessages.failedToAccessBillingPortal'
|
||||
)
|
||||
})
|
||||
|
||||
it('delegates balance and sign-in methods to the auth store', async () => {
|
||||
mockAuthStore.fetchBalance.mockResolvedValueOnce({ balance: 12 })
|
||||
mockAuthStore.loginWithGoogle.mockResolvedValueOnce('google')
|
||||
mockAuthStore.loginWithGithub.mockResolvedValueOnce('github')
|
||||
mockAuthStore.login.mockResolvedValueOnce('email')
|
||||
mockAuthStore.register.mockResolvedValueOnce('registered')
|
||||
const actions = useAuthActions()
|
||||
|
||||
await expect(actions.fetchBalance()).resolves.toEqual({ balance: 12 })
|
||||
await expect(actions.signInWithGoogle({ isNewUser: true })).resolves.toBe(
|
||||
'google'
|
||||
)
|
||||
await expect(actions.signInWithGithub({ isNewUser: false })).resolves.toBe(
|
||||
'github'
|
||||
)
|
||||
await expect(actions.signInWithEmail('u@example.com', 'pw')).resolves.toBe(
|
||||
'email'
|
||||
)
|
||||
await expect(
|
||||
actions.signUpWithEmail('u@example.com', 'pw', 'turnstile')
|
||||
).resolves.toBe('registered')
|
||||
|
||||
expect(mockAuthStore.loginWithGoogle).toHaveBeenCalledWith({
|
||||
isNewUser: true
|
||||
})
|
||||
expect(mockAuthStore.loginWithGithub).toHaveBeenCalledWith({
|
||||
isNewUser: false
|
||||
})
|
||||
expect(mockAuthStore.login).toHaveBeenCalledWith('u@example.com', 'pw')
|
||||
expect(mockAuthStore.register).toHaveBeenCalledWith(
|
||||
'u@example.com',
|
||||
'pw',
|
||||
'turnstile'
|
||||
)
|
||||
})
|
||||
|
||||
it('updates passwords and shows success toast', async () => {
|
||||
const { updatePassword } = useAuthActions()
|
||||
|
||||
await updatePassword('new-password')
|
||||
|
||||
expect(mockAuthStore.updatePassword).toHaveBeenCalledWith('new-password')
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'success',
|
||||
summary: 'auth.passwordUpdate.success'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
213
src/composables/auth/useCurrentUser.test.ts
Normal file
213
src/composables/auth/useCurrentUser.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
|
||||
import type { User as FirebaseUser } from 'firebase/auth'
|
||||
|
||||
import type { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
|
||||
type FirebaseUserMock = Pick<
|
||||
FirebaseUser,
|
||||
'uid' | 'displayName' | 'email' | 'photoURL'
|
||||
> & {
|
||||
providerData: Array<Pick<FirebaseUser['providerData'][number], 'providerId'>>
|
||||
}
|
||||
|
||||
type ApiKeyUser = NonNullable<
|
||||
ReturnType<typeof useApiKeyAuthStore>['currentUser']
|
||||
>
|
||||
|
||||
const mockStores = vi.hoisted(() => ({
|
||||
authStore: undefined as
|
||||
| undefined
|
||||
| {
|
||||
currentUser: FirebaseUserMock | null
|
||||
loading: boolean
|
||||
tokenRefreshTrigger: number
|
||||
},
|
||||
apiKeyStore: undefined as
|
||||
| undefined
|
||||
| {
|
||||
isAuthenticated: boolean
|
||||
currentUser: ApiKeyUser | null
|
||||
clearStoredApiKey: ReturnType<typeof vi.fn>
|
||||
},
|
||||
commandStore: undefined as
|
||||
| undefined
|
||||
| {
|
||||
execute: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => mockStores.authStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/apiKeyAuthStore', () => ({
|
||||
useApiKeyAuthStore: () => mockStores.apiKeyStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => mockStores.commandStore
|
||||
}))
|
||||
|
||||
async function setup() {
|
||||
vi.resetModules()
|
||||
const authStore = reactive({
|
||||
currentUser: null as FirebaseUserMock | null,
|
||||
loading: false,
|
||||
tokenRefreshTrigger: 0
|
||||
})
|
||||
const apiKeyStore = reactive({
|
||||
isAuthenticated: false,
|
||||
currentUser: null as ApiKeyUser | null,
|
||||
clearStoredApiKey: vi.fn()
|
||||
})
|
||||
const commandStore = {
|
||||
execute: vi.fn()
|
||||
}
|
||||
|
||||
mockStores.authStore = authStore
|
||||
mockStores.apiKeyStore = apiKeyStore
|
||||
mockStores.commandStore = commandStore
|
||||
|
||||
const { useCurrentUser } = await import('./useCurrentUser')
|
||||
return {
|
||||
currentUser: useCurrentUser(),
|
||||
authStore,
|
||||
apiKeyStore,
|
||||
commandStore
|
||||
}
|
||||
}
|
||||
|
||||
function firebaseUser(
|
||||
providerId: string,
|
||||
overrides: Partial<FirebaseUserMock> = {}
|
||||
): FirebaseUserMock {
|
||||
return {
|
||||
uid: 'firebase-user',
|
||||
displayName: 'Firebase User',
|
||||
email: 'firebase@example.com',
|
||||
photoURL: 'https://example.com/photo.png',
|
||||
providerData: [{ providerId }],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useCurrentUser', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reports logged-out state when no auth source is active', async () => {
|
||||
const { currentUser } = await setup()
|
||||
|
||||
expect(currentUser.loading).toBe(false)
|
||||
expect(currentUser.isLoggedIn.value).toBe(false)
|
||||
expect(currentUser.resolvedUserInfo.value).toBeNull()
|
||||
expect(currentUser.userDisplayName.value).toBeUndefined()
|
||||
expect(currentUser.userEmail.value).toBeUndefined()
|
||||
expect(currentUser.userPhotoUrl.value).toBeUndefined()
|
||||
expect(currentUser.providerName.value).toBeUndefined()
|
||||
expect(currentUser.providerIcon.value).toBe('pi pi-user')
|
||||
expect(currentUser.isEmailProvider.value).toBe(false)
|
||||
})
|
||||
|
||||
it('uses API key user identity before firebase identity', async () => {
|
||||
const { currentUser, authStore, apiKeyStore } = await setup()
|
||||
authStore.currentUser = firebaseUser('google.com')
|
||||
apiKeyStore.isAuthenticated = true
|
||||
apiKeyStore.currentUser = {
|
||||
id: 'api-user',
|
||||
name: 'API User',
|
||||
email: 'api@example.com'
|
||||
}
|
||||
|
||||
expect(currentUser.isLoggedIn.value).toBe(true)
|
||||
expect(currentUser.isApiKeyLogin.value).toBe(true)
|
||||
expect(currentUser.resolvedUserInfo.value).toEqual({ id: 'api-user' })
|
||||
expect(currentUser.userDisplayName.value).toBe('API User')
|
||||
expect(currentUser.userEmail.value).toBe('api@example.com')
|
||||
expect(currentUser.userPhotoUrl.value).toBeNull()
|
||||
expect(currentUser.providerName.value).toBe('Comfy API Key')
|
||||
expect(currentUser.providerIcon.value).toBe('pi pi-key')
|
||||
expect(currentUser.isEmailProvider.value).toBe(false)
|
||||
})
|
||||
|
||||
it('maps firebase provider metadata to display fields', async () => {
|
||||
const { currentUser, authStore } = await setup()
|
||||
|
||||
authStore.currentUser = firebaseUser('google.com')
|
||||
expect(currentUser.providerName.value).toBe('Google')
|
||||
expect(currentUser.providerIcon.value).toBe('pi pi-google')
|
||||
expect(currentUser.userDisplayName.value).toBe('Firebase User')
|
||||
expect(currentUser.userEmail.value).toBe('firebase@example.com')
|
||||
expect(currentUser.userPhotoUrl.value).toBe('https://example.com/photo.png')
|
||||
expect(currentUser.resolvedUserInfo.value).toEqual({ id: 'firebase-user' })
|
||||
|
||||
authStore.currentUser = firebaseUser('github.com')
|
||||
expect(currentUser.providerName.value).toBe('GitHub')
|
||||
expect(currentUser.providerIcon.value).toBe('pi pi-github')
|
||||
|
||||
authStore.currentUser = firebaseUser('password')
|
||||
expect(currentUser.providerName.value).toBe('password')
|
||||
expect(currentUser.providerIcon.value).toBe('pi pi-user')
|
||||
expect(currentUser.isEmailProvider.value).toBe(true)
|
||||
})
|
||||
|
||||
it('routes sign out through the active auth source', async () => {
|
||||
const { currentUser, apiKeyStore, commandStore } = await setup()
|
||||
|
||||
apiKeyStore.isAuthenticated = true
|
||||
apiKeyStore.currentUser = { id: 'api-user' }
|
||||
await currentUser.handleSignOut()
|
||||
expect(apiKeyStore.clearStoredApiKey).toHaveBeenCalledOnce()
|
||||
|
||||
apiKeyStore.isAuthenticated = false
|
||||
await currentUser.handleSignOut()
|
||||
expect(commandStore.execute).toHaveBeenCalledWith('Comfy.User.SignOut')
|
||||
})
|
||||
|
||||
it('opens the sign-in dialog through the command store', async () => {
|
||||
const { currentUser, commandStore } = await setup()
|
||||
|
||||
await currentUser.handleSignIn()
|
||||
|
||||
expect(commandStore.execute).toHaveBeenCalledWith(
|
||||
'Comfy.User.OpenSignInDialog'
|
||||
)
|
||||
})
|
||||
|
||||
it('runs user lifecycle callbacks for resolve, token refresh, and logout', async () => {
|
||||
const { currentUser, authStore } = await setup()
|
||||
const resolved = vi.fn()
|
||||
const tokenRefreshed = vi.fn()
|
||||
const logout = vi.fn()
|
||||
|
||||
currentUser.onUserResolved(resolved)
|
||||
currentUser.onTokenRefreshed(tokenRefreshed)
|
||||
currentUser.onUserLogout(logout)
|
||||
|
||||
authStore.currentUser = firebaseUser('google.com')
|
||||
await nextTick()
|
||||
expect(resolved.mock.calls[0][0]).toEqual({ id: 'firebase-user' })
|
||||
|
||||
authStore.tokenRefreshTrigger += 1
|
||||
await nextTick()
|
||||
expect(tokenRefreshed).toHaveBeenCalledOnce()
|
||||
|
||||
authStore.currentUser = null
|
||||
await nextTick()
|
||||
expect(logout).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('runs onUserResolved immediately when a user already exists', async () => {
|
||||
const { currentUser, apiKeyStore } = await setup()
|
||||
apiKeyStore.isAuthenticated = true
|
||||
apiKeyStore.currentUser = { id: 'api-user' }
|
||||
const resolved = vi.fn()
|
||||
|
||||
currentUser.onUserResolved(resolved)
|
||||
|
||||
expect(resolved.mock.calls[0][0]).toEqual({ id: 'api-user' })
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type {
|
||||
BillingStatus,
|
||||
@@ -75,9 +76,10 @@ export interface BillingActions {
|
||||
*/
|
||||
requireActiveSubscription: () => Promise<void>
|
||||
/**
|
||||
* Shows the subscription dialog.
|
||||
* Shows the subscription dialog. Pass a reason so the paywall open and any
|
||||
* downstream checkout stay attributed to the triggering product moment.
|
||||
*/
|
||||
showSubscriptionDialog: () => void
|
||||
showSubscriptionDialog: (options?: SubscriptionDialogOptions) => void
|
||||
}
|
||||
|
||||
export interface BillingState {
|
||||
|
||||
@@ -19,6 +19,7 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
|
||||
|
||||
const {
|
||||
mockTeamWorkspacesEnabled,
|
||||
mockConsolidatedBillingEnabled,
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits,
|
||||
@@ -26,6 +27,7 @@ const {
|
||||
mockBillingStatus
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockConsolidatedBillingEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn(),
|
||||
@@ -57,11 +59,23 @@ vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
teamWorkspacesEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
const consolidatedBillingEnabledRef = ref(
|
||||
mockConsolidatedBillingEnabled.value
|
||||
)
|
||||
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
|
||||
get: () => consolidatedBillingEnabledRef.value,
|
||||
set: (value: boolean) => {
|
||||
consolidatedBillingEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
useFeatureFlags: () => ({
|
||||
flags: {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
},
|
||||
get consolidatedBillingEnabled() {
|
||||
return mockConsolidatedBillingEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -151,6 +165,7 @@ describe('useBillingContext', () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
mockPlans.value = []
|
||||
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
|
||||
@@ -162,16 +177,27 @@ describe('useBillingContext', () => {
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('selects workspace type for personal when team workspaces are enabled', () => {
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('selects workspace type for personal when consolidated billing is enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('workspace')
|
||||
})
|
||||
|
||||
it('selects workspace type for team when team workspaces are enabled', () => {
|
||||
it('selects workspace type for team regardless of consolidated billing', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { type } = useBillingContext()
|
||||
@@ -272,6 +298,7 @@ describe('useBillingContext', () => {
|
||||
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
|
||||
// Authenticated remote config resolves the flag on for the same workspace
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -280,9 +307,27 @@ describe('useBillingContext', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
await nextTick()
|
||||
expect(type.value).toBe('legacy')
|
||||
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(workspaceApi.getBillingStatus).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscription mirror to workspace store', () => {
|
||||
it('mirrors subscription for personal workspaces when team workspaces are enabled', async () => {
|
||||
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
@@ -294,6 +339,20 @@ describe('useBillingContext', () => {
|
||||
subscriptionPlan: null
|
||||
})
|
||||
})
|
||||
|
||||
it('never clobbers the list-derived store when a subscription is absent', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
await initialize()
|
||||
await nextTick()
|
||||
|
||||
expect(mockUpdateActiveWorkspace).not.toHaveBeenCalledWith({
|
||||
isSubscribed: false,
|
||||
subscriptionPlan: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMaxSeats', () => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { computed, ref, shallowRef, toValue, watch } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import {
|
||||
KEY_TO_TIER,
|
||||
getTierFeatures
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
PreviewSubscribeOptions,
|
||||
SubscribeOptions
|
||||
@@ -17,10 +17,10 @@ import type {
|
||||
BalanceInfo,
|
||||
BillingActions,
|
||||
BillingContext,
|
||||
BillingType,
|
||||
BillingState,
|
||||
SubscriptionInfo
|
||||
} from './types'
|
||||
import { useBillingRouting } from './useBillingRouting'
|
||||
import { useLegacyBilling } from './useLegacyBilling'
|
||||
import { useWorkspaceBilling } from '@/platform/workspace/composables/useWorkspaceBilling'
|
||||
|
||||
@@ -34,8 +34,9 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
* Unified billing context that selects the billing implementation by build/flag.
|
||||
*
|
||||
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
|
||||
* - Team workspaces enabled: workspace billing via /api/billing/* for both
|
||||
* personal (single-seat workspace) and team workspaces
|
||||
* - Team workspaces enabled: workspace billing via /api/billing/* for team
|
||||
* workspaces, and for personal workspaces once consolidated billing is
|
||||
* enabled; personal workspaces otherwise stay on legacy billing
|
||||
*
|
||||
* The context automatically initializes when the workspace changes and provides
|
||||
* a unified interface for subscription status, balance, and billing actions.
|
||||
@@ -68,7 +69,7 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
*/
|
||||
function useBillingContextInternal(): BillingContext {
|
||||
const store = useTeamWorkspaceStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { type } = useBillingRouting()
|
||||
|
||||
const legacyBillingRef = shallowRef<(BillingState & BillingActions) | null>(
|
||||
null
|
||||
@@ -95,16 +96,6 @@ function useBillingContextInternal(): BillingContext {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Determines which billing type to use, keyed only on the build/flag:
|
||||
* - Team workspaces feature disabled (OSS/Desktop): legacy (/customers)
|
||||
* - Team workspaces feature enabled: workspace (/api/billing), for both
|
||||
* personal (single-seat workspace) and team workspaces
|
||||
*/
|
||||
const type = computed<BillingType>(() =>
|
||||
flags.teamWorkspacesEnabled ? 'workspace' : 'legacy'
|
||||
)
|
||||
|
||||
const activeContext = computed(() =>
|
||||
type.value === 'legacy' ? getLegacyBilling() : getWorkspaceBilling()
|
||||
)
|
||||
@@ -169,9 +160,12 @@ function useBillingContextInternal(): BillingContext {
|
||||
return plan?.max_seats ?? getTierFeatures(tierKey).maxMembers
|
||||
}
|
||||
|
||||
// Sync subscription info to workspace store for display in workspace switcher
|
||||
// A subscription is considered "subscribed" for workspace purposes if it's active AND not cancelled
|
||||
// This ensures the delete button is enabled after cancellation, even before the period ends
|
||||
// Sync subscription info to workspace store for display in workspace switcher.
|
||||
// Subscribed means active AND not cancelled, so the delete button enables
|
||||
// after cancellation, even before the period ends. A null subscription means
|
||||
// "not loaded yet" (adapters are discarded on every workspace/type switch);
|
||||
// skip it so the transient reinit gap can't clobber the list-derived baseline
|
||||
// (personal workspaces and subscribed teams already read subscribed there).
|
||||
watch(
|
||||
subscription,
|
||||
(sub) => {
|
||||
@@ -185,24 +179,27 @@ function useBillingContextInternal(): BillingContext {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Discarding the adapter instances forces a fresh fetch and lets an in-flight
|
||||
// init detect that it was superseded (its captured adapter is no longer the
|
||||
// active one), so a stale response can't resolve into a ready state for the
|
||||
// wrong workspace.
|
||||
function resetBillingState() {
|
||||
legacyBillingRef.value = null
|
||||
workspaceBillingRef.value = null
|
||||
isInitialized.value = false
|
||||
isLoading.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// type can flip after setup when the team-workspaces flag resolves from
|
||||
// authenticated config, swapping the active backend; a fresh init is needed.
|
||||
// The watch fires only when id or type actually changes, so any fire with a
|
||||
// workspace selected warrants a reinit.
|
||||
// type flips when the team-workspaces or consolidated-billing flag resolves
|
||||
// from authenticated config, swapping the active backend. Reset then reinit
|
||||
// on every workspace-id or type change.
|
||||
watch(
|
||||
[() => store.activeWorkspace?.id, () => type.value],
|
||||
async ([newWorkspaceId]) => {
|
||||
if (!newWorkspaceId) {
|
||||
resetBillingState()
|
||||
return
|
||||
}
|
||||
resetBillingState()
|
||||
if (!newWorkspaceId) return
|
||||
|
||||
isInitialized.value = false
|
||||
try {
|
||||
await initialize()
|
||||
} catch (err) {
|
||||
@@ -215,17 +212,20 @@ function useBillingContextInternal(): BillingContext {
|
||||
async function initialize(): Promise<void> {
|
||||
if (isInitialized.value) return
|
||||
|
||||
const adapter = activeContext.value
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await activeContext.value.initialize()
|
||||
await adapter.initialize()
|
||||
if (activeContext.value !== adapter) return
|
||||
isInitialized.value = true
|
||||
} catch (err) {
|
||||
if (activeContext.value !== adapter) return
|
||||
error.value =
|
||||
err instanceof Error ? err.message : 'Failed to initialize billing'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
if (activeContext.value === adapter) isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,8 +281,8 @@ function useBillingContextInternal(): BillingContext {
|
||||
return activeContext.value.requireActiveSubscription()
|
||||
}
|
||||
|
||||
function showSubscriptionDialog() {
|
||||
return activeContext.value.showSubscriptionDialog()
|
||||
function showSubscriptionDialog(options?: SubscriptionDialogOptions) {
|
||||
return activeContext.value.showSubscriptionDialog(options)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
99
src/composables/billing/useBillingRouting.test.ts
Normal file
99
src/composables/billing/useBillingRouting.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useBillingRouting } from './useBillingRouting'
|
||||
|
||||
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
|
||||
mockFlags: {
|
||||
teamWorkspacesEnabled: false,
|
||||
consolidatedBillingEnabled: false
|
||||
},
|
||||
mockActiveWorkspace: {
|
||||
value: null as { id: string; type: 'personal' | 'team' } | null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => ({
|
||||
get activeWorkspace() {
|
||||
return mockActiveWorkspace.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const personal = { id: 'w-personal', type: 'personal' as const }
|
||||
const team = { id: 'w-team', type: 'team' as const }
|
||||
|
||||
describe('useBillingRouting', () => {
|
||||
beforeEach(() => {
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
})
|
||||
|
||||
it('uses legacy billing when team workspaces are disabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('legacy')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('moves personal to workspace billing when consolidated billing is enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('workspace')
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to legacy while the workspace has not loaded', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockActiveWorkspace.value = null
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
})
|
||||
36
src/composables/billing/useBillingRouting.ts
Normal file
36
src/composables/billing/useBillingRouting.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import type { BillingType } from './types'
|
||||
|
||||
/**
|
||||
* Selects the billing backend for the active workspace: legacy user-scoped
|
||||
* (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces
|
||||
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
|
||||
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
|
||||
*/
|
||||
export function useBillingRouting() {
|
||||
const { flags } = useFeatureFlags()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
|
||||
const type = computed<BillingType>(() => {
|
||||
if (!flags.teamWorkspacesEnabled) return 'legacy'
|
||||
|
||||
// An unloaded workspace has no type yet; stay legacy so bootstrap never
|
||||
// eagerly routes to workspace billing.
|
||||
const workspaceType = workspaceStore.activeWorkspace?.type
|
||||
if (!workspaceType) return 'legacy'
|
||||
|
||||
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
|
||||
return 'legacy'
|
||||
}
|
||||
|
||||
return 'workspace'
|
||||
})
|
||||
|
||||
const shouldUseWorkspaceBilling = computed(() => type.value === 'workspace')
|
||||
|
||||
return { type, shouldUseWorkspaceBilling }
|
||||
}
|
||||
256
src/composables/billing/useLegacyBilling.test.ts
Normal file
256
src/composables/billing/useLegacyBilling.test.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useLegacyBilling } from './useLegacyBilling'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isActiveSubscription: { value: false },
|
||||
subscriptionTier: { value: null as string | null },
|
||||
subscriptionDuration: { value: null as string | null },
|
||||
subscriptionStatus: {
|
||||
value: null as null | {
|
||||
renewal_date?: string | null
|
||||
end_date?: string | null
|
||||
}
|
||||
},
|
||||
isCancelled: { value: false },
|
||||
fetchStatus: vi.fn(),
|
||||
manageSubscription: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
showSubscriptionDialog: vi.fn(),
|
||||
balance: {
|
||||
value: null as null | {
|
||||
amount_micros?: number
|
||||
currency?: string
|
||||
effective_balance_micros?: number
|
||||
prepaid_balance_micros?: number
|
||||
cloud_credit_balance_micros?: number
|
||||
}
|
||||
},
|
||||
fetchBalance: vi.fn(),
|
||||
purchaseCredits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
useSubscription: () => ({
|
||||
isActiveSubscription: mocks.isActiveSubscription,
|
||||
subscriptionTier: mocks.subscriptionTier,
|
||||
subscriptionDuration: mocks.subscriptionDuration,
|
||||
subscriptionStatus: mocks.subscriptionStatus,
|
||||
isCancelled: mocks.isCancelled,
|
||||
fetchStatus: mocks.fetchStatus,
|
||||
manageSubscription: mocks.manageSubscription,
|
||||
subscribe: mocks.subscribe,
|
||||
showSubscriptionDialog: mocks.showSubscriptionDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => ({
|
||||
get balance() {
|
||||
return mocks.balance.value
|
||||
},
|
||||
fetchBalance: mocks.fetchBalance
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: () => ({
|
||||
purchaseCredits: mocks.purchaseCredits
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useLegacyBilling', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mocks.isActiveSubscription.value = false
|
||||
mocks.subscriptionTier.value = null
|
||||
mocks.subscriptionDuration.value = null
|
||||
mocks.subscriptionStatus.value = null
|
||||
mocks.isCancelled.value = false
|
||||
mocks.balance.value = null
|
||||
mocks.fetchStatus.mockResolvedValue(undefined)
|
||||
mocks.manageSubscription.mockResolvedValue(undefined)
|
||||
mocks.subscribe.mockResolvedValue(undefined)
|
||||
mocks.fetchBalance.mockResolvedValue(undefined)
|
||||
mocks.purchaseCredits.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('returns empty subscription and balance state without legacy data', () => {
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.subscription.value).toBeNull()
|
||||
expect(billing.balance.value).toBeNull()
|
||||
expect(billing.subscriptionStatus.value).toBeNull()
|
||||
expect(billing.renewalDate.value).toBeNull()
|
||||
expect(billing.isFreeTier.value).toBe(false)
|
||||
})
|
||||
|
||||
it('maps active subscription and explicit balance fields', () => {
|
||||
mocks.isActiveSubscription.value = true
|
||||
mocks.subscriptionTier.value = 'PRO'
|
||||
mocks.subscriptionDuration.value = 'MONTHLY'
|
||||
mocks.subscriptionStatus.value = {
|
||||
renewal_date: '2026-01-01T00:00:00Z',
|
||||
end_date: '2026-02-01T00:00:00Z'
|
||||
}
|
||||
mocks.balance.value = {
|
||||
amount_micros: 500,
|
||||
currency: 'eur',
|
||||
effective_balance_micros: 400,
|
||||
prepaid_balance_micros: 300,
|
||||
cloud_credit_balance_micros: 200
|
||||
}
|
||||
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.subscription.value).toEqual({
|
||||
isActive: true,
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
planSlug: null,
|
||||
renewalDate: '2026-01-01T00:00:00Z',
|
||||
endDate: '2026-02-01T00:00:00Z',
|
||||
isCancelled: false,
|
||||
hasFunds: true
|
||||
})
|
||||
expect(billing.balance.value).toEqual({
|
||||
amountMicros: 500,
|
||||
currency: 'eur',
|
||||
effectiveBalanceMicros: 400,
|
||||
prepaidBalanceMicros: 300,
|
||||
cloudCreditBalanceMicros: 200
|
||||
})
|
||||
expect(billing.subscriptionStatus.value).toBe('active')
|
||||
})
|
||||
|
||||
it('uses legacy balance defaults when optional fields are absent', () => {
|
||||
mocks.subscriptionTier.value = 'FREE'
|
||||
mocks.balance.value = {}
|
||||
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.balance.value).toEqual({
|
||||
amountMicros: 0,
|
||||
currency: 'usd',
|
||||
effectiveBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 0,
|
||||
cloudCreditBalanceMicros: 0
|
||||
})
|
||||
expect(billing.subscription.value?.hasFunds).toBe(false)
|
||||
})
|
||||
|
||||
it('uses amount as effective balance when only amount is present', () => {
|
||||
mocks.balance.value = { amount_micros: 250 }
|
||||
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.balance.value?.effectiveBalanceMicros).toBe(250)
|
||||
})
|
||||
|
||||
it('reports canceled status before active status', () => {
|
||||
mocks.isActiveSubscription.value = true
|
||||
mocks.isCancelled.value = true
|
||||
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
expect(billing.subscriptionStatus.value).toBe('canceled')
|
||||
})
|
||||
|
||||
it('initializes once and re-fetches zero free-tier balance', async () => {
|
||||
mocks.subscriptionTier.value = 'FREE'
|
||||
mocks.balance.value = { amount_micros: 0 }
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await billing.initialize()
|
||||
await billing.initialize()
|
||||
|
||||
expect(billing.isInitialized.value).toBe(true)
|
||||
expect(mocks.fetchStatus).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.fetchBalance).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('stores initialization error messages from Error failures', async () => {
|
||||
mocks.fetchStatus.mockRejectedValue(new Error('status failed'))
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.initialize()).rejects.toThrow('status failed')
|
||||
|
||||
expect(billing.error.value).toBe('status failed')
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stores fallback initialization error messages for non-Error failures', async () => {
|
||||
mocks.fetchStatus.mockRejectedValue('status failed')
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.initialize()).rejects.toBe('status failed')
|
||||
|
||||
expect(billing.error.value).toBe('Failed to initialize billing')
|
||||
})
|
||||
|
||||
it('stores subscription fetch fallback errors', async () => {
|
||||
mocks.fetchStatus.mockRejectedValue('status failed')
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.fetchStatus()).rejects.toBe('status failed')
|
||||
|
||||
expect(billing.error.value).toBe('Failed to fetch subscription')
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stores balance fetch errors', async () => {
|
||||
mocks.fetchBalance.mockRejectedValue(new Error('balance failed'))
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.fetchBalance()).rejects.toThrow('balance failed')
|
||||
|
||||
expect(billing.error.value).toBe('balance failed')
|
||||
expect(billing.isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stores balance fetch fallback errors', async () => {
|
||||
mocks.fetchBalance.mockRejectedValue('balance failed')
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.fetchBalance()).rejects.toBe('balance failed')
|
||||
|
||||
expect(billing.error.value).toBe('Failed to fetch balance')
|
||||
})
|
||||
|
||||
it('delegates legacy billing actions', async () => {
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await expect(billing.subscribe('pro-monthly')).resolves.toBeUndefined()
|
||||
await expect(billing.previewSubscribe('pro-monthly')).resolves.toBeNull()
|
||||
await billing.manageSubscription()
|
||||
await billing.cancelSubscription()
|
||||
await billing.resubscribe()
|
||||
await billing.topup(750)
|
||||
await expect(billing.fetchPlans()).resolves.toBeUndefined()
|
||||
billing.showSubscriptionDialog()
|
||||
|
||||
expect(mocks.subscribe).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.manageSubscription).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.purchaseCredits).toHaveBeenCalledWith(7.5)
|
||||
expect(mocks.showSubscriptionDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows the subscription dialog when active subscription is required', async () => {
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await billing.requireActiveSubscription()
|
||||
|
||||
expect(mocks.showSubscriptionDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not show the subscription dialog for active subscribers', async () => {
|
||||
mocks.isActiveSubscription.value = true
|
||||
const billing = useLegacyBilling()
|
||||
|
||||
await billing.requireActiveSubscription()
|
||||
|
||||
expect(mocks.showSubscriptionDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
BillingStatus,
|
||||
BillingSubscriptionStatus,
|
||||
@@ -189,12 +190,12 @@ export function useLegacyBilling(): BillingState & BillingActions {
|
||||
async function requireActiveSubscription(): Promise<void> {
|
||||
await fetchStatus()
|
||||
if (!isActiveSubscription.value) {
|
||||
legacyShowSubscriptionDialog()
|
||||
legacyShowSubscriptionDialog({ reason: 'subscription_required' })
|
||||
}
|
||||
}
|
||||
|
||||
function showSubscriptionDialog(): void {
|
||||
legacyShowSubscriptionDialog()
|
||||
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
|
||||
legacyShowSubscriptionDialog(options)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
217
src/composables/bottomPanelTabs/useTerminal.test.ts
Normal file
217
src/composables/bottomPanelTabs/useTerminal.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, ref } from 'vue'
|
||||
|
||||
interface MockTerminalInstance {
|
||||
cols: number
|
||||
rows: number
|
||||
options: unknown
|
||||
loadAddon: ReturnType<typeof vi.fn>
|
||||
attachCustomKeyEventHandler: ReturnType<typeof vi.fn>
|
||||
open: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
resize: ReturnType<typeof vi.fn>
|
||||
hasSelection: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
interface MockFitAddonInstance {
|
||||
proposeDimensions: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mockXterm = vi.hoisted(() => {
|
||||
const terminalInstances: MockTerminalInstance[] = []
|
||||
const fitAddonInstances: MockFitAddonInstance[] = []
|
||||
|
||||
class Terminal {
|
||||
cols = 80
|
||||
rows = 24
|
||||
loadAddon = vi.fn()
|
||||
attachCustomKeyEventHandler = vi.fn()
|
||||
open = vi.fn()
|
||||
dispose = vi.fn()
|
||||
resize = vi.fn((cols: number, rows: number) => {
|
||||
this.cols = cols
|
||||
this.rows = rows
|
||||
})
|
||||
hasSelection = vi.fn(() => false)
|
||||
|
||||
constructor(readonly options: unknown) {
|
||||
terminalInstances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
class FitAddon {
|
||||
proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 }))
|
||||
|
||||
constructor() {
|
||||
fitAddonInstances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
Terminal,
|
||||
FitAddon,
|
||||
terminalInstances,
|
||||
fitAddonInstances
|
||||
}
|
||||
})
|
||||
|
||||
const mockResizeObserverInstances = [] as MockResizeObserver[]
|
||||
|
||||
class MockResizeObserver {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
|
||||
constructor(readonly callback: ResizeObserverCallback) {
|
||||
mockResizeObserverInstances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@xterm/xterm', () => ({
|
||||
Terminal: mockXterm.Terminal
|
||||
}))
|
||||
|
||||
vi.mock('@xterm/addon-fit', () => ({
|
||||
FitAddon: mockXterm.FitAddon
|
||||
}))
|
||||
|
||||
vi.mock('es-toolkit/compat', () => ({
|
||||
debounce: (fn: () => void) => fn
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isDesktop: true
|
||||
}))
|
||||
|
||||
import { useTerminal } from './useTerminal'
|
||||
|
||||
function terminalElement() {
|
||||
const element = document.createElement('div')
|
||||
Object.defineProperty(element, 'clientWidth', { value: 160 })
|
||||
Object.defineProperty(element, 'clientHeight', { value: 100 })
|
||||
return element
|
||||
}
|
||||
|
||||
function mountTerminal(
|
||||
configure?: (
|
||||
result: ReturnType<typeof useTerminal>,
|
||||
root: ReturnType<typeof ref<HTMLElement | undefined>>
|
||||
) => void
|
||||
) {
|
||||
let result: ReturnType<typeof useTerminal> | undefined
|
||||
const root = ref<HTMLElement | undefined>(terminalElement())
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = useTerminal(root)
|
||||
configure?.(result, root)
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
)
|
||||
app.mount(document.createElement('div'))
|
||||
if (!result) throw new Error('Expected terminal composable to initialize')
|
||||
return { app, result, root }
|
||||
}
|
||||
|
||||
describe('useTerminal', () => {
|
||||
beforeEach(() => {
|
||||
mockXterm.terminalInstances.length = 0
|
||||
mockXterm.fitAddonInstances.length = 0
|
||||
mockResizeObserverInstances.length = 0
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
||||
})
|
||||
|
||||
it('creates a desktop themed terminal and opens it on mount', () => {
|
||||
const { app, root } = mountTerminal()
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
const fitAddon = mockXterm.fitAddonInstances[0]
|
||||
|
||||
expect(terminal.options).toMatchObject({
|
||||
convertEol: true,
|
||||
theme: { background: '#171717' }
|
||||
})
|
||||
expect(terminal.loadAddon).toHaveBeenCalledWith(fitAddon)
|
||||
expect(terminal.open).toHaveBeenCalledWith(root.value)
|
||||
|
||||
app.unmount()
|
||||
expect(terminal.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lets browser copy and paste shortcuts pass through', () => {
|
||||
mountTerminal()
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
const handler = terminal.attachCustomKeyEventHandler.mock.calls[0][0] as (
|
||||
event: KeyboardEvent
|
||||
) => boolean
|
||||
|
||||
terminal.hasSelection.mockReturnValue(true)
|
||||
expect(
|
||||
handler(new KeyboardEvent('keydown', { key: 'c', ctrlKey: true }))
|
||||
).toBe(false)
|
||||
expect(
|
||||
handler(new KeyboardEvent('keydown', { key: 'v', metaKey: true }))
|
||||
).toBe(false)
|
||||
|
||||
terminal.hasSelection.mockReturnValue(false)
|
||||
expect(
|
||||
handler(new KeyboardEvent('keydown', { key: 'c', ctrlKey: true }))
|
||||
).toBe(true)
|
||||
expect(
|
||||
handler(new KeyboardEvent('keyup', { key: 'v', ctrlKey: true }))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('auto-sizes from fit dimensions and disconnects the observer on unmount', () => {
|
||||
const onResize = vi.fn()
|
||||
const { app, root } = mountTerminal((terminal, rootRef) => {
|
||||
terminal.useAutoSize({
|
||||
root: rootRef,
|
||||
minCols: 100,
|
||||
minRows: 20,
|
||||
onResize
|
||||
})
|
||||
})
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
const observer = mockResizeObserverInstances[0]
|
||||
|
||||
expect(observer.observe).toHaveBeenCalledWith(root.value)
|
||||
expect(terminal.resize).toHaveBeenCalledWith(120, 40)
|
||||
expect(onResize).toHaveBeenCalledOnce()
|
||||
|
||||
app.unmount()
|
||||
expect(observer.disconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('estimates invalid fit dimensions from the root element', () => {
|
||||
const { result, root } = mountTerminal()
|
||||
const fitAddon = mockXterm.fitAddonInstances[0]
|
||||
fitAddon.proposeDimensions.mockReturnValue({
|
||||
cols: Number.NaN,
|
||||
rows: undefined
|
||||
})
|
||||
const { resize } = result.useAutoSize({ root, minCols: 30, minRows: 10 })
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
|
||||
resize()
|
||||
|
||||
expect(terminal.resize).toHaveBeenLastCalledWith(30, 10)
|
||||
})
|
||||
|
||||
it('keeps existing terminal dimensions when auto sizing is disabled', () => {
|
||||
const { result, root } = mountTerminal()
|
||||
const terminal = mockXterm.terminalInstances[0]
|
||||
terminal.cols = 90
|
||||
terminal.rows = 30
|
||||
const { resize } = result.useAutoSize({
|
||||
root,
|
||||
autoCols: false,
|
||||
autoRows: false,
|
||||
minCols: 10,
|
||||
minRows: 10
|
||||
})
|
||||
|
||||
resize()
|
||||
|
||||
expect(terminal.resize).toHaveBeenLastCalledWith(90, 30)
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import type { Ref, ShallowRef } from 'vue'
|
||||
import { defineComponent, h, nextTick, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useBoundingBoxes } from './useBoundingBoxes'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
@@ -35,11 +36,26 @@ const ctx = {
|
||||
lineWidth: 0
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
function makeCanvas(): HTMLCanvasElement {
|
||||
function makeCanvas(
|
||||
options: {
|
||||
context?: CanvasRenderingContext2D | null
|
||||
clientWidth?: number
|
||||
clientHeight?: number
|
||||
} = {}
|
||||
): HTMLCanvasElement {
|
||||
const el = document.createElement('canvas')
|
||||
Object.defineProperty(el, 'clientWidth', { value: 100, configurable: true })
|
||||
Object.defineProperty(el, 'clientHeight', { value: 100, configurable: true })
|
||||
el.getContext = (() => ctx) as unknown as HTMLCanvasElement['getContext']
|
||||
Object.defineProperty(el, 'clientWidth', {
|
||||
value: options.clientWidth ?? 100,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(el, 'clientHeight', {
|
||||
value: options.clientHeight ?? 100,
|
||||
configurable: true
|
||||
})
|
||||
el.getContext = (() =>
|
||||
options.context === undefined
|
||||
? ctx
|
||||
: options.context) as unknown as HTMLCanvasElement['getContext']
|
||||
el.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
@@ -96,14 +112,14 @@ interface Captured extends Api {
|
||||
modelValue: Ref<BoundingBox[]>
|
||||
}
|
||||
|
||||
function setup(initial: BoundingBox[] = []) {
|
||||
function setup(initial: BoundingBox[] | undefined = []) {
|
||||
let captured: Captured | undefined
|
||||
const Harness = defineComponent({
|
||||
setup() {
|
||||
const canvasEl = shallowRef<HTMLCanvasElement | null>(null)
|
||||
const canvasContainer = shallowRef<HTMLDivElement | null>(null)
|
||||
const inlineEditorEl = shallowRef<HTMLTextAreaElement | null>(null)
|
||||
const modelValue = ref(initial)
|
||||
const modelValue = ref(initial as BoundingBox[])
|
||||
const api = useBoundingBoxes(toNodeId('1'), {
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
@@ -159,9 +175,43 @@ describe('useBoundingBoxes initialization', () => {
|
||||
expect(c.hasRegions.value).toBe(false)
|
||||
expect(c.activeRegion.value).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to default dimensions when the litegraph node is unavailable', () => {
|
||||
appState.node = null
|
||||
const c = setup([box()])
|
||||
expect(c.canvasStyle.value).toEqual({ aspectRatio: '1024 / 1024' })
|
||||
})
|
||||
|
||||
it('ignores non-positive dimension widgets', () => {
|
||||
appState.node = {
|
||||
widgets: [
|
||||
{ name: 'width', value: 0 },
|
||||
{ name: 'height', value: 'bad' }
|
||||
],
|
||||
findInputSlot: () => -1,
|
||||
getInputNode: () => null
|
||||
}
|
||||
const c = setup()
|
||||
expect(c.canvasStyle.value).toEqual({ aspectRatio: '1024 / 1024' })
|
||||
})
|
||||
|
||||
it('treats an undefined model value as empty', () => {
|
||||
const c = setup(undefined)
|
||||
expect(c.hasRegions.value).toBe(false)
|
||||
expect(c.modelValue.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes drawing', () => {
|
||||
it('ignores non-primary pointer buttons', async () => {
|
||||
const c = setup()
|
||||
c.onPointerDown(pe(10, 10, { button: 1 }))
|
||||
c.onCanvasPointerMove(pe(60, 60))
|
||||
c.onDocPointerUp(pe(60, 60))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('draws a new region and syncs it to the model value', async () => {
|
||||
const c = setup()
|
||||
c.onPointerDown(pe(10, 10))
|
||||
@@ -187,6 +237,102 @@ describe('useBoundingBoxes drawing', () => {
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('moves an existing active region by dragging inside it', async () => {
|
||||
const c = setup([box()])
|
||||
c.onPointerDown(pe(30, 30))
|
||||
c.onCanvasPointerMove(pe(45, 50))
|
||||
c.onDocPointerUp(pe(45, 50))
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].x).toBeGreaterThan(51)
|
||||
expect(c.modelValue.value[0].y).toBeGreaterThan(51)
|
||||
})
|
||||
|
||||
it('resizes an existing active region from its corner handle', async () => {
|
||||
const c = setup([box()])
|
||||
c.onPointerDown(pe(60, 60))
|
||||
c.onCanvasPointerMove(pe(80, 80))
|
||||
c.onDocPointerUp(pe(80, 80))
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].width).toBeGreaterThan(256)
|
||||
expect(c.modelValue.value[0].height).toBeGreaterThan(256)
|
||||
})
|
||||
|
||||
it('keeps selection valid when Alt-clicking overlapping regions', async () => {
|
||||
const c = setup([
|
||||
box(),
|
||||
box({
|
||||
metadata: {
|
||||
type: 'obj',
|
||||
text: '',
|
||||
desc: 'second',
|
||||
palette: ['#ff0000']
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
c.onPointerDown(pe(30, 30, { altKey: true }))
|
||||
c.onDocPointerUp(pe(30, 30))
|
||||
await flush()
|
||||
|
||||
expect(c.activeRegion.value).not.toBeNull()
|
||||
expect(c.modelValue.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('ignores document movement and pointer up when no draw is active', async () => {
|
||||
const c = setup([box()])
|
||||
|
||||
c.onCanvasPointerMove(pe(5, 95))
|
||||
c.onDocPointerUp(pe(95, 95))
|
||||
await flush()
|
||||
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('uses zero pointer coordinates when the canvas is unavailable', async () => {
|
||||
const c = setup()
|
||||
c.canvasEl.value = null
|
||||
|
||||
c.onPointerDown(pe(50, 50))
|
||||
c.onCanvasPointerMove(pe(80, 80))
|
||||
c.onDocPointerUp(pe(80, 80))
|
||||
await flush()
|
||||
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('redraws active text regions with fallback palette color', async () => {
|
||||
const c = setup([
|
||||
box({
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 30,
|
||||
height: 30,
|
||||
metadata: {
|
||||
type: 'text',
|
||||
text: 'hello',
|
||||
desc: 'alpha beta\n\ncharlie',
|
||||
palette: []
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
c.focused.value = true
|
||||
c.syncState()
|
||||
await flush()
|
||||
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('draws safely when the canvas context is unavailable', async () => {
|
||||
const c = setup([box()])
|
||||
c.canvasEl.value = makeCanvas({ context: null })
|
||||
|
||||
c.syncState()
|
||||
await flush()
|
||||
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes region editing', () => {
|
||||
@@ -214,6 +360,60 @@ describe('useBoundingBoxes region editing', () => {
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does nothing when changing type without an active region', async () => {
|
||||
const c = setup()
|
||||
c.setActiveType('text')
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deletes the active region on Backspace', async () => {
|
||||
const c = setup([box()])
|
||||
c.onCanvasKeyDown({
|
||||
key: 'Backspace',
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
} as unknown as KeyboardEvent)
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ignores unrelated keys and key events while drawing', async () => {
|
||||
const c = setup([box()])
|
||||
c.onCanvasKeyDown({
|
||||
key: 'Enter',
|
||||
preventDefault: () => {
|
||||
throw new Error('should not prevent')
|
||||
},
|
||||
stopPropagation: () => {}
|
||||
} as unknown as KeyboardEvent)
|
||||
c.onPointerDown(pe(80, 80))
|
||||
c.onCanvasKeyDown({
|
||||
key: 'Delete',
|
||||
preventDefault: () => {
|
||||
throw new Error('should not prevent while drawing')
|
||||
},
|
||||
stopPropagation: () => {}
|
||||
} as unknown as KeyboardEvent)
|
||||
c.onDocPointerUp(pe(80, 80))
|
||||
await flush()
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps a remaining region selected after deleting from a multi-region list', async () => {
|
||||
const c = setup([box(), box({ x: 10 })])
|
||||
|
||||
c.onCanvasKeyDown({
|
||||
key: 'Delete',
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {}
|
||||
} as unknown as KeyboardEvent)
|
||||
await flush()
|
||||
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
expect(c.activeRegion.value).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes inline editor', () => {
|
||||
@@ -237,6 +437,86 @@ describe('useBoundingBoxes inline editor', () => {
|
||||
c.onInlineKeyDown({ key: 'Escape' } as KeyboardEvent)
|
||||
expect(c.inlineEditor.value).toBeNull()
|
||||
})
|
||||
|
||||
it('commits the inline editor on Ctrl+Enter', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
|
||||
await flush()
|
||||
c.inlineEditor.value!.value = 'committed'
|
||||
c.onInlineKeyDown({
|
||||
key: 'Enter',
|
||||
ctrlKey: true,
|
||||
metaKey: false
|
||||
} as KeyboardEvent)
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.desc).toBe('committed')
|
||||
})
|
||||
|
||||
it('commits the inline editor on Meta+Enter', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
|
||||
await flush()
|
||||
c.inlineEditor.value!.value = 'meta committed'
|
||||
c.onInlineKeyDown({
|
||||
key: 'Enter',
|
||||
ctrlKey: false,
|
||||
metaKey: true
|
||||
} as KeyboardEvent)
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.desc).toBe('meta committed')
|
||||
})
|
||||
|
||||
it('ignores Enter without a modifier in the inline editor', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
|
||||
await flush()
|
||||
c.inlineEditor.value!.value = 'not committed'
|
||||
c.onInlineKeyDown({
|
||||
key: 'Enter',
|
||||
ctrlKey: false,
|
||||
metaKey: false
|
||||
} as KeyboardEvent)
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.desc).toBe('')
|
||||
})
|
||||
|
||||
it('leaves state unchanged when committing without an editor', async () => {
|
||||
const c = setup([box()])
|
||||
c.commitInlineEditor()
|
||||
await flush()
|
||||
expect(c.modelValue.value[0].metadata.desc).toBe('')
|
||||
})
|
||||
|
||||
it('closes a stale inline editor after its region was removed', async () => {
|
||||
const c = setup([box()])
|
||||
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
|
||||
await flush()
|
||||
c.inlineEditor.value!.value = 'stale'
|
||||
|
||||
c.clearAll()
|
||||
c.commitInlineEditor()
|
||||
await flush()
|
||||
|
||||
expect(c.inlineEditor.value).toBeNull()
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not open the inline editor when double-clicking empty space', async () => {
|
||||
const c = setup([box({ x: 0, y: 0, width: 50, height: 50 })])
|
||||
c.onDoubleClick(pe(95, 95) as unknown as MouseEvent)
|
||||
await flush()
|
||||
expect(c.inlineEditor.value).toBeNull()
|
||||
})
|
||||
|
||||
it('uses zero mouse coordinates when double-clicking without a canvas', async () => {
|
||||
const c = setup([box({ x: 0, y: 0, width: 512, height: 512 })])
|
||||
c.canvasEl.value = null
|
||||
|
||||
c.onDoubleClick(pe(30, 30) as unknown as MouseEvent)
|
||||
await flush()
|
||||
|
||||
expect(c.inlineEditor.value).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes hover cursor', () => {
|
||||
@@ -247,4 +527,74 @@ describe('useBoundingBoxes hover cursor', () => {
|
||||
await flush()
|
||||
expect(c.canvasCursor.value).toBe('pointer')
|
||||
})
|
||||
|
||||
it('returns to the default cursor after leaving the canvas', async () => {
|
||||
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
|
||||
c.onCanvasPointerMove(pe(15, 15))
|
||||
await flush()
|
||||
c.onPointerLeave()
|
||||
await flush()
|
||||
expect(c.canvasCursor.value).toBe('crosshair')
|
||||
})
|
||||
|
||||
it('does nothing when leaving without hover state', async () => {
|
||||
const c = setup([box()])
|
||||
c.onPointerLeave()
|
||||
await flush()
|
||||
expect(c.canvasCursor.value).toBe('crosshair')
|
||||
})
|
||||
|
||||
it('keeps cursor default when canvas context is unavailable for title hit testing', async () => {
|
||||
const c = setup([box()])
|
||||
c.canvasEl.value = makeCanvas({ context: null })
|
||||
c.onCanvasPointerMove(pe(30, 30))
|
||||
await flush()
|
||||
expect(c.canvasCursor.value).toBe('crosshair')
|
||||
})
|
||||
|
||||
it('keeps hover state unchanged when pointer movement hits the same tag', async () => {
|
||||
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
|
||||
|
||||
c.onCanvasPointerMove(pe(15, 15))
|
||||
await flush()
|
||||
c.onCanvasPointerMove(pe(15, 15))
|
||||
await flush()
|
||||
|
||||
expect(c.canvasCursor.value).toBe('pointer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes background image', () => {
|
||||
it('loads a background image and snaps node dimensions', async () => {
|
||||
const widthCallback = vi.fn()
|
||||
const heightCallback = vi.fn()
|
||||
const inputNode = { id: 2 }
|
||||
appState.node = {
|
||||
widgets: [
|
||||
{ name: 'width', value: 512, callback: widthCallback },
|
||||
{ name: 'height', value: 512, callback: heightCallback }
|
||||
],
|
||||
findInputSlot: () => 0,
|
||||
getInputNode: () => inputNode
|
||||
}
|
||||
const store = useNodeOutputStore()
|
||||
vi.spyOn(store, 'getNodeImageUrls').mockReturnValue(['blob:bg'])
|
||||
class FakeImage {
|
||||
crossOrigin = ''
|
||||
naturalWidth = 257
|
||||
naturalHeight = 271
|
||||
onload: (() => void) | null = null
|
||||
|
||||
set src(_value: string) {
|
||||
this.onload?.()
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Image', FakeImage)
|
||||
|
||||
setup([box()])
|
||||
await flush()
|
||||
|
||||
expect(widthCallback).toHaveBeenCalledWith(256)
|
||||
expect(heightCallback).toHaveBeenCalledWith(272)
|
||||
})
|
||||
})
|
||||
|
||||
118
src/composables/canvas/useFocusNode.test.ts
Normal file
118
src/composables/canvas/useFocusNode.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
type Graph = {
|
||||
isRootGraph: boolean
|
||||
}
|
||||
|
||||
type FocusableNode = {
|
||||
graph?: Graph
|
||||
boundingRect: DOMRect
|
||||
}
|
||||
|
||||
const { appState, canvasStore, getNodeByExecutionId } = vi.hoisted(() => ({
|
||||
appState: {
|
||||
rootGraph: { isRootGraph: true }
|
||||
},
|
||||
canvasStore: {
|
||||
canvas: undefined as
|
||||
| undefined
|
||||
| {
|
||||
graph: Graph
|
||||
subgraph?: Graph
|
||||
setGraph: ReturnType<typeof vi.fn>
|
||||
animateToBounds: ReturnType<typeof vi.fn>
|
||||
}
|
||||
},
|
||||
getNodeByExecutionId: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => canvasStore
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: appState
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByExecutionId
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
getNodeByExecutionId.mockReset()
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
(callback: FrameRequestCallback): number => {
|
||||
callback(0)
|
||||
return 1
|
||||
}
|
||||
)
|
||||
canvasStore.canvas = {
|
||||
graph: appState.rootGraph,
|
||||
setGraph: vi.fn(),
|
||||
animateToBounds: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
describe('useFocusNode', () => {
|
||||
it('does nothing when there is no canvas or matching graph node', async () => {
|
||||
canvasStore.canvas = undefined
|
||||
await useFocusNode().focusNode('node-1')
|
||||
|
||||
expect(getNodeByExecutionId).not.toHaveBeenCalled()
|
||||
|
||||
canvasStore.canvas = {
|
||||
graph: appState.rootGraph,
|
||||
setGraph: vi.fn(),
|
||||
animateToBounds: vi.fn()
|
||||
}
|
||||
getNodeByExecutionId.mockReturnValue({ boundingRect: new DOMRect() })
|
||||
|
||||
await useFocusNode().focusNode('node-1')
|
||||
|
||||
expect(canvasStore.canvas.animateToBounds).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('navigates to the node graph before focusing its bounds', async () => {
|
||||
const subgraph = { isRootGraph: false }
|
||||
const bounds = new DOMRect(1, 2, 3, 4)
|
||||
getNodeByExecutionId.mockReturnValue({
|
||||
graph: subgraph,
|
||||
boundingRect: bounds
|
||||
} satisfies FocusableNode)
|
||||
|
||||
await useFocusNode().focusNode('node-1')
|
||||
|
||||
expect(getNodeByExecutionId).toHaveBeenCalledWith(
|
||||
appState.rootGraph,
|
||||
'node-1'
|
||||
)
|
||||
expect(canvasStore.canvas?.subgraph).toBe(subgraph)
|
||||
expect(canvasStore.canvas?.setGraph).toHaveBeenCalledWith(subgraph)
|
||||
expect(canvasStore.canvas?.animateToBounds).toHaveBeenCalledWith(bounds)
|
||||
})
|
||||
|
||||
it('uses an execution id map and skips graph navigation when already there', async () => {
|
||||
const graph = { isRootGraph: true }
|
||||
const bounds = new DOMRect(5, 6, 7, 8)
|
||||
canvasStore.canvas = {
|
||||
graph,
|
||||
setGraph: vi.fn(),
|
||||
animateToBounds: vi.fn()
|
||||
}
|
||||
const node = { graph, boundingRect: bounds } satisfies FocusableNode
|
||||
|
||||
await useFocusNode().focusNode(
|
||||
'node-1',
|
||||
new Map([['node-1', fromAny<LGraphNode, unknown>(node)]])
|
||||
)
|
||||
|
||||
expect(getNodeByExecutionId).not.toHaveBeenCalled()
|
||||
expect(canvasStore.canvas.setGraph).not.toHaveBeenCalled()
|
||||
expect(canvasStore.canvas.animateToBounds).toHaveBeenCalledWith(bounds)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { defineComponent, h, markRaw, ref } from 'vue'
|
||||
import { defineComponent, h, markRaw, nextTick, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSelectionToolboxPosition } from '@/composables/canvas/useSelectionToolboxPosition'
|
||||
@@ -12,19 +12,35 @@ import {
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
canvas: null
|
||||
}))
|
||||
|
||||
const mockFeatureFlags = vi.hoisted(() => ({
|
||||
refs: null as null | {
|
||||
shouldRenderVueNodes: { value: boolean }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: mockApp }))
|
||||
|
||||
vi.mock('@/composables/useVueFeatureFlags', () => ({
|
||||
useVueFeatureFlags: () => ({
|
||||
shouldRenderVueNodes: { value: false }
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/useVueFeatureFlags', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const shouldRenderVueNodes = ref(false)
|
||||
mockFeatureFlags.refs = {
|
||||
shouldRenderVueNodes
|
||||
}
|
||||
|
||||
return {
|
||||
useVueFeatureFlags: () => ({
|
||||
shouldRenderVueNodes
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('useSelectionToolboxPosition', () => {
|
||||
let canvasStore: ReturnType<typeof useCanvasStore>
|
||||
@@ -32,28 +48,39 @@ describe('useSelectionToolboxPosition', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
canvasStore = useCanvasStore()
|
||||
layoutStore.initializeFromLiteGraph([])
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
if (mockFeatureFlags.refs) {
|
||||
mockFeatureFlags.refs.shouldRenderVueNodes.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function renderToolboxForSelection(item: Positionable) {
|
||||
function renderToolboxForSelection(
|
||||
items: Iterable<Positionable>,
|
||||
state: Partial<LGraphCanvas['state']> = {},
|
||||
ds: Partial<LGraphCanvas['ds']> = {}
|
||||
) {
|
||||
canvasStore.canvas = markRaw({
|
||||
canvas: document.createElement('canvas'),
|
||||
ds: {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
offset: ds.offset ?? [0, 0],
|
||||
scale: ds.scale ?? 1
|
||||
},
|
||||
selectedItems: new Set([item]),
|
||||
selectedItems: new Set(items),
|
||||
state: {
|
||||
draggingItems: false,
|
||||
selectionChanged: true
|
||||
selectionChanged: true,
|
||||
...state
|
||||
}
|
||||
} as Partial<LGraphCanvas> as LGraphCanvas)
|
||||
|
||||
let toolbox: HTMLElement | undefined
|
||||
let visible!: ReturnType<typeof useSelectionToolboxPosition>['visible']
|
||||
const TestHarness = defineComponent({
|
||||
setup() {
|
||||
const toolboxRef = ref<HTMLElement>(document.createElement('div'))
|
||||
toolbox = toolboxRef.value
|
||||
useSelectionToolboxPosition(toolboxRef)
|
||||
;({ visible } = useSelectionToolboxPosition(toolboxRef))
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
@@ -61,7 +88,28 @@ describe('useSelectionToolboxPosition', () => {
|
||||
const wrapper = render(TestHarness)
|
||||
if (!toolbox) throw new Error('Toolbox element was not initialized')
|
||||
|
||||
return { toolbox, unmount: wrapper.unmount }
|
||||
if (!visible) throw new Error('Visible state was not initialized')
|
||||
|
||||
return { toolbox, unmount: wrapper.unmount, visible }
|
||||
}
|
||||
|
||||
function setCanvasSelection(
|
||||
items: Iterable<Positionable>,
|
||||
state: Partial<LGraphCanvas['state']> = {}
|
||||
) {
|
||||
canvasStore.canvas = markRaw({
|
||||
canvas: document.createElement('canvas'),
|
||||
ds: {
|
||||
offset: [0, 0],
|
||||
scale: 1
|
||||
},
|
||||
selectedItems: new Set(items),
|
||||
state: {
|
||||
draggingItems: false,
|
||||
selectionChanged: true,
|
||||
...state
|
||||
}
|
||||
} as Partial<LGraphCanvas> as LGraphCanvas)
|
||||
}
|
||||
|
||||
it('positions groups from their unchanged bounds', () => {
|
||||
@@ -69,7 +117,7 @@ describe('useSelectionToolboxPosition', () => {
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection(group)
|
||||
const { toolbox, unmount } = renderToolboxForSelection([group])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
|
||||
unmount()
|
||||
@@ -81,11 +129,221 @@ describe('useSelectionToolboxPosition', () => {
|
||||
node.pos = [100, 200]
|
||||
node.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection(node)
|
||||
const { toolbox, unmount } = renderToolboxForSelection([node])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
|
||||
`${190 - LiteGraph.NODE_TITLE_HEIGHT}px`
|
||||
)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not set coordinates when selection is empty', () => {
|
||||
const { toolbox, unmount } = renderToolboxForSelection([])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not update when selection state is unchanged', () => {
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
|
||||
const { toolbox, visible, unmount } = renderToolboxForSelection([group], {
|
||||
selectionChanged: false
|
||||
})
|
||||
|
||||
expect(visible.value).toBe(false)
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not set coordinates while selected items are being dragged', () => {
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([group], {
|
||||
draggingItems: true
|
||||
})
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('positions multiple selected items from their union bounds', () => {
|
||||
const first = new LGraphGroup('First', 1)
|
||||
first.pos = [100, 200]
|
||||
first.size = [100, 40]
|
||||
const second = new LGraphGroup('Second', 2)
|
||||
second.pos = [300, 260]
|
||||
second.size = [50, 40]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([first, second])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('270px')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('applies canvas scale and offset to screen coordinates', () => {
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [100, 40]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection(
|
||||
[group],
|
||||
{},
|
||||
{ offset: [10, 20], scale: 2 }
|
||||
)
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('360px')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('420px')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('uses Vue layout bounds when Vue node rendering is enabled', () => {
|
||||
if (!mockFeatureFlags.refs) {
|
||||
throw new Error('feature flag refs were not initialized')
|
||||
}
|
||||
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
|
||||
const node = new LGraphNode('Node')
|
||||
node.id = toNodeId(12)
|
||||
node.pos = [100, 200]
|
||||
node.size = [160, 80]
|
||||
layoutStore.initializeFromLiteGraph([
|
||||
{
|
||||
id: node.id,
|
||||
pos: [300, 400],
|
||||
size: [200, 120]
|
||||
}
|
||||
])
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([node])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('400px')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
|
||||
`${390 - LiteGraph.NODE_TITLE_HEIGHT}px`
|
||||
)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('falls back to LiteGraph node bounds when Vue layout is missing', () => {
|
||||
if (!mockFeatureFlags.refs) {
|
||||
throw new Error('feature flag refs were not initialized')
|
||||
}
|
||||
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
|
||||
const node = new LGraphNode('Node')
|
||||
node.id = toNodeId(13)
|
||||
node.pos = [100, 200]
|
||||
node.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([node])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
|
||||
`${190 - LiteGraph.NODE_TITLE_HEIGHT}px`
|
||||
)
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('hides the toolbox while Vue nodes are being dragged', () => {
|
||||
if (!mockFeatureFlags.refs) {
|
||||
throw new Error('feature flag refs were not initialized')
|
||||
}
|
||||
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
|
||||
const { toolbox, unmount } = renderToolboxForSelection([group])
|
||||
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('ignores selected items that are not nodes or groups', () => {
|
||||
const item = createMockPositionable({
|
||||
id: toNodeId(52),
|
||||
pos: [100, 200],
|
||||
size: [160, 80],
|
||||
boundingRect: [100, 200, 160, 80]
|
||||
})
|
||||
|
||||
const { toolbox, visible, unmount } = renderToolboxForSelection([item])
|
||||
|
||||
expect(visible.value).toBe(true)
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('ignores selected items without valid ids', () => {
|
||||
const item = {
|
||||
id: null,
|
||||
pos: [100, 200],
|
||||
size: [160, 80],
|
||||
boundingRect: [100, 200, 160, 80]
|
||||
} as unknown as Positionable
|
||||
|
||||
const { toolbox, visible, unmount } = renderToolboxForSelection([item])
|
||||
|
||||
expect(visible.value).toBe(true)
|
||||
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
|
||||
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('stays visible without mutating style when the toolbox ref is empty', () => {
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
setCanvasSelection([group])
|
||||
|
||||
let visible!: ReturnType<typeof useSelectionToolboxPosition>['visible']
|
||||
const TestHarness = defineComponent({
|
||||
setup() {
|
||||
;({ visible } = useSelectionToolboxPosition(ref()))
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
|
||||
const wrapper = render(TestHarness)
|
||||
|
||||
expect(visible.value).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('hides and restores around Vue node drag state changes', async () => {
|
||||
if (!mockFeatureFlags.refs) {
|
||||
throw new Error('feature flag refs were not initialized')
|
||||
}
|
||||
mockFeatureFlags.refs.shouldRenderVueNodes.value = true
|
||||
const group = new LGraphGroup('Group', 1)
|
||||
group.pos = [100, 200]
|
||||
group.size = [160, 80]
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
window.setTimeout(() => callback(0), 0)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', (handle: number) => {
|
||||
clearTimeout(handle)
|
||||
})
|
||||
|
||||
const { visible, unmount } = renderToolboxForSelection([group])
|
||||
expect(visible.value).toBe(true)
|
||||
|
||||
layoutStore.isDraggingVueNodes.value = true
|
||||
await nextTick()
|
||||
expect(visible.value).toBe(false)
|
||||
|
||||
layoutStore.isDraggingVueNodes.value = false
|
||||
await nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(visible.value).toBe(true)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
100
src/composables/element/useAbsolutePosition.test.ts
Normal file
100
src/composables/element/useAbsolutePosition.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
canvas: {},
|
||||
ds: {
|
||||
scale: 3
|
||||
}
|
||||
},
|
||||
canvasPosToClientPos: vi.fn((pos: [number, number]) => [
|
||||
pos[0] + 10,
|
||||
pos[1] + 20
|
||||
]),
|
||||
getCanvas: vi.fn(),
|
||||
getSetting: vi.fn(),
|
||||
updateCanvasPosition: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: mocks.getCanvas
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: mocks.getSetting
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
|
||||
useCanvasPositionConversion: vi.fn(() => ({
|
||||
canvasPosToClientPos: mocks.canvasPosToClientPos,
|
||||
update: mocks.updateCanvasPosition
|
||||
}))
|
||||
}))
|
||||
|
||||
const { useAbsolutePosition } = await import('./useAbsolutePosition')
|
||||
|
||||
describe('useAbsolutePosition', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getCanvas.mockReturnValue(mocks.canvas)
|
||||
mocks.canvas.ds.scale = 3
|
||||
})
|
||||
|
||||
it('positions and scales an element with the canvas scale', () => {
|
||||
const { style, updatePosition } = useAbsolutePosition()
|
||||
|
||||
updatePosition({
|
||||
pos: [1, 2],
|
||||
size: [4, 5]
|
||||
})
|
||||
|
||||
expect(style.value).toMatchObject({
|
||||
position: 'fixed',
|
||||
left: '11px',
|
||||
top: '22px',
|
||||
width: '12px',
|
||||
height: '15px'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses an explicit scale when provided', () => {
|
||||
const { style, updatePosition } = useAbsolutePosition()
|
||||
|
||||
updatePosition({
|
||||
pos: [1, 2],
|
||||
size: [4, 5],
|
||||
scale: 2
|
||||
})
|
||||
|
||||
expect(style.value).toMatchObject({
|
||||
width: '8px',
|
||||
height: '10px'
|
||||
})
|
||||
})
|
||||
|
||||
it('applies transform scaling without resizing the element bounds', () => {
|
||||
const { style, updatePosition } = useAbsolutePosition({
|
||||
useTransform: true
|
||||
})
|
||||
|
||||
updatePosition({
|
||||
pos: [1, 2],
|
||||
size: [4, 5],
|
||||
scale: 2
|
||||
})
|
||||
|
||||
expect(style.value).toMatchObject({
|
||||
position: 'fixed',
|
||||
transformOrigin: '0 0',
|
||||
transform: 'scale(2)',
|
||||
left: '11px',
|
||||
top: '22px',
|
||||
width: '4px',
|
||||
height: '5px'
|
||||
})
|
||||
})
|
||||
})
|
||||
86
src/composables/element/useCanvasPositionConversion.test.ts
Normal file
86
src/composables/element/useCanvasPositionConversion.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const canvas = {
|
||||
canvas: {},
|
||||
ds: {
|
||||
offset: [10, 20],
|
||||
scale: 2
|
||||
}
|
||||
} as unknown as LGraphCanvas
|
||||
|
||||
return {
|
||||
bounds: {
|
||||
left: { value: 4 },
|
||||
top: { value: 6 }
|
||||
},
|
||||
canvas,
|
||||
getCanvas: vi.fn(() => canvas),
|
||||
update: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useElementBounding: vi.fn(() => ({
|
||||
left: mocks.bounds.left,
|
||||
top: mocks.bounds.top,
|
||||
update: mocks.update
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: mocks.getCanvas
|
||||
})
|
||||
}))
|
||||
|
||||
const { useCanvasPositionConversion, useSharedCanvasPositionConversion } =
|
||||
await import('./useCanvasPositionConversion')
|
||||
|
||||
describe('useCanvasPositionConversion', () => {
|
||||
beforeEach(() => {
|
||||
mocks.bounds.left.value = 4
|
||||
mocks.bounds.top.value = 6
|
||||
mocks.getCanvas.mockClear()
|
||||
mocks.update.mockClear()
|
||||
})
|
||||
|
||||
it('converts client positions into canvas coordinates', () => {
|
||||
const { clientPosToCanvasPos } = useCanvasPositionConversion(
|
||||
mocks.canvas.canvas,
|
||||
mocks.canvas
|
||||
)
|
||||
|
||||
expect(clientPosToCanvasPos([34, 66])).toEqual([5, 10])
|
||||
})
|
||||
|
||||
it('converts canvas positions into client coordinates', () => {
|
||||
const { canvasPosToClientPos } = useCanvasPositionConversion(
|
||||
mocks.canvas.canvas,
|
||||
mocks.canvas
|
||||
)
|
||||
|
||||
expect(canvasPosToClientPos([5, 10])).toEqual([34, 66])
|
||||
})
|
||||
|
||||
it('returns the element bounds update callback', () => {
|
||||
const { update } = useCanvasPositionConversion(
|
||||
mocks.canvas.canvas,
|
||||
mocks.canvas
|
||||
)
|
||||
|
||||
update()
|
||||
|
||||
expect(mocks.update).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reuses the shared converter instance', () => {
|
||||
const first = useSharedCanvasPositionConversion()
|
||||
const second = useSharedCanvasPositionConversion()
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(mocks.getCanvas).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
82
src/composables/element/useOverflowObserver.test.ts
Normal file
82
src/composables/element/useOverflowObserver.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
|
||||
|
||||
import { useOverflowObserver } from './useOverflowObserver'
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useMutationObserver: vi.fn(() => ({ stop: vi.fn() })),
|
||||
useResizeObserver: vi.fn(() => ({ stop: vi.fn() }))
|
||||
}))
|
||||
|
||||
const useMutationObserverMock = vi.mocked(useMutationObserver)
|
||||
const useResizeObserverMock = vi.mocked(useResizeObserver)
|
||||
|
||||
function setElementWidths(
|
||||
element: HTMLElement,
|
||||
widths: { scrollWidth: number; clientWidth: number }
|
||||
) {
|
||||
Object.defineProperty(element, 'scrollWidth', {
|
||||
value: widths.scrollWidth,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(element, 'clientWidth', {
|
||||
value: widths.clientWidth,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
describe('useOverflowObserver', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useMutationObserverMock.mockReturnValue(fromPartial({ stop: vi.fn() }))
|
||||
useResizeObserverMock.mockReturnValue(fromPartial({ stop: vi.fn() }))
|
||||
})
|
||||
|
||||
it('checks overflow immediately when debounce is disabled', () => {
|
||||
const element = document.createElement('div')
|
||||
const onCheck = vi.fn()
|
||||
setElementWidths(element, { scrollWidth: 120, clientWidth: 100 })
|
||||
|
||||
const observer = useOverflowObserver(element, {
|
||||
debounceTime: 0,
|
||||
onCheck
|
||||
})
|
||||
|
||||
observer.checkOverflow()
|
||||
|
||||
expect(observer.isOverflowing.value).toBe(true)
|
||||
expect(onCheck).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('can skip observers and still dispose', () => {
|
||||
const element = document.createElement('div')
|
||||
|
||||
const observer = useOverflowObserver(element, {
|
||||
useMutationObserver: false,
|
||||
useResizeObserver: false
|
||||
})
|
||||
|
||||
observer.dispose()
|
||||
|
||||
expect(observer.disposed.value).toBe(true)
|
||||
expect(useMutationObserverMock).not.toHaveBeenCalled()
|
||||
expect(useResizeObserverMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops enabled observers on dispose', () => {
|
||||
const element = document.createElement('div')
|
||||
const stopMutation = vi.fn()
|
||||
const stopResize = vi.fn()
|
||||
useMutationObserverMock.mockReturnValue(fromPartial({ stop: stopMutation }))
|
||||
useResizeObserverMock.mockReturnValue(fromPartial({ stop: stopResize }))
|
||||
|
||||
const observer = useOverflowObserver(element)
|
||||
|
||||
observer.dispose()
|
||||
|
||||
expect(stopMutation).toHaveBeenCalledOnce()
|
||||
expect(stopResize).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
import {
|
||||
@@ -360,5 +360,203 @@ describe('contextMenuConverter', () => {
|
||||
)
|
||||
expect(hasExtensionsCategory).toBe(true)
|
||||
})
|
||||
|
||||
it('skips items without content and duplicate equivalents', () => {
|
||||
const result = convertContextMenuToOptions(
|
||||
[
|
||||
{ content: '', callback: () => {} },
|
||||
{ content: 'Duplicate', callback: () => {} },
|
||||
{ content: 'Clone', callback: () => {} }
|
||||
],
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
|
||||
expect(result.map((option) => option.label)).toEqual(['Duplicate'])
|
||||
})
|
||||
|
||||
it('wraps callbacks and reports callback errors', () => {
|
||||
const callback = vi.fn()
|
||||
const error = new Error('callback failed')
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const result = convertContextMenuToOptions(
|
||||
[
|
||||
{ content: 'Run', value: 'run-value', callback },
|
||||
{
|
||||
content: 'Broken',
|
||||
callback: () => {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ content: 'Disabled', disabled: true, callback: () => {} }
|
||||
],
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
|
||||
result[0].action?.()
|
||||
result[1].action?.()
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(
|
||||
'run-value',
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ content: 'Run' })
|
||||
)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Error executing context menu callback:',
|
||||
error
|
||||
)
|
||||
expect(result[2].action).toBeUndefined()
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('converts static submenus and submenu callbacks', () => {
|
||||
const submenuCallback = vi.fn()
|
||||
const error = new Error('submenu failed')
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const result = convertContextMenuToOptions(
|
||||
[
|
||||
{
|
||||
content: 'Static Submenu',
|
||||
has_submenu: true,
|
||||
submenu: {
|
||||
options: [
|
||||
'<b>ignored string without callback</b>',
|
||||
null,
|
||||
{
|
||||
content: '<b>Choice</b>',
|
||||
value: 'choice',
|
||||
callback: submenuCallback
|
||||
},
|
||||
{
|
||||
content: '<i>Disabled</i>',
|
||||
disabled: true
|
||||
},
|
||||
{
|
||||
content: '<span>Broken</span>',
|
||||
callback: () => {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ content: '' }
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
|
||||
const submenu = result[0].submenu ?? []
|
||||
expect(result[0].hasSubmenu).toBe(true)
|
||||
expect(submenu.map((option) => option.label)).toEqual([
|
||||
'<b>ignored string without callback</b>',
|
||||
'Choice',
|
||||
'Disabled',
|
||||
'Broken'
|
||||
])
|
||||
expect(submenu[2].disabled).toBe(true)
|
||||
|
||||
submenu[1].action?.()
|
||||
submenu[3].action?.()
|
||||
|
||||
expect(submenuCallback).toHaveBeenCalledWith(
|
||||
'choice',
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ content: '<b>Choice</b>' })
|
||||
)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Error executing submenu callback:',
|
||||
error
|
||||
)
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('captures dynamic submenus created by callbacks', () => {
|
||||
const stringCallback = vi.fn()
|
||||
const objectCallback = vi.fn()
|
||||
const result = convertContextMenuToOptions(
|
||||
[
|
||||
{
|
||||
content: 'Dynamic Submenu',
|
||||
has_submenu: true,
|
||||
callback: () => {
|
||||
new LiteGraph.ContextMenu(
|
||||
[
|
||||
'Auto',
|
||||
{
|
||||
content: '<b>Object choice</b>',
|
||||
value: 'object',
|
||||
callback: objectCallback
|
||||
}
|
||||
],
|
||||
{ callback: stringCallback, extra: { source: 'test' } }
|
||||
)
|
||||
}
|
||||
}
|
||||
],
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
|
||||
const submenu = result[0].submenu ?? []
|
||||
expect(result[0].hasSubmenu).toBe(true)
|
||||
expect(submenu.map((option) => option.label)).toEqual([
|
||||
'Auto',
|
||||
'Object choice'
|
||||
])
|
||||
|
||||
submenu[0].action?.()
|
||||
submenu[1].action?.()
|
||||
|
||||
expect(stringCallback).toHaveBeenCalledWith(
|
||||
'Auto',
|
||||
expect.objectContaining({ extra: { source: 'test' } }),
|
||||
undefined,
|
||||
undefined,
|
||||
{ source: 'test' }
|
||||
)
|
||||
expect(objectCallback).toHaveBeenCalledWith(
|
||||
'object',
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ content: '<b>Object choice</b>' })
|
||||
)
|
||||
})
|
||||
|
||||
it('warns when dynamic submenu callbacks fail to provide items', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const result = convertContextMenuToOptions(
|
||||
[
|
||||
{
|
||||
content: 'Empty Dynamic Submenu',
|
||||
has_submenu: true,
|
||||
callback: () => {}
|
||||
}
|
||||
],
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
|
||||
expect(result[0].hasSubmenu).toBe(true)
|
||||
expect(result[0].submenu).toBeUndefined()
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[ContextMenuConverter] No items captured for:',
|
||||
'Empty Dynamic Submenu'
|
||||
)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[ContextMenuConverter] Failed to capture submenu for:',
|
||||
'Empty Dynamic Submenu'
|
||||
)
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ import * as missingModelScan from '@/platform/missingModel/missingModelScan'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
@@ -130,6 +131,39 @@ describe('Connection error clearing via onConnectionsChange', () => {
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not clear errors when a connected input has no root graph', () => {
|
||||
const { graph, node } = createGraphWithInput()
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'clip'
|
||||
)
|
||||
|
||||
node.onConnectionsChange!(NodeSlotType.INPUT, 0, true, null, node.inputs[0])
|
||||
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not clear errors when a connected input has no slot name', () => {
|
||||
const { graph, node } = createGraphWithInput()
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
createNodeExecutionId([node.id]),
|
||||
'clip'
|
||||
)
|
||||
|
||||
node.onConnectionsChange!(NodeSlotType.INPUT, 12, true, null, fromAny(null))
|
||||
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('clears errors for pure input slots without widget property', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
@@ -252,6 +286,36 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not clear errors when the host execution id is unavailable', () => {
|
||||
const graph = new LGraph()
|
||||
const otherGraph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('number', 'steps', 20, () => undefined, {})
|
||||
graph.add(node)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(otherGraph)
|
||||
store.lastNodeErrors = {
|
||||
[String(node.id)]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: 'Too big',
|
||||
details: '',
|
||||
extra_info: { input_name: 'steps' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'TestNode'
|
||||
}
|
||||
}
|
||||
|
||||
node.onWidgetChanged!.call(node, 'steps', 50, 20, node.widgets![0])
|
||||
|
||||
expect(store.lastNodeErrors).not.toBeNull()
|
||||
})
|
||||
|
||||
it('clears missing media when an upload emits onWidgetChanged', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('LoadImage')
|
||||
@@ -391,6 +455,124 @@ describe('installErrorClearingHooks lifecycle', () => {
|
||||
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
|
||||
})
|
||||
|
||||
it('removes unhooked nodes without restoring callbacks', () => {
|
||||
const graph = new LGraph()
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('late')
|
||||
expect(() => graph.onNodeRemoved!(node)).not.toThrow()
|
||||
expect(node.onConnectionsChange).toBeUndefined()
|
||||
expect(node.onWidgetChanged).toBeUndefined()
|
||||
})
|
||||
|
||||
it('restores recursively installed callbacks on subgraph cleanup', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const innerNode = new LGraphNode('inner')
|
||||
const originalOnConnectionsChange = vi.fn()
|
||||
const originalOnWidgetChanged = vi.fn()
|
||||
innerNode.onConnectionsChange = originalOnConnectionsChange
|
||||
innerNode.onWidgetChanged = originalOnWidgetChanged
|
||||
subgraph.add(innerNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const graph = subgraph.rootGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const cleanup = installErrorClearingHooks(graph)
|
||||
|
||||
expect(innerNode.onConnectionsChange).not.toBe(originalOnConnectionsChange)
|
||||
expect(innerNode.onWidgetChanged).not.toBe(originalOnWidgetChanged)
|
||||
|
||||
cleanup()
|
||||
|
||||
expect(innerNode.onConnectionsChange).toBe(originalOnConnectionsChange)
|
||||
expect(innerNode.onWidgetChanged).toBe(originalOnWidgetChanged)
|
||||
})
|
||||
|
||||
it('restores undefined graph hooks when cleanup is called', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const cleanup = installErrorClearingHooks(graph)
|
||||
cleanup()
|
||||
|
||||
expect(graph.onNodeAdded).toBeUndefined()
|
||||
expect(graph.onNodeRemoved).toBeUndefined()
|
||||
expect(graph.onTrigger).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls original graph hooks for added, removed, and trigger events', () => {
|
||||
const graph = new LGraph()
|
||||
const onNodeAdded = vi.fn()
|
||||
const onNodeRemoved = vi.fn()
|
||||
const onTrigger = vi.fn()
|
||||
graph.onNodeAdded = onNodeAdded
|
||||
graph.onNodeRemoved = onNodeRemoved
|
||||
graph.onTrigger = onTrigger
|
||||
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('test')
|
||||
graph.onNodeAdded!(node)
|
||||
graph.onNodeRemoved!(node)
|
||||
graph.onTrigger!({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'title',
|
||||
oldValue: 'old',
|
||||
newValue: 'new'
|
||||
})
|
||||
|
||||
expect(onNodeAdded).toHaveBeenCalledWith(node)
|
||||
expect(onNodeRemoved).toHaveBeenCalledWith(node)
|
||||
expect(onTrigger).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ property: 'title' })
|
||||
)
|
||||
})
|
||||
|
||||
it('skips scanning added nodes while graph loading is in progress', async () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
vi.spyOn(ChangeTracker, 'isLoadingGraph', 'get').mockReturnValue(true)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
graph.add(node)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips scanning added nodes when root graph is unavailable', async () => {
|
||||
const graph = new LGraph()
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
const mediaScan = vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
graph.add(new LGraphNode('CheckpointLoaderSimple'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
expect(mediaScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips scanning added inactive nodes', async () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
node.mode = LGraphEventMode.BYPASS
|
||||
graph.add(node)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('scans added-node missing models after widget values are restored', async () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
@@ -734,6 +916,84 @@ describe('realtime scan verifies pending cloud candidates', () => {
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('logs pending model verification failures without surfacing candidates', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: String(node.id),
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: true,
|
||||
name: 'broken.safetensors',
|
||||
isMissing: undefined
|
||||
}
|
||||
])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
const verifySpy = vi
|
||||
.spyOn(missingModelScan, 'verifyAssetSupportedCandidates')
|
||||
.mockRejectedValue(new Error('nope'))
|
||||
const warnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
|
||||
await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledOnce())
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('logs pending media verification failures without surfacing candidates', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: String(node.id),
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'broken.png',
|
||||
isMissing: undefined
|
||||
}
|
||||
])
|
||||
const verifySpy = vi
|
||||
.spyOn(missingMediaScan, 'verifyMediaCandidates')
|
||||
.mockRejectedValue(new Error('nope'))
|
||||
const warnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
|
||||
await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledOnce())
|
||||
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('realtime verification staleness guards', () => {
|
||||
@@ -893,6 +1153,54 @@ describe('realtime verification staleness guards', () => {
|
||||
// result must not be added to the store.
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('skips adding verified media when rootGraph switched before verification resolved', async () => {
|
||||
const graphA = new LGraph()
|
||||
const nodeA = new LGraphNode('LoadImage')
|
||||
graphA.add(nodeA)
|
||||
const rootSpy = vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graphA)
|
||||
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: String(nodeA.id),
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'stale_from_A.png',
|
||||
isMissing: undefined
|
||||
}
|
||||
])
|
||||
let resolveVerify: (() => void) | undefined
|
||||
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
|
||||
const verifySpy = vi
|
||||
.spyOn(missingMediaScan, 'verifyMediaCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
await verifyPromise
|
||||
for (const c of candidates) c.isMissing = true
|
||||
})
|
||||
|
||||
installErrorClearingHooks(graphA)
|
||||
|
||||
nodeA.mode = LGraphEventMode.ALWAYS
|
||||
graphA.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: nodeA.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
|
||||
|
||||
const graphB = new LGraph()
|
||||
graphB.add(new LGraphNode('LoadImage'))
|
||||
rootSpy.mockReturnValue(graphB)
|
||||
|
||||
resolveVerify!()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
@@ -1004,6 +1312,167 @@ describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('skips inactive descendants during subgraph replay scans', async () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = createTestSubgraph({ rootGraph })
|
||||
const activeNode = new LGraphNode('UNETLoader')
|
||||
const bypassedNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
bypassedNode.mode = LGraphEventMode.BYPASS
|
||||
subgraph.add(activeNode)
|
||||
subgraph.add(bypassedNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
parentGraph: rootGraph,
|
||||
id: 205
|
||||
})
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
const modelScanSpy = vi
|
||||
.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
.mockReturnValue([])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
|
||||
installErrorClearingHooks(rootGraph)
|
||||
|
||||
rootGraph.onNodeAdded?.(subgraphNode)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(modelScanSpy).toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
activeNode,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(modelScanSpy).not.toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
bypassedNode,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces missing node errors from the Unknown fallback type', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.type = fromAny<LGraphNode['type'], unknown>(undefined)
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
expect(useMissingNodesErrorStore().missingNodesError?.nodeTypes).toEqual([
|
||||
expect.objectContaining({ type: 'Unknown', nodeId: String(node.id) })
|
||||
])
|
||||
})
|
||||
|
||||
it('does not show the overlay when un-bypass finds no missing errors', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const node = createTestSubgraphNode(subgraph)
|
||||
const graph = subgraph.rootGraph
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
const showOverlay = vi.spyOn(useExecutionErrorStore(), 'showErrorOverlay')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
expect(showOverlay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores mode changes that do not change active state', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.ALWAYS,
|
||||
newValue: LGraphEventMode.ON_EVENT
|
||||
})
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores mode changes for missing local nodes', () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: 999,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores mode changes when root graph is unavailable', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
graph.add(node)
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores mode changes when the local node has no root execution id', () => {
|
||||
const graph = new LGraph()
|
||||
const rootGraph = new LGraph()
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
const modelScan = vi.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
expect(modelScan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes host-keyed promoted missing models when a source ancestor is bypassed', () => {
|
||||
const { rootGraph, outerSubgraph, innerSubgraphNode } =
|
||||
createNestedSubgraphRuntime()
|
||||
|
||||
124
src/composables/graph/useFrameNodes.test.ts
Normal file
124
src/composables/graph/useFrameNodes.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockSelectionState = vi.hoisted(() => ({
|
||||
refs: null as null | {
|
||||
hasMultipleSelection: { value: boolean }
|
||||
}
|
||||
}))
|
||||
|
||||
const mockSettingStore = vi.hoisted(() => ({
|
||||
get: vi.fn()
|
||||
}))
|
||||
|
||||
const mockTitleEditorStore = vi.hoisted(() => ({
|
||||
titleEditorTarget: null as null | object
|
||||
}))
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
selectedItems: new Set<object>(),
|
||||
graph: {
|
||||
add: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const mockGroups = vi.hoisted(() => ({
|
||||
instances: [] as Array<{
|
||||
resizeTo: ReturnType<typeof vi.fn>
|
||||
}>
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionState', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const hasMultipleSelection = ref(false)
|
||||
mockSelectionState.refs = {
|
||||
hasMultipleSelection
|
||||
}
|
||||
|
||||
return {
|
||||
useSelectionState: () => ({
|
||||
hasMultipleSelection
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useTitleEditorStore: () => mockTitleEditorStore
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: mockApp
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LGraphGroup: class MockLGraphGroup {
|
||||
resizeTo = vi.fn()
|
||||
|
||||
constructor() {
|
||||
mockGroups.instances.push(this)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useFrameNodes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
if (mockSelectionState.refs) {
|
||||
mockSelectionState.refs.hasMultipleSelection.value = false
|
||||
}
|
||||
mockSettingStore.get.mockReturnValue(24)
|
||||
mockTitleEditorStore.titleEditorTarget = null
|
||||
mockApp.canvas.selectedItems = new Set()
|
||||
mockApp.canvas.graph = {
|
||||
add: vi.fn()
|
||||
}
|
||||
mockGroups.instances = []
|
||||
})
|
||||
|
||||
it('exposes whether selected nodes can be framed', async () => {
|
||||
const { useFrameNodes } = await import('./useFrameNodes')
|
||||
const { canFrame } = useFrameNodes()
|
||||
|
||||
expect(canFrame.value).toBe(false)
|
||||
|
||||
if (!mockSelectionState.refs) {
|
||||
throw new Error('selection refs were not initialized')
|
||||
}
|
||||
mockSelectionState.refs.hasMultipleSelection.value = true
|
||||
|
||||
expect(canFrame.value).toBe(true)
|
||||
})
|
||||
|
||||
it('does nothing when no items are selected', async () => {
|
||||
const { useFrameNodes } = await import('./useFrameNodes')
|
||||
const { frameNodes } = useFrameNodes()
|
||||
|
||||
frameNodes()
|
||||
|
||||
expect(mockGroups.instances).toHaveLength(0)
|
||||
expect(mockApp.canvas.graph.add).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('frames selected items and opens the title editor on the new group', async () => {
|
||||
const selectedNode = {}
|
||||
mockApp.canvas.selectedItems = new Set([selectedNode])
|
||||
|
||||
const { useFrameNodes } = await import('./useFrameNodes')
|
||||
const { frameNodes } = useFrameNodes()
|
||||
|
||||
frameNodes()
|
||||
|
||||
const group = mockGroups.instances[0]
|
||||
expect(group.resizeTo).toHaveBeenCalledWith(
|
||||
mockApp.canvas.selectedItems,
|
||||
24
|
||||
)
|
||||
expect(mockApp.canvas.graph.add).toHaveBeenCalledWith(group)
|
||||
expect(mockTitleEditorStore.titleEditorTarget).toBe(group)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,14 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import {
|
||||
extractVueNodeData,
|
||||
getControlWidget,
|
||||
useGraphNodeManager
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
import {
|
||||
@@ -14,8 +19,10 @@ import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
describe('Node Reactivity', () => {
|
||||
beforeEach(() => {
|
||||
@@ -263,6 +270,26 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
|
||||
expect(widgetData.slotMetadata).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps widget slot metadata even when the input slot name is empty', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
|
||||
const input = node.addInput('', 'STRING')
|
||||
input.widget = { name: 'prompt' }
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgetData = vueNodeData
|
||||
.get(node.id)
|
||||
?.widgets?.find((w) => w.name === 'prompt')
|
||||
|
||||
expect(widgetData?.slotMetadata).toMatchObject({
|
||||
index: 0,
|
||||
linked: false,
|
||||
type: 'STRING'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph output slot label reactivity', () => {
|
||||
@@ -756,3 +783,535 @@ describe('Pre-remove vueNodeData drain', () => {
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graph node manager property triggers', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('updates Vue node data for LiteGraph property change events', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'title',
|
||||
newValue: 'Renamed'
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'has_errors',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'flags.collapsed',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'flags.ghost',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'flags.pinned',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
newValue: 4
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'color',
|
||||
newValue: '#123456'
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'bgcolor',
|
||||
newValue: '#abcdef'
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'shape',
|
||||
newValue: 2
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'showAdvanced',
|
||||
newValue: true
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'badges',
|
||||
newValue: [{ text: 'hot' }]
|
||||
})
|
||||
|
||||
expect(vueNodeData.get(node.id)).toMatchObject({
|
||||
title: 'Renamed',
|
||||
hasErrors: true,
|
||||
flags: {
|
||||
collapsed: true,
|
||||
ghost: true,
|
||||
pinned: true
|
||||
},
|
||||
mode: 4,
|
||||
color: '#123456',
|
||||
bgcolor: '#abcdef',
|
||||
shape: 2,
|
||||
showAdvanced: true,
|
||||
badges: [{ text: 'hot' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes invalid property payloads to safe Vue node data', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
newValue: 'invalid'
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'color',
|
||||
newValue: false
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'bgcolor',
|
||||
newValue: 123
|
||||
})
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'shape',
|
||||
newValue: 'round'
|
||||
})
|
||||
|
||||
expect(vueNodeData.get(node.id)).toMatchObject({
|
||||
mode: 0,
|
||||
color: undefined,
|
||||
bgcolor: undefined,
|
||||
shape: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores property events for nodes the manager does not track', () => {
|
||||
const graph = new LGraph()
|
||||
useGraphNodeManager(graph)
|
||||
|
||||
expect(() =>
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: 'missing',
|
||||
property: 'title',
|
||||
newValue: 'ignored'
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('ignores non-input slot link events and refreshes slot error metadata', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addWidget('string', 'prompt', 'hello', () => undefined)
|
||||
const input = node.addInput('prompt', 'STRING')
|
||||
input.widget = { name: 'prompt' }
|
||||
graph.add(node)
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgetData = vueNodeData
|
||||
.get(node.id)
|
||||
?.widgets?.find((w) => w.name === 'prompt')
|
||||
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.OUTPUT
|
||||
})
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(false)
|
||||
|
||||
input.link = fromAny(123)
|
||||
graph.trigger('node:slot-errors:changed', {
|
||||
nodeId: node.id
|
||||
})
|
||||
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractVueNodeData widget mapping', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('normalizes widget callback values and redraws sibling widgets', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
const callback = vi.fn()
|
||||
const siblingTriggerDraw = vi.fn()
|
||||
node.addWidget('string', 'prompt', 'hello', callback)
|
||||
node.addCustomWidget(
|
||||
fromAny<IBaseWidget, unknown>({
|
||||
name: 'sibling',
|
||||
type: 'text',
|
||||
value: '',
|
||||
options: {},
|
||||
triggerDraw: siblingTriggerDraw
|
||||
})
|
||||
)
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgetData = vueNodeData
|
||||
.get(node.id)
|
||||
?.widgets?.find((widget) => widget.name === 'prompt')
|
||||
if (!widgetData?.callback) throw new Error('Missing widget callback')
|
||||
|
||||
widgetData.callback(null)
|
||||
expect(node.widgets![0].value).toBeUndefined()
|
||||
|
||||
widgetData.callback('text')
|
||||
expect(node.widgets![0].value).toBe('text')
|
||||
|
||||
widgetData.callback(3)
|
||||
expect(node.widgets![0].value).toBe(3)
|
||||
|
||||
widgetData.callback(true)
|
||||
expect(node.widgets![0].value).toBe(true)
|
||||
|
||||
const objectValue = { nested: true }
|
||||
widgetData.callback(objectValue)
|
||||
expect(node.widgets![0].value).toStrictEqual(objectValue)
|
||||
|
||||
const fileValues = [new File(['x'], 'x.txt')]
|
||||
widgetData.callback(fileValues)
|
||||
expect(node.widgets![0].value).toHaveLength(1)
|
||||
expect((node.widgets![0].value as File[])[0]).toBeInstanceOf(File)
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
widgetData.callback(Symbol('invalid'))
|
||||
|
||||
expect(node.widgets![0].value).toBeUndefined()
|
||||
expect(callback).toHaveBeenLastCalledWith(undefined, app.canvas, node)
|
||||
expect(siblingTriggerDraw).toHaveBeenCalled()
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Invalid widget value type: symbol',
|
||||
expect.any(Symbol)
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('extracts display, DOM, layout, tooltip, and duplicate widget metadata', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addCustomWidget({
|
||||
name: 'plain',
|
||||
type: 'text',
|
||||
value: 'a'
|
||||
} as IBaseWidget)
|
||||
node.addCustomWidget(
|
||||
fromAny<IBaseWidget, unknown>({
|
||||
name: 'plain',
|
||||
type: 'text',
|
||||
value: 'b',
|
||||
advanced: true,
|
||||
element: document.createElement('input'),
|
||||
computeLayoutSize: () => ({ minWidth: 1, minHeight: 1 }),
|
||||
options: {
|
||||
canvasOnly: true,
|
||||
hidden: true,
|
||||
read_only: true
|
||||
},
|
||||
tooltip: 'Details'
|
||||
})
|
||||
)
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const widgets = vueNodeData.get(node.id)?.widgets
|
||||
|
||||
expect(widgets?.[0]?.options).toBeUndefined()
|
||||
expect(widgets?.[1]).toMatchObject({
|
||||
name: 'plain',
|
||||
type: 'text',
|
||||
hasLayoutSize: true,
|
||||
isDOMWidget: true,
|
||||
tooltip: 'Details',
|
||||
options: {
|
||||
canvasOnly: true,
|
||||
advanced: true,
|
||||
hidden: true,
|
||||
read_only: true
|
||||
}
|
||||
})
|
||||
expect(widgets?.[0]?.widgetId).toBeDefined()
|
||||
expect(widgets?.[1]?.widgetId).toBeDefined()
|
||||
})
|
||||
|
||||
it('falls back to safe widget data when a widget mapper throws', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const node = new LGraphNode('test')
|
||||
const badWidget = fromAny<IBaseWidget, unknown>({
|
||||
name: 'broken',
|
||||
type: 'custom',
|
||||
value: 'x',
|
||||
get options() {
|
||||
throw new Error('bad options')
|
||||
}
|
||||
})
|
||||
node.widgets = [badWidget]
|
||||
|
||||
const data = extractVueNodeData(node)
|
||||
|
||||
expect(data.widgets?.[0]).toEqual({ name: 'broken', type: 'custom' })
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[safeWidgetMapper] Failed to map widget:',
|
||||
'broken',
|
||||
expect.any(Error)
|
||||
)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('falls back to unknown widget data when a broken widget has no name or type', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const node = new LGraphNode('test')
|
||||
const badWidget = fromAny<IBaseWidget, unknown>({
|
||||
value: 'x',
|
||||
get options() {
|
||||
throw new Error('bad options')
|
||||
}
|
||||
})
|
||||
node.widgets = [badWidget]
|
||||
|
||||
const data = extractVueNodeData(node)
|
||||
|
||||
expect(data.widgets?.[0]).toEqual({ name: 'unknown', type: 'text' })
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps custom widgets getter results in sync', () => {
|
||||
const node = new LGraphNode('test')
|
||||
let widgets = [
|
||||
{
|
||||
name: 'first',
|
||||
type: 'text',
|
||||
value: 'one',
|
||||
options: {}
|
||||
} as IBaseWidget
|
||||
]
|
||||
Object.defineProperty(node, 'widgets', {
|
||||
get() {
|
||||
return widgets
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const data = extractVueNodeData(node)
|
||||
expect(data.widgets?.map((widget) => widget.name)).toEqual(['first'])
|
||||
|
||||
widgets = [
|
||||
{
|
||||
name: 'second',
|
||||
type: 'text',
|
||||
value: 'two',
|
||||
options: {}
|
||||
} as IBaseWidget
|
||||
]
|
||||
|
||||
expect(node.widgets?.map((widget) => widget.name)).toEqual(['second'])
|
||||
expect(data.widgets?.map((widget) => widget.name)).toEqual(['second'])
|
||||
})
|
||||
|
||||
it('treats undefined custom widget getter results as an empty widget list', () => {
|
||||
const node = new LGraphNode('test')
|
||||
Object.defineProperty(node, 'widgets', {
|
||||
get() {
|
||||
return undefined
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const data = extractVueNodeData(node)
|
||||
|
||||
expect(data.widgets?.length).toBe(0)
|
||||
})
|
||||
|
||||
it('derives node type fallbacks and subgraph id from graph context', () => {
|
||||
const node = new LGraphNode('')
|
||||
node.type = ''
|
||||
Object.defineProperty(node, 'constructor', {
|
||||
value: { title: 'FallbackTitle', nodeData: { api_node: true } },
|
||||
configurable: true
|
||||
})
|
||||
node.graph = {
|
||||
id: 'subgraph-id',
|
||||
rootGraph: new LGraph()
|
||||
} as LGraph
|
||||
|
||||
const data = extractVueNodeData(node)
|
||||
|
||||
expect(data.type).toBe('FallbackTitle')
|
||||
expect(data.subgraphId).toBe('subgraph-id')
|
||||
expect(data.apiNode).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves flags when extracting Vue node data', () => {
|
||||
const node = new LGraphNode('test')
|
||||
node.flags = { collapsed: true, pinned: true }
|
||||
|
||||
const data = extractVueNodeData(node)
|
||||
|
||||
expect(data.flags).toEqual({ collapsed: true, pinned: true })
|
||||
})
|
||||
|
||||
it('keeps existing promoted widget state when mapping host widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'ckpt_input', type: '*' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
const interiorInput = interiorNode.addInput('ckpt_input', '*')
|
||||
interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'source.safetensors',
|
||||
() => undefined,
|
||||
{
|
||||
values: ['source.safetensors']
|
||||
}
|
||||
)
|
||||
interiorInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
const id = subgraphNode.inputs[0].widgetId
|
||||
if (!id) throw new Error('Expected promoted input to have widgetId')
|
||||
const widgetStore = useWidgetValueStore()
|
||||
if (widgetStore.getWidget(id)) {
|
||||
widgetStore.setValue(id, 'existing.safetensors')
|
||||
} else {
|
||||
widgetStore.registerWidget(id, {
|
||||
type: 'combo',
|
||||
value: 'existing.safetensors',
|
||||
options: {},
|
||||
label: 'Existing'
|
||||
})
|
||||
}
|
||||
|
||||
useGraphNodeManager(graph)
|
||||
|
||||
expect(widgetStore.getWidget(id)?.value).toBe('existing.safetensors')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Graph node manager lifecycle hooks', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('defers layout extraction until graph configuration completes', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.title = 'Before'
|
||||
const originalOnNodeAdded = vi.fn()
|
||||
const originalAfterConfigured = vi.fn()
|
||||
graph.onNodeAdded = originalOnNodeAdded
|
||||
node.onAfterGraphConfigured = originalAfterConfigured
|
||||
const originalWindowApp = window.app
|
||||
window.app = { configuringGraph: true } as Window['app']
|
||||
|
||||
try {
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
graph.add(node)
|
||||
|
||||
expect(originalOnNodeAdded).toHaveBeenCalledWith(node)
|
||||
expect(vueNodeData.get(node.id)?.title).toBe('Before')
|
||||
|
||||
node.title = 'After'
|
||||
node.onAfterGraphConfigured?.()
|
||||
|
||||
expect(originalAfterConfigured).toHaveBeenCalled()
|
||||
expect(vueNodeData.get(node.id)?.title).toBe('After')
|
||||
} finally {
|
||||
window.app = originalWindowApp
|
||||
}
|
||||
})
|
||||
|
||||
it('chains original remove and trigger handlers, then restores them on cleanup', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
const originalOnNodeAdded = vi.fn()
|
||||
const originalOnNodeRemoved = vi.fn()
|
||||
const originalOnTrigger = vi.fn()
|
||||
graph.onNodeAdded = originalOnNodeAdded
|
||||
graph.onNodeRemoved = originalOnNodeRemoved
|
||||
graph.onTrigger = originalOnTrigger
|
||||
|
||||
const manager = useGraphNodeManager(graph)
|
||||
graph.add(node)
|
||||
graph.trigger('node:property:changed', {
|
||||
nodeId: node.id,
|
||||
property: 'title',
|
||||
newValue: 'Renamed'
|
||||
})
|
||||
graph.remove(node)
|
||||
|
||||
expect(originalOnNodeAdded).toHaveBeenCalledWith(node)
|
||||
expect(originalOnTrigger).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'node:property:changed' })
|
||||
)
|
||||
expect(originalOnNodeRemoved).toHaveBeenCalledWith(node)
|
||||
expect(manager.vueNodeData.size).toBe(0)
|
||||
|
||||
manager.cleanup()
|
||||
|
||||
expect(graph.onNodeAdded).toBe(originalOnNodeAdded)
|
||||
expect(graph.onNodeRemoved).toBe(originalOnNodeRemoved)
|
||||
expect(graph.onTrigger).toBe(originalOnTrigger)
|
||||
})
|
||||
|
||||
it('cleans up to undefined when no original callbacks existed', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
graph.add(node)
|
||||
|
||||
const manager = useGraphNodeManager(graph)
|
||||
expect(manager.vueNodeData.has(node.id)).toBe(true)
|
||||
|
||||
manager.cleanup()
|
||||
|
||||
expect(graph.onNodeAdded).toBeUndefined()
|
||||
expect(graph.onNodeRemoved).toBeUndefined()
|
||||
expect(graph.onTrigger).toBeUndefined()
|
||||
expect(manager.vueNodeData.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getControlWidget', () => {
|
||||
it('normalizes linked control widget values and updates the source widget', () => {
|
||||
const linkedControl = {
|
||||
[IS_CONTROL_WIDGET]: true,
|
||||
value: 'fixed'
|
||||
}
|
||||
const widget = {
|
||||
linkedWidgets: [linkedControl]
|
||||
} as unknown as IBaseWidget
|
||||
|
||||
const control = getControlWidget(widget)
|
||||
|
||||
expect(control?.value).toBe('fixed')
|
||||
|
||||
control?.update('unexpected')
|
||||
|
||||
expect(linkedControl.value).toBe('randomize')
|
||||
})
|
||||
})
|
||||
|
||||
215
src/composables/graph/useGroupMenuOptions.test.ts
Normal file
215
src/composables/graph/useGroupMenuOptions.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import type * as VueI18n from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useGroupMenuOptions } from '@/composables/graph/useGroupMenuOptions'
|
||||
|
||||
const { canvas, captureCanvasState, isLightTheme, refreshCanvas, settings } =
|
||||
vi.hoisted(() => ({
|
||||
canvas: { setDirty: vi.fn() },
|
||||
captureCanvasState: vi.fn(),
|
||||
isLightTheme: { value: false },
|
||||
refreshCanvas: vi.fn(),
|
||||
settings: { 'Comfy.GroupSelectedNodes.Padding': 10 } as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof VueI18n>()),
|
||||
useI18n: () => ({ t: (key: string) => key })
|
||||
}))
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: (k: string) => settings[k] })
|
||||
}))
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: { changeTracker: { captureCanvasState } }
|
||||
})
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({ canvas })
|
||||
}))
|
||||
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
|
||||
useCanvasRefresh: () => ({ refreshCanvas })
|
||||
}))
|
||||
vi.mock('@/composables/graph/useNodeCustomization', () => ({
|
||||
useNodeCustomization: () => ({
|
||||
shapeOptions: [{ value: 1, localizedName: 'Box' }],
|
||||
colorOptions: [
|
||||
{ value: { dark: '#111', light: '#eee' }, localizedName: 'Red' }
|
||||
],
|
||||
isLightTheme
|
||||
})
|
||||
}))
|
||||
|
||||
function group(over: Record<string, unknown> = {}): LGraphGroup {
|
||||
return {
|
||||
recomputeInsideNodes: vi.fn(),
|
||||
resizeTo: vi.fn(),
|
||||
children: [],
|
||||
graph: { change: vi.fn() },
|
||||
nodes: [],
|
||||
...over
|
||||
} as unknown as LGraphGroup
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
canvas.setDirty.mockReset()
|
||||
captureCanvasState.mockReset()
|
||||
isLightTheme.value = false
|
||||
refreshCanvas.mockReset()
|
||||
})
|
||||
|
||||
describe('useGroupMenuOptions', () => {
|
||||
it('fits a group to its nodes, resizing with the configured padding', () => {
|
||||
const g = group()
|
||||
useGroupMenuOptions().getFitGroupToNodesOption(g).action?.()
|
||||
|
||||
expect(g.recomputeInsideNodes).toHaveBeenCalled()
|
||||
expect(g.resizeTo).toHaveBeenCalledWith(g.children, 10)
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aborts the fit action when recompute throws', () => {
|
||||
const g = group({
|
||||
recomputeInsideNodes: vi.fn(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
})
|
||||
useGroupMenuOptions().getFitGroupToNodesOption(g).action?.()
|
||||
|
||||
expect(g.resizeTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies a shape to all group nodes via the shape submenu', () => {
|
||||
const node = { shape: 0, mode: LGraphEventMode.ALWAYS }
|
||||
const bump = vi.fn()
|
||||
const option = useGroupMenuOptions().getGroupShapeOptions(
|
||||
group({ nodes: [node] }),
|
||||
bump
|
||||
)
|
||||
option.submenu?.[0].action?.()
|
||||
|
||||
expect(node.shape).toBe(1)
|
||||
expect(refreshCanvas).toHaveBeenCalled()
|
||||
expect(bump).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles shape actions when a group has no nodes array', () => {
|
||||
const bump = vi.fn()
|
||||
useGroupMenuOptions()
|
||||
.getGroupShapeOptions(group({ nodes: undefined }), bump)
|
||||
.submenu?.[0].action?.()
|
||||
|
||||
expect(refreshCanvas).toHaveBeenCalled()
|
||||
expect(bump).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies a color to the group via the color submenu (dark theme)', () => {
|
||||
const g = group()
|
||||
const bump = vi.fn()
|
||||
useGroupMenuOptions().getGroupColorOptions(g, bump).submenu?.[0].action?.()
|
||||
|
||||
expect((g as unknown as { color: string }).color).toBe('#111')
|
||||
expect(bump).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies a light-theme color to the group via the color submenu', () => {
|
||||
const g = group()
|
||||
const bump = vi.fn()
|
||||
isLightTheme.value = true
|
||||
useGroupMenuOptions().getGroupColorOptions(g, bump).submenu?.[0].action?.()
|
||||
|
||||
expect((g as unknown as { color: string }).color).toBe('#eee')
|
||||
expect(bump).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns no mode options for an empty group', () => {
|
||||
expect(useGroupMenuOptions().getGroupModeOptions(group(), vi.fn())).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('returns no mode options when a group has no nodes array', () => {
|
||||
expect(
|
||||
useGroupMenuOptions().getGroupModeOptions(
|
||||
group({ nodes: undefined }),
|
||||
vi.fn()
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('returns no mode options when recomputing group nodes fails', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const options = useGroupMenuOptions().getGroupModeOptions(
|
||||
group({
|
||||
recomputeInsideNodes: vi.fn(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
}),
|
||||
vi.fn()
|
||||
)
|
||||
|
||||
expect(options).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Failed to recompute nodes in group for mode options:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('builds mode options for uniform nodes and applies the new mode', () => {
|
||||
const node = { shape: 0, mode: LGraphEventMode.ALWAYS }
|
||||
const bump = vi.fn()
|
||||
const options = useGroupMenuOptions().getGroupModeOptions(
|
||||
group({ nodes: [node] }),
|
||||
bump
|
||||
)
|
||||
|
||||
expect(options.length).toBeGreaterThan(0)
|
||||
options[0].action?.()
|
||||
expect(node.mode).not.toBe(LGraphEventMode.ALWAYS)
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(bump).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers two alternate modes when all nodes are NEVER', () => {
|
||||
const options = useGroupMenuOptions().getGroupModeOptions(
|
||||
group({ nodes: [{ mode: LGraphEventMode.NEVER }] }),
|
||||
vi.fn()
|
||||
)
|
||||
expect(options).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('offers two alternate modes when all nodes are BYPASS', () => {
|
||||
const options = useGroupMenuOptions().getGroupModeOptions(
|
||||
group({ nodes: [{ mode: LGraphEventMode.BYPASS }] }),
|
||||
vi.fn()
|
||||
)
|
||||
expect(options).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('offers all three modes when nodes have mixed modes', () => {
|
||||
const options = useGroupMenuOptions().getGroupModeOptions(
|
||||
group({
|
||||
nodes: [
|
||||
{ mode: LGraphEventMode.ALWAYS },
|
||||
{ mode: LGraphEventMode.NEVER }
|
||||
]
|
||||
}),
|
||||
vi.fn()
|
||||
)
|
||||
expect(options).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('offers all three modes when the uniform mode is unknown', () => {
|
||||
const options = useGroupMenuOptions().getGroupModeOptions(
|
||||
group({ nodes: [{ mode: 999 }] }),
|
||||
vi.fn()
|
||||
)
|
||||
expect(options).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { useImageMenuOptions } from './useImageMenuOptions'
|
||||
@@ -19,6 +20,11 @@ vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadFile: vi.fn(),
|
||||
openFileInNewTab: vi.fn()
|
||||
}))
|
||||
|
||||
function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: clipboard,
|
||||
@@ -27,6 +33,15 @@ function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
|
||||
})
|
||||
}
|
||||
|
||||
function stubClipboardItem() {
|
||||
vi.stubGlobal(
|
||||
'ClipboardItem',
|
||||
class ClipboardItemStub {
|
||||
constructor(public readonly items: Record<string, Blob>) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function createImageNode(
|
||||
overrides: Partial<LGraphNode> | Record<string, unknown> = {}
|
||||
): LGraphNode {
|
||||
@@ -45,8 +60,13 @@ function createImageNode(
|
||||
}
|
||||
|
||||
describe('useImageMenuOptions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('getImageMenuOptions', () => {
|
||||
@@ -75,6 +95,12 @@ describe('useImageMenuOptions', () => {
|
||||
expect(getImageMenuOptions(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when node image capabilities are absent', () => {
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
|
||||
expect(getImageMenuOptions(fromPartial<LGraphNode>({}))).toEqual([])
|
||||
})
|
||||
|
||||
it('returns only Paste Image when node has no images but supports paste', () => {
|
||||
const node = createMockLGraphNode({
|
||||
imgs: [],
|
||||
@@ -182,4 +208,225 @@ describe('useImageMenuOptions', () => {
|
||||
expect(node.pasteFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('image actions', () => {
|
||||
it('opens the selected image without preview query params', () => {
|
||||
const node = createImageNode()
|
||||
node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar'
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const openOption = getImageMenuOptions(node).find(
|
||||
(o) => o.label === 'Open Image'
|
||||
)
|
||||
openOption?.action?.()
|
||||
|
||||
expect(openFileInNewTab).toHaveBeenCalledWith(
|
||||
'http://localhost/test.png?foo=bar'
|
||||
)
|
||||
})
|
||||
|
||||
it('saves the selected image without preview query params', () => {
|
||||
const node = createImageNode()
|
||||
node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar'
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const saveOption = getImageMenuOptions(node).find(
|
||||
(o) => o.label === 'Save Image'
|
||||
)
|
||||
saveOption?.action?.()
|
||||
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
'http://localhost/test.png?foo=bar'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not open or save when the active image is missing', () => {
|
||||
const node = createImageNode({ imageIndex: 1 })
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
options.find((o) => o.label === 'Open Image')?.action?.()
|
||||
options.find((o) => o.label === 'Save Image')?.action?.()
|
||||
|
||||
expect(openFileInNewTab).not.toHaveBeenCalled()
|
||||
expect(downloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not run image actions when images are cleared after menu creation', async () => {
|
||||
const node = createImageNode()
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
node.imgs = []
|
||||
|
||||
options.find((o) => o.label === 'Open Image')?.action?.()
|
||||
await options.find((o) => o.label === 'Copy Image')?.action?.()
|
||||
options.find((o) => o.label === 'Save Image')?.action?.()
|
||||
|
||||
expect(openFileInNewTab).not.toHaveBeenCalled()
|
||||
expect(downloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not copy when the active image is missing', async () => {
|
||||
const node = createImageNode({ imageIndex: 1 })
|
||||
const write = vi.fn()
|
||||
mockClipboard(fromPartial<Clipboard>({ write }))
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs save failures for invalid image URLs', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const node = createImageNode()
|
||||
Object.defineProperty(node.imgs![0], 'src', {
|
||||
value: 'http://[',
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Save Image')
|
||||
?.action?.()
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Failed to save image:',
|
||||
expect.any(TypeError)
|
||||
)
|
||||
expect(downloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies the selected image to clipboard', async () => {
|
||||
const node = createImageNode()
|
||||
const drawImage = vi.fn()
|
||||
const write = vi.fn().mockResolvedValue(undefined)
|
||||
stubClipboardItem()
|
||||
mockClipboard(fromPartial<Clipboard>({ write }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
(() =>
|
||||
fromPartial<CanvasRenderingContext2D>({
|
||||
drawImage
|
||||
})) as unknown as HTMLCanvasElement['getContext']
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(new Blob(['image'], { type: 'image/png' }))
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(drawImage).toHaveBeenCalledWith(node.imgs![0], 0, 0)
|
||||
expect(write).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
items: { 'image/png': expect.any(Blob) }
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('does not copy when canvas context is unavailable', async () => {
|
||||
const node = createImageNode()
|
||||
const write = vi.fn()
|
||||
mockClipboard(fromPartial<Clipboard>({ write }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
(() => null) as HTMLCanvasElement['getContext']
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not copy when canvas blob creation fails', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const node = createImageNode()
|
||||
const write = vi.fn()
|
||||
mockClipboard(fromPartial<Clipboard>({ write }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
(() =>
|
||||
fromPartial<CanvasRenderingContext2D>({
|
||||
drawImage: vi.fn()
|
||||
})) as unknown as HTMLCanvasElement['getContext']
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(null)
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('Failed to create image blob')
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not copy when clipboard write is unavailable', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const node = createImageNode()
|
||||
mockClipboard(fromPartial<Clipboard>({ write: undefined }))
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
(() =>
|
||||
fromPartial<CanvasRenderingContext2D>({
|
||||
drawImage: vi.fn()
|
||||
})) as unknown as HTMLCanvasElement['getContext']
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(new Blob(['image'], { type: 'image/png' }))
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('Clipboard API not available')
|
||||
})
|
||||
|
||||
it('logs clipboard copy failures', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const node = createImageNode()
|
||||
stubClipboardItem()
|
||||
mockClipboard(
|
||||
fromPartial<Clipboard>({
|
||||
write: vi.fn().mockRejectedValue(new Error('blocked'))
|
||||
})
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
|
||||
(() =>
|
||||
fromPartial<CanvasRenderingContext2D>({
|
||||
drawImage: vi.fn()
|
||||
})) as unknown as HTMLCanvasElement['getContext']
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback: BlobCallback) => {
|
||||
callback(new Blob(['image'], { type: 'image/png' }))
|
||||
}
|
||||
)
|
||||
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
await getImageMenuOptions(node)
|
||||
.find((o) => o.label === 'Copy Image')
|
||||
?.action?.()
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Failed to copy image to clipboard:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
292
src/composables/graph/useMoreOptionsMenu.test.ts
Normal file
292
src/composables/graph/useMoreOptionsMenu.test.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
isNodeOptionsOpen,
|
||||
registerNodeOptionsInstance,
|
||||
showNodeOptions,
|
||||
toggleNodeOptions,
|
||||
useMoreOptionsMenu
|
||||
} from '@/composables/graph/useMoreOptionsMenu'
|
||||
|
||||
const {
|
||||
canvasState,
|
||||
extraWidgetOptions,
|
||||
imageOptions,
|
||||
nodeMenu,
|
||||
selectionMenu,
|
||||
selectionState
|
||||
} = vi.hoisted(() => ({
|
||||
canvasState: {
|
||||
canvas: undefined as
|
||||
| undefined
|
||||
| {
|
||||
getNodeMenuOptions: ReturnType<typeof vi.fn>
|
||||
}
|
||||
},
|
||||
extraWidgetOptions: {
|
||||
value: [] as Array<{ content: string; callback?: () => void }>
|
||||
},
|
||||
imageOptions: {
|
||||
value: [] as Array<{ label: string; hasSubmenu?: boolean; submenu?: [] }>
|
||||
},
|
||||
nodeMenu: {
|
||||
visualOptions: {
|
||||
value: [] as Array<{
|
||||
label: string
|
||||
hasSubmenu?: boolean
|
||||
submenu?: Array<{ label: string; action: () => void }>
|
||||
}>
|
||||
}
|
||||
},
|
||||
selectionMenu: {
|
||||
basicOptions: { value: [{ label: 'Copy' }] },
|
||||
multipleOptions: { value: [{ label: 'Align' }] },
|
||||
subgraphOptions: { value: [] as Array<{ label: string }> }
|
||||
},
|
||||
selectionState: {
|
||||
selectedItems: { value: [] as unknown[] },
|
||||
selectedNodes: { value: [] as unknown[] },
|
||||
canOpenNodeInfo: { value: false },
|
||||
openNodeInfo: vi.fn(() => true),
|
||||
hasSubgraphs: { value: false },
|
||||
hasImageNode: { value: false },
|
||||
hasOutputNodesSelected: { value: false },
|
||||
hasMultipleSelection: { value: false },
|
||||
computeSelectionFlags: vi.fn(() => ({
|
||||
collapsed: false,
|
||||
pinned: false
|
||||
}))
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectionState', () => ({
|
||||
useSelectionState: () => selectionState
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => canvasState
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
getExtraOptionsForWidget: () => extraWidgetOptions.value
|
||||
}))
|
||||
vi.mock('@/composables/graph/useImageMenuOptions', () => ({
|
||||
useImageMenuOptions: () => ({
|
||||
getImageMenuOptions: () => imageOptions.value
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/graph/useNodeMenuOptions', () => ({
|
||||
useNodeMenuOptions: () => ({
|
||||
getNodeInfoOption: (openNodeInfo: () => boolean) => ({
|
||||
label: 'Node Info',
|
||||
action: openNodeInfo
|
||||
}),
|
||||
getNodeVisualOptions: () => nodeMenu.visualOptions.value,
|
||||
getPinOption: () => ({ label: 'Pin' }),
|
||||
getBypassOption: () => ({ label: 'Bypass' }),
|
||||
getRunBranchOption: () => ({ label: 'Run Branch' })
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/graph/useGroupMenuOptions', () => ({
|
||||
useGroupMenuOptions: () => ({
|
||||
getFitGroupToNodesOption: () => ({ label: 'Fit' }),
|
||||
getGroupColorOptions: () => ({ label: 'Group Color' }),
|
||||
getGroupModeOptions: () => [{ label: 'Group Mode' }]
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/graph/useSelectionMenuOptions', () => ({
|
||||
useSelectionMenuOptions: () => ({
|
||||
getBasicSelectionOptions: () => selectionMenu.basicOptions.value,
|
||||
getMultipleNodesOptions: () => selectionMenu.multipleOptions.value,
|
||||
getSubgraphOptions: () => selectionMenu.subgraphOptions.value
|
||||
})
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
registerNodeOptionsInstance(null)
|
||||
canvasState.canvas = undefined
|
||||
extraWidgetOptions.value = []
|
||||
imageOptions.value = []
|
||||
nodeMenu.visualOptions.value = []
|
||||
selectionMenu.basicOptions.value = [{ label: 'Copy' }]
|
||||
selectionMenu.multipleOptions.value = [{ label: 'Align' }]
|
||||
selectionMenu.subgraphOptions.value = []
|
||||
selectionState.selectedItems.value = []
|
||||
selectionState.selectedNodes.value = []
|
||||
selectionState.canOpenNodeInfo.value = false
|
||||
selectionState.hasSubgraphs.value = false
|
||||
selectionState.hasImageNode.value = false
|
||||
selectionState.hasOutputNodesSelected.value = false
|
||||
selectionState.hasMultipleSelection.value = false
|
||||
selectionState.computeSelectionFlags.mockReturnValue({
|
||||
collapsed: false,
|
||||
pinned: false
|
||||
})
|
||||
})
|
||||
|
||||
function labels() {
|
||||
return useMoreOptionsMenu()
|
||||
.menuOptions.value.map((o) => o.label)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
describe('node options popover instance', () => {
|
||||
it('reports closed when no instance is registered', () => {
|
||||
expect(isNodeOptionsOpen()).toBe(false)
|
||||
})
|
||||
|
||||
it('reflects the registered instance open state and forwards toggle/show', () => {
|
||||
const toggle = vi.fn()
|
||||
const show = vi.fn()
|
||||
registerNodeOptionsInstance({
|
||||
toggle,
|
||||
show,
|
||||
hide: vi.fn(),
|
||||
isOpen: ref(true)
|
||||
})
|
||||
|
||||
expect(isNodeOptionsOpen()).toBe(true)
|
||||
toggleNodeOptions(new Event('click'))
|
||||
showNodeOptions(new MouseEvent('contextmenu'))
|
||||
expect(toggle).toHaveBeenCalled()
|
||||
expect(show).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMoreOptionsMenu', () => {
|
||||
it('assembles a non-empty menu for a single selected node', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
|
||||
expect(labels()).toContain('Copy')
|
||||
expect(labels()).toContain('Pin')
|
||||
})
|
||||
|
||||
it('includes run-branch and multiple-node options for output selections', () => {
|
||||
const nodes = [
|
||||
{ id: 1, widgets: [] },
|
||||
{ id: 2, widgets: [] }
|
||||
]
|
||||
selectionState.selectedItems.value = nodes
|
||||
selectionState.selectedNodes.value = nodes
|
||||
selectionState.hasOutputNodesSelected.value = true
|
||||
selectionState.hasMultipleSelection.value = true
|
||||
|
||||
const menuLabels = labels()
|
||||
expect(menuLabels).toContain('Run Branch')
|
||||
expect(menuLabels).toContain('Align')
|
||||
})
|
||||
|
||||
it('recomputes menu flags after a manual bump', () => {
|
||||
const { bump, menuOptions } = useMoreOptionsMenu()
|
||||
void menuOptions.value
|
||||
expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(1)
|
||||
|
||||
bump()
|
||||
void menuOptions.value
|
||||
expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('assembles group-context options for a single selected group', () => {
|
||||
const group = new LGraphGroup('Group')
|
||||
selectionState.selectedItems.value = [group]
|
||||
selectionState.selectedNodes.value = []
|
||||
|
||||
const menuLabels = labels()
|
||||
expect(menuLabels).toContain('Group Mode')
|
||||
expect(menuLabels).toContain('Fit')
|
||||
expect(menuLabels).toContain('Group Color')
|
||||
})
|
||||
|
||||
it('includes node info and visual options for a single node', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
selectionState.canOpenNodeInfo.value = true
|
||||
nodeMenu.visualOptions.value = [
|
||||
{ label: 'Minimize Node' },
|
||||
{ label: 'Shape', hasSubmenu: true, submenu: [] },
|
||||
{ label: 'Color', hasSubmenu: true, submenu: [] }
|
||||
]
|
||||
|
||||
const menu = useMoreOptionsMenu().menuOptions.value
|
||||
expect(menu.map((o) => o.label)).toEqual(
|
||||
expect.arrayContaining(['Node Info', 'Minimize Node', 'Shape', 'Color'])
|
||||
)
|
||||
menu.find((o) => o.label === 'Node Info')?.action?.()
|
||||
expect(selectionState.openNodeInfo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns only entries that have populated submenus', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
nodeMenu.visualOptions.value = [
|
||||
{ label: 'Minimize Node' },
|
||||
{
|
||||
label: 'Shape',
|
||||
hasSubmenu: true,
|
||||
submenu: [{ label: 'Box', action: vi.fn() }]
|
||||
},
|
||||
{ label: 'Color', hasSubmenu: true }
|
||||
]
|
||||
|
||||
expect(
|
||||
useMoreOptionsMenu().menuOptionsWithSubmenu.value.map((o) => o.label)
|
||||
).toEqual(['Shape'])
|
||||
})
|
||||
|
||||
it('includes image menu options for a selected image node', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
selectionState.hasImageNode.value = true
|
||||
imageOptions.value = [{ label: 'Open Image' }]
|
||||
|
||||
expect(labels()).toContain('Open Image')
|
||||
})
|
||||
|
||||
it('merges LiteGraph menu options for a single selected node', () => {
|
||||
const node = { id: 1, widgets: [] }
|
||||
const getNodeMenuOptions = vi.fn(() => [
|
||||
{ content: 'Extension Action', callback: vi.fn() }
|
||||
])
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
canvasState.canvas = { getNodeMenuOptions }
|
||||
|
||||
expect(labels()).toContain('Extension Action')
|
||||
expect(getNodeMenuOptions).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('keeps Vue options when LiteGraph menu construction throws', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const node = { id: 1, widgets: [] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
canvasState.canvas = {
|
||||
getNodeMenuOptions: vi.fn(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
}
|
||||
|
||||
expect(labels()).toContain('Copy')
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Error getting LiteGraph menu items:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('adds hovered widget options to the selected node menu', () => {
|
||||
const node = { id: 1, widgets: [{ name: 'image' }] }
|
||||
selectionState.selectedItems.value = [node]
|
||||
selectionState.selectedNodes.value = [node]
|
||||
extraWidgetOptions.value = [{ content: 'Widget Extra', callback: vi.fn() }]
|
||||
|
||||
showNodeOptions(new MouseEvent('contextmenu'), 'image')
|
||||
|
||||
expect(labels()).toContain('Widget Extra')
|
||||
})
|
||||
})
|
||||
175
src/composables/graph/useNodeCustomization.test.ts
Normal file
175
src/composables/graph/useNodeCustomization.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type * as VueI18n from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'
|
||||
|
||||
const { selection, refreshCanvas, palette } = vi.hoisted(() => ({
|
||||
selection: { items: [] as unknown[] },
|
||||
refreshCanvas: vi.fn(),
|
||||
palette: { light_theme: false }
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof VueI18n>()),
|
||||
useI18n: () => ({ t: (key: string) => key })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
get selectedItems() {
|
||||
return selection.items
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => ({
|
||||
get completedActivePalette() {
|
||||
return { light_theme: palette.light_theme }
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
|
||||
useCanvasRefresh: () => ({ refreshCanvas })
|
||||
}))
|
||||
|
||||
function colorable(bgcolor?: string) {
|
||||
return {
|
||||
setColorOption: vi.fn(),
|
||||
getColorOption: () => (bgcolor ? { bgcolor } : null)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
selection.items = []
|
||||
refreshCanvas.mockReset()
|
||||
palette.light_theme = false
|
||||
})
|
||||
|
||||
describe('useNodeCustomization', () => {
|
||||
it('exposes color and shape option lists', () => {
|
||||
const { colorOptions, shapeOptions } = useNodeCustomization()
|
||||
expect(colorOptions.length).toBeGreaterThan(1)
|
||||
expect(shapeOptions.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('reflects the active palette light-theme flag', () => {
|
||||
palette.light_theme = true
|
||||
expect(useNodeCustomization().isLightTheme.value).toBe(true)
|
||||
})
|
||||
|
||||
it('clears color on all colorable items for the no-color option', () => {
|
||||
const item = colorable()
|
||||
selection.items = [item]
|
||||
useNodeCustomization().applyColor(null)
|
||||
|
||||
expect(item.setColorOption).toHaveBeenCalledWith(null)
|
||||
expect(refreshCanvas).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies a named color option to colorable items', () => {
|
||||
const item = colorable()
|
||||
selection.items = [item]
|
||||
const { colorOptions, applyColor } = useNodeCustomization()
|
||||
const named = colorOptions.at(-1)!
|
||||
|
||||
applyColor(named)
|
||||
|
||||
expect(item.setColorOption).toHaveBeenCalledTimes(1)
|
||||
expect(item.setColorOption.mock.calls[0][0]).not.toBeNull()
|
||||
})
|
||||
|
||||
it('skips non-colorable items when applying colors', () => {
|
||||
const item = colorable()
|
||||
selection.items = [{}, item]
|
||||
|
||||
useNodeCustomization().applyColor(null)
|
||||
|
||||
expect(item.setColorOption).toHaveBeenCalledWith(null)
|
||||
expect(refreshCanvas).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null current color for an empty selection', () => {
|
||||
expect(useNodeCustomization().getCurrentColor()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null current color when no selected item is colorable', () => {
|
||||
selection.items = [{}]
|
||||
expect(useNodeCustomization().getCurrentColor()).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a recognized current color', () => {
|
||||
const { colorOptions, getCurrentColor } = useNodeCustomization()
|
||||
const named = colorOptions.at(-1)!
|
||||
selection.items = [colorable(named.value.dark)]
|
||||
|
||||
expect(getCurrentColor()?.name).toBe(named.name)
|
||||
})
|
||||
|
||||
it('falls back to the no-color option for an unrecognized current color', () => {
|
||||
selection.items = [colorable('#not-a-known-color')]
|
||||
const result = useNodeCustomization().getCurrentColor()
|
||||
expect(result?.name).toBe('noColor')
|
||||
})
|
||||
|
||||
it('no-ops shape changes when no graph nodes are selected', () => {
|
||||
selection.items = [colorable()]
|
||||
const { applyShape, shapeOptions } = useNodeCustomization()
|
||||
applyShape(shapeOptions[0])
|
||||
expect(refreshCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null current shape with no nodes selected', () => {
|
||||
expect(useNodeCustomization().getCurrentShape()).toBeNull()
|
||||
})
|
||||
|
||||
it('applies a shape to selected graph nodes and refreshes', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
selection.items = [node]
|
||||
const { applyShape, shapeOptions } = useNodeCustomization()
|
||||
const target = shapeOptions[0]
|
||||
|
||||
applyShape(target)
|
||||
|
||||
expect(node.shape).toBe(target.value)
|
||||
expect(refreshCanvas).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the current shape of a selected node', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
const { shapeOptions, getCurrentShape } = useNodeCustomization()
|
||||
node.shape = shapeOptions[0].value
|
||||
selection.items = [node]
|
||||
|
||||
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
|
||||
})
|
||||
|
||||
it('uses the default shape when a selected node has no shape', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
Object.defineProperty(node, 'shape', {
|
||||
value: undefined,
|
||||
writable: true,
|
||||
configurable: true
|
||||
})
|
||||
const { shapeOptions, getCurrentShape } = useNodeCustomization()
|
||||
selection.items = [node]
|
||||
|
||||
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
|
||||
})
|
||||
|
||||
it('falls back to the default shape for an unknown node shape', () => {
|
||||
const node = new LGraphNode('Test')
|
||||
Object.defineProperty(node, 'shape', {
|
||||
value: 999,
|
||||
writable: true,
|
||||
configurable: true
|
||||
})
|
||||
const { shapeOptions, getCurrentShape } = useNodeCustomization()
|
||||
selection.items = [node]
|
||||
|
||||
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
|
||||
})
|
||||
})
|
||||
@@ -10,30 +10,43 @@ import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
// canvasStore transitively imports the app singleton; stub it so the real
|
||||
// ComfyApp module never loads during these unit tests.
|
||||
const { actions, customization } = vi.hoisted(() => ({
|
||||
actions: {
|
||||
adjustNodeSize: vi.fn(),
|
||||
toggleNodeCollapse: vi.fn(),
|
||||
toggleNodePin: vi.fn(),
|
||||
toggleNodeBypass: vi.fn(),
|
||||
runBranch: vi.fn()
|
||||
},
|
||||
customization: {
|
||||
shapeOptions: [] as Array<{ localizedName: string; value: string }>,
|
||||
colorOptions: [] as Array<{
|
||||
name: string
|
||||
localizedName: string
|
||||
value: { dark: string; light: string }
|
||||
}>,
|
||||
applyShape: vi.fn(),
|
||||
applyColor: vi.fn(),
|
||||
isLightTheme: { value: false }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { selected_nodes: null } }
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeCustomization', () => ({
|
||||
useNodeCustomization: () => ({
|
||||
shapeOptions: [],
|
||||
applyShape: vi.fn(),
|
||||
applyColor: vi.fn(),
|
||||
colorOptions: [],
|
||||
isLightTheme: { value: false }
|
||||
shapeOptions: customization.shapeOptions,
|
||||
applyShape: customization.applyShape,
|
||||
applyColor: customization.applyColor,
|
||||
colorOptions: customization.colorOptions,
|
||||
isLightTheme: customization.isLightTheme
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
|
||||
useSelectedNodeActions: () => ({
|
||||
adjustNodeSize: vi.fn(),
|
||||
toggleNodeCollapse: vi.fn(),
|
||||
toggleNodePin: vi.fn(),
|
||||
toggleNodeBypass: vi.fn(),
|
||||
runBranch: vi.fn()
|
||||
})
|
||||
useSelectedNodeActions: () => actions
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -69,9 +82,29 @@ const getBypassLabel = (selected: LGraphNode[]): string => {
|
||||
return label
|
||||
}
|
||||
|
||||
describe('useNodeMenuOptions.getBypassOption', () => {
|
||||
function readNodeMenuOptions<T>(
|
||||
read: (options: ReturnType<typeof useNodeMenuOptions>) => T
|
||||
): T {
|
||||
const unread = Symbol('unread')
|
||||
const result: { value: T | typeof unread } = { value: unread }
|
||||
const Wrapper = defineComponent({
|
||||
setup() {
|
||||
result.value = read(useNodeMenuOptions())
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
render(Wrapper, { global: { plugins: [i18n] } })
|
||||
if (result.value === unread) throw new Error('Composable was not read')
|
||||
return result.value
|
||||
}
|
||||
|
||||
describe('useNodeMenuOptions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
customization.shapeOptions = []
|
||||
customization.colorOptions = []
|
||||
customization.isLightTheme.value = false
|
||||
})
|
||||
|
||||
it('labels as "Bypass" when no node is bypassed', () => {
|
||||
@@ -97,4 +130,109 @@ describe('useNodeMenuOptions.getBypassOption', () => {
|
||||
])
|
||||
).toBe('contextMenu.Bypass')
|
||||
})
|
||||
|
||||
it('labels visual node options from the collapsed state and bumps after action', () => {
|
||||
const expandBump = vi.fn()
|
||||
const expand = readNodeMenuOptions(
|
||||
({ getNodeVisualOptions }) =>
|
||||
getNodeVisualOptions({ collapsed: true, pinned: false }, expandBump)[0]
|
||||
)
|
||||
expect(expand).toMatchObject({
|
||||
label: 'contextMenu.Expand Node',
|
||||
icon: 'icon-[lucide--maximize-2]'
|
||||
})
|
||||
expand.action?.()
|
||||
expect(actions.toggleNodeCollapse).toHaveBeenCalledTimes(1)
|
||||
expect(expandBump).toHaveBeenCalledTimes(1)
|
||||
|
||||
const minimize = readNodeMenuOptions(
|
||||
({ getNodeVisualOptions }) =>
|
||||
getNodeVisualOptions({ collapsed: false, pinned: false }, vi.fn())[0]
|
||||
)
|
||||
expect(minimize).toMatchObject({
|
||||
label: 'contextMenu.Minimize Node',
|
||||
icon: 'icon-[lucide--minimize-2]'
|
||||
})
|
||||
})
|
||||
|
||||
it('labels pin options from the pinned state and bumps after action', () => {
|
||||
const bump = vi.fn()
|
||||
const unpin = readNodeMenuOptions(({ getPinOption }) =>
|
||||
getPinOption({ collapsed: false, pinned: true }, bump)
|
||||
)
|
||||
expect(unpin).toMatchObject({
|
||||
label: 'contextMenu.Unpin',
|
||||
icon: 'icon-[lucide--pin-off]'
|
||||
})
|
||||
unpin.action?.()
|
||||
expect(actions.toggleNodePin).toHaveBeenCalledTimes(1)
|
||||
expect(bump).toHaveBeenCalledTimes(1)
|
||||
|
||||
const pin = readNodeMenuOptions(({ getPinOption }) =>
|
||||
getPinOption({ collapsed: false, pinned: false }, vi.fn())
|
||||
)
|
||||
expect(pin).toMatchObject({
|
||||
label: 'contextMenu.Pin',
|
||||
icon: 'icon-[lucide--pin]'
|
||||
})
|
||||
})
|
||||
|
||||
it('builds shape and color submenus and applies selected values', () => {
|
||||
customization.shapeOptions = [{ localizedName: 'Box', value: 'box' }]
|
||||
customization.colorOptions = [
|
||||
{
|
||||
name: 'noColor',
|
||||
localizedName: 'No Color',
|
||||
value: { dark: '#000', light: '#fff' }
|
||||
},
|
||||
{
|
||||
name: 'red',
|
||||
localizedName: 'Red',
|
||||
value: { dark: '#111', light: '#eee' }
|
||||
}
|
||||
]
|
||||
|
||||
const { visualOptions, colorSubmenu } = readNodeMenuOptions((options) => ({
|
||||
visualOptions: options.getNodeVisualOptions(
|
||||
{ collapsed: false, pinned: false },
|
||||
vi.fn()
|
||||
),
|
||||
colorSubmenu: options.colorSubmenu.value
|
||||
}))
|
||||
|
||||
expect(visualOptions[1].submenu).toEqual([
|
||||
expect.objectContaining({ label: 'Box' })
|
||||
])
|
||||
visualOptions[1].submenu?.[0].action()
|
||||
expect(customization.applyShape).toHaveBeenCalledWith(
|
||||
customization.shapeOptions[0]
|
||||
)
|
||||
|
||||
expect(colorSubmenu).toEqual([
|
||||
expect.objectContaining({ label: 'No Color', color: '#000' }),
|
||||
expect.objectContaining({ label: 'Red', color: '#111' })
|
||||
])
|
||||
colorSubmenu[0].action()
|
||||
colorSubmenu[1].action()
|
||||
expect(customization.applyColor).toHaveBeenNthCalledWith(1, null)
|
||||
expect(customization.applyColor).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
customization.colorOptions[1]
|
||||
)
|
||||
})
|
||||
|
||||
it('uses light-theme colors for the color submenu', () => {
|
||||
customization.isLightTheme.value = true
|
||||
customization.colorOptions = [
|
||||
{
|
||||
name: 'red',
|
||||
localizedName: 'Red',
|
||||
value: { dark: '#111', light: '#eee' }
|
||||
}
|
||||
]
|
||||
|
||||
expect(
|
||||
readNodeMenuOptions((options) => options.colorSubmenu.value[0].color)
|
||||
).toBe('#eee')
|
||||
})
|
||||
})
|
||||
|
||||
221
src/composables/graph/useSelectionOperations.test.ts
Normal file
221
src/composables/graph/useSelectionOperations.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSelectionOperations } from '@/composables/graph/useSelectionOperations'
|
||||
|
||||
const {
|
||||
canvas,
|
||||
toastAdd,
|
||||
captureCanvasState,
|
||||
updateSelectedItems,
|
||||
prompt,
|
||||
titleEditor,
|
||||
store
|
||||
} = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
selectedItems: new Set<unknown>(),
|
||||
copyToClipboard: vi.fn(),
|
||||
pasteFromClipboard: vi.fn(),
|
||||
deleteSelected: vi.fn(),
|
||||
setDirty: vi.fn()
|
||||
},
|
||||
toastAdd: vi.fn(),
|
||||
captureCanvasState: vi.fn(),
|
||||
updateSelectedItems: vi.fn(),
|
||||
prompt: vi.fn(),
|
||||
titleEditor: { titleEditorTarget: null as unknown },
|
||||
store: { selectedItems: [] as unknown[] }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: { canvas } }))
|
||||
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: toastAdd })
|
||||
}))
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: { changeTracker: { captureCanvasState } }
|
||||
})
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
updateSelectedItems,
|
||||
get selectedItems() {
|
||||
return store.selectedItems
|
||||
}
|
||||
}),
|
||||
useTitleEditorStore: () => titleEditor
|
||||
}))
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({ prompt })
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
canvas.selectedItems = new Set()
|
||||
canvas.copyToClipboard.mockReset()
|
||||
canvas.pasteFromClipboard.mockReset()
|
||||
canvas.deleteSelected.mockReset()
|
||||
canvas.setDirty.mockReset()
|
||||
toastAdd.mockReset()
|
||||
captureCanvasState.mockReset()
|
||||
updateSelectedItems.mockReset()
|
||||
prompt.mockReset()
|
||||
titleEditor.titleEditorTarget = null
|
||||
store.selectedItems = []
|
||||
})
|
||||
|
||||
describe('useSelectionOperations', () => {
|
||||
it('warns and does nothing when copying an empty selection', () => {
|
||||
useSelectionOperations().copySelection()
|
||||
expect(canvas.copyToClipboard).not.toHaveBeenCalled()
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'warn' })
|
||||
)
|
||||
})
|
||||
|
||||
it('copies a non-empty selection and reports success', () => {
|
||||
canvas.selectedItems = new Set(['a'])
|
||||
useSelectionOperations().copySelection()
|
||||
expect(canvas.copyToClipboard).toHaveBeenCalled()
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('pastes from clipboard and captures canvas state', () => {
|
||||
useSelectionOperations().pasteSelection()
|
||||
expect(canvas.pasteFromClipboard).toHaveBeenCalledWith({
|
||||
connectInputs: false
|
||||
})
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('duplicates by copy, clear, paste', () => {
|
||||
canvas.selectedItems = new Set(['a'])
|
||||
useSelectionOperations().duplicateSelection()
|
||||
expect(canvas.copyToClipboard).toHaveBeenCalled()
|
||||
expect(canvas.selectedItems.size).toBe(0)
|
||||
expect(updateSelectedItems).toHaveBeenCalled()
|
||||
expect(canvas.pasteFromClipboard).toHaveBeenCalled()
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns when duplicating nothing', () => {
|
||||
useSelectionOperations().duplicateSelection()
|
||||
expect(canvas.copyToClipboard).not.toHaveBeenCalled()
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'warn' })
|
||||
)
|
||||
})
|
||||
|
||||
it('deletes a non-empty selection and marks the canvas dirty', () => {
|
||||
canvas.selectedItems = new Set(['a'])
|
||||
useSelectionOperations().deleteSelection()
|
||||
expect(canvas.deleteSelected).toHaveBeenCalled()
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns when deleting nothing', () => {
|
||||
useSelectionOperations().deleteSelection()
|
||||
expect(canvas.deleteSelected).not.toHaveBeenCalled()
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'warn' })
|
||||
)
|
||||
})
|
||||
|
||||
it('routes a single node rename to the title editor', async () => {
|
||||
const node = new LGraphNode('Test')
|
||||
store.selectedItems = [node]
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(titleEditor.titleEditorTarget).toBe(node)
|
||||
expect(prompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renames a single non-node item via the prompt dialog', async () => {
|
||||
const group = { title: 'Old' }
|
||||
store.selectedItems = [group]
|
||||
prompt.mockResolvedValue('New')
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(group.title).toBe('New')
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a single titled item unchanged when the prompt returns the same title', async () => {
|
||||
const group = { title: 'Old' }
|
||||
store.selectedItems = [group]
|
||||
prompt.mockResolvedValue('Old')
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(group.title).toBe('Old')
|
||||
expect(canvas.setDirty).not.toHaveBeenCalled()
|
||||
expect(captureCanvasState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not assign a title to a selected item without a title property', async () => {
|
||||
const item = {}
|
||||
store.selectedItems = [item]
|
||||
prompt.mockResolvedValue('New')
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(item).toEqual({})
|
||||
expect(canvas.setDirty).not.toHaveBeenCalled()
|
||||
expect(captureCanvasState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('batch-renames multiple items with an indexed base name', async () => {
|
||||
const a = { title: 'a' }
|
||||
const b = { title: 'b' }
|
||||
store.selectedItems = [a, b]
|
||||
prompt.mockResolvedValue('Item')
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(a.title).toBe('Item 1')
|
||||
expect(b.title).toBe('Item 2')
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips untitled items during batch rename', async () => {
|
||||
const a = { title: 'a' }
|
||||
const b = {}
|
||||
store.selectedItems = [a, b]
|
||||
prompt.mockResolvedValue('Item')
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(a.title).toBe('Item 1')
|
||||
expect(b).toEqual({})
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
expect(captureCanvasState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a multiple selection unchanged when batch rename is cancelled', async () => {
|
||||
const a = { title: 'a' }
|
||||
const b = { title: 'b' }
|
||||
store.selectedItems = [a, b]
|
||||
prompt.mockResolvedValue('')
|
||||
|
||||
await useSelectionOperations().renameSelection()
|
||||
|
||||
expect(a.title).toBe('a')
|
||||
expect(b.title).toBe('b')
|
||||
expect(canvas.setDirty).not.toHaveBeenCalled()
|
||||
expect(captureCanvasState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns when renaming an empty selection', async () => {
|
||||
await useSelectionOperations().renameSelection()
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'warn' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,12 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import {
|
||||
isImageNode,
|
||||
isLGraphGroup,
|
||||
isLGraphNode,
|
||||
isLoad3dNode
|
||||
} from '@/utils/litegraphUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
import {
|
||||
createMockLGraphNode,
|
||||
@@ -17,7 +22,9 @@ import {
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn(),
|
||||
isImageNode: vi.fn()
|
||||
isImageNode: vi.fn(),
|
||||
isLGraphGroup: vi.fn(),
|
||||
isLoad3dNode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeFilterUtil', () => ({
|
||||
@@ -96,6 +103,14 @@ describe('useSelectionState', () => {
|
||||
const typedNode = node as { type?: string }
|
||||
return typedNode?.type === 'ImageNode'
|
||||
})
|
||||
vi.mocked(isLGraphGroup).mockImplementation((item: unknown) => {
|
||||
const typedItem = item as { isGroup?: boolean }
|
||||
return typedItem?.isGroup === true
|
||||
})
|
||||
vi.mocked(isLoad3dNode).mockImplementation((node: unknown) => {
|
||||
const typedNode = node as { type?: string }
|
||||
return typedNode?.type === 'Load3D'
|
||||
})
|
||||
vi.mocked(filterOutputNodes).mockImplementation((nodes) =>
|
||||
nodes.filter((n) => n.type === 'OutputNode')
|
||||
)
|
||||
@@ -135,6 +150,21 @@ describe('useSelectionState', () => {
|
||||
const { hasMultipleSelection } = useSelectionState()
|
||||
expect(hasMultipleSelection.value).toBe(false)
|
||||
})
|
||||
|
||||
test('hasGroupedNodesSelection should detect a group containing nodes', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const graphNode = createMockLGraphNode({ id: 2 })
|
||||
const group = createMockPositionable({ id: 2000 })
|
||||
Object.assign(group, {
|
||||
isGroup: true,
|
||||
isNode: false,
|
||||
children: new Set([graphNode])
|
||||
})
|
||||
canvasStore.$state.selectedItems = [group]
|
||||
|
||||
const { hasGroupedNodesSelection } = useSelectionState()
|
||||
expect(hasGroupedNodesSelection.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Type Filtering', () => {
|
||||
@@ -215,6 +245,13 @@ describe('useSelectionState', () => {
|
||||
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
|
||||
expect(newIsPinned).toBe(false)
|
||||
})
|
||||
|
||||
test('should compute default flags for an empty node selection', () => {
|
||||
expect(useSelectionState().computeSelectionFlags()).toEqual({
|
||||
collapsed: false,
|
||||
pinned: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Info', () => {
|
||||
|
||||
@@ -4,34 +4,45 @@ import { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
publishSubgraph: vi.fn(),
|
||||
selectedItems: [] as unknown[]
|
||||
selectedItems: [] as unknown[],
|
||||
getSelectedNodes: vi.fn((): unknown[] => []),
|
||||
getCanvas: vi.fn(),
|
||||
updateSelectedItems: vi.fn(),
|
||||
revokeSubgraphPreviews: vi.fn(),
|
||||
activeWorkflow: null as null | {
|
||||
changeTracker?: {
|
||||
captureCanvasState: () => void
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/canvas/useSelectedLiteGraphItems', () => ({
|
||||
useSelectedLiteGraphItems: () => ({
|
||||
getSelectedNodes: vi.fn(() => [])
|
||||
getSelectedNodes: mocks.getSelectedNodes
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
getCanvas: vi.fn(),
|
||||
getCanvas: mocks.getCanvas,
|
||||
get selectedItems() {
|
||||
return mocks.selectedItems
|
||||
},
|
||||
updateSelectedItems: vi.fn()
|
||||
updateSelectedItems: mocks.updateSelectedItems
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: null
|
||||
get activeWorkflow() {
|
||||
return mocks.activeWorkflow
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({
|
||||
revokeSubgraphPreviews: vi.fn()
|
||||
revokeSubgraphPreviews: mocks.revokeSubgraphPreviews
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -50,10 +61,36 @@ function createRegularNode(): LGraphNode {
|
||||
return new LGraphNode('testnode')
|
||||
}
|
||||
|
||||
function createCanvas({
|
||||
graph,
|
||||
subgraph,
|
||||
selectedItems = []
|
||||
}: {
|
||||
graph?: {
|
||||
convertToSubgraph?: ReturnType<typeof vi.fn>
|
||||
unpackSubgraph?: ReturnType<typeof vi.fn>
|
||||
}
|
||||
subgraph?: {
|
||||
convertToSubgraph?: ReturnType<typeof vi.fn>
|
||||
unpackSubgraph?: ReturnType<typeof vi.fn>
|
||||
}
|
||||
selectedItems?: unknown[]
|
||||
} = {}) {
|
||||
return {
|
||||
graph,
|
||||
subgraph,
|
||||
selectedItems: new Set(selectedItems),
|
||||
select: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('useSubgraphOperations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.selectedItems = []
|
||||
mocks.getSelectedNodes.mockReturnValue([])
|
||||
mocks.getCanvas.mockReturnValue(createCanvas())
|
||||
mocks.activeWorkflow = null
|
||||
})
|
||||
|
||||
it('addSubgraphToLibrary calls publishSubgraph when single SubgraphNode selected', async () => {
|
||||
@@ -103,4 +140,126 @@ describe('useSubgraphOperations', () => {
|
||||
|
||||
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports selected subgraph and selectable node state', async () => {
|
||||
mocks.selectedItems = [createRegularNode()]
|
||||
mocks.getSelectedNodes.mockReturnValue([])
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { isSubgraphSelected, hasSelectableNodes } = useSubgraphOperations()
|
||||
|
||||
expect(isSubgraphSelected()).toBe(false)
|
||||
expect(hasSelectableNodes()).toBe(false)
|
||||
|
||||
mocks.selectedItems = [createSubgraphNode()]
|
||||
mocks.getSelectedNodes.mockReturnValue([createRegularNode()])
|
||||
|
||||
expect(isSubgraphSelected()).toBe(true)
|
||||
expect(hasSelectableNodes()).toBe(true)
|
||||
})
|
||||
|
||||
it('converts selected items to a subgraph and captures workflow state', async () => {
|
||||
const captureCanvasState = vi.fn()
|
||||
const node = createSubgraphNode()
|
||||
const graph = {
|
||||
convertToSubgraph: vi.fn(() => ({ node })),
|
||||
unpackSubgraph: vi.fn()
|
||||
}
|
||||
const canvas = createCanvas({
|
||||
graph,
|
||||
selectedItems: [createRegularNode()]
|
||||
})
|
||||
mocks.getCanvas.mockReturnValue(canvas)
|
||||
mocks.activeWorkflow = {
|
||||
changeTracker: {
|
||||
captureCanvasState
|
||||
}
|
||||
}
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { convertToSubgraph } = useSubgraphOperations()
|
||||
|
||||
convertToSubgraph()
|
||||
|
||||
expect(graph.convertToSubgraph).toHaveBeenCalledWith(canvas.selectedItems)
|
||||
expect(canvas.select).toHaveBeenCalledWith(node)
|
||||
expect(mocks.updateSelectedItems).toHaveBeenCalledOnce()
|
||||
expect(captureCanvasState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not select or capture when conversion has no graph or no result', async () => {
|
||||
const graph = {
|
||||
convertToSubgraph: vi.fn(() => null),
|
||||
unpackSubgraph: vi.fn()
|
||||
}
|
||||
const canvas = createCanvas({ graph })
|
||||
mocks.getCanvas
|
||||
.mockReturnValueOnce(createCanvas())
|
||||
.mockReturnValueOnce(canvas)
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { convertToSubgraph } = useSubgraphOperations()
|
||||
|
||||
expect(convertToSubgraph()).toBeNull()
|
||||
expect(convertToSubgraph()).toBeUndefined()
|
||||
expect(canvas.select).not.toHaveBeenCalled()
|
||||
expect(mocks.updateSelectedItems).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unpacks selected subgraph nodes from the active graph and revokes previews', async () => {
|
||||
const captureCanvasState = vi.fn()
|
||||
const subgraphNode = createSubgraphNode()
|
||||
const graph = {
|
||||
convertToSubgraph: vi.fn(),
|
||||
unpackSubgraph: vi.fn()
|
||||
}
|
||||
mocks.getCanvas.mockReturnValue(
|
||||
createCanvas({
|
||||
subgraph: graph,
|
||||
selectedItems: [subgraphNode, createRegularNode()]
|
||||
})
|
||||
)
|
||||
mocks.activeWorkflow = {
|
||||
changeTracker: {
|
||||
captureCanvasState
|
||||
}
|
||||
}
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { unpackSubgraph } = useSubgraphOperations()
|
||||
|
||||
unpackSubgraph()
|
||||
|
||||
expect(mocks.revokeSubgraphPreviews).toHaveBeenCalledWith(subgraphNode)
|
||||
expect(graph.unpackSubgraph).toHaveBeenCalledWith(subgraphNode, {
|
||||
skipMissingNodes: true
|
||||
})
|
||||
expect(captureCanvasState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not unpack when no graph or no subgraph nodes are selected', async () => {
|
||||
const graph = {
|
||||
convertToSubgraph: vi.fn(),
|
||||
unpackSubgraph: vi.fn()
|
||||
}
|
||||
mocks.getCanvas
|
||||
.mockReturnValueOnce(createCanvas())
|
||||
.mockReturnValueOnce(
|
||||
createCanvas({ graph, selectedItems: [createRegularNode()] })
|
||||
)
|
||||
|
||||
const { useSubgraphOperations } =
|
||||
await import('@/composables/graph/useSubgraphOperations')
|
||||
const { unpackSubgraph } = useSubgraphOperations()
|
||||
|
||||
unpackSubgraph()
|
||||
unpackSubgraph()
|
||||
|
||||
expect(graph.unpackSubgraph).not.toHaveBeenCalled()
|
||||
expect(mocks.revokeSubgraphPreviews).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
342
src/composables/graph/useVueNodeLifecycle.test.ts
Normal file
342
src/composables/graph/useVueNodeLifecycle.test.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as VueUse from '@vueuse/core'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { UNASSIGNED_NODE_ID, toNodeId } from '@/types/nodeId'
|
||||
|
||||
type MockReroute = {
|
||||
id: string
|
||||
pos: [number, number]
|
||||
parentId?: string | null
|
||||
linkIds: Set<string>
|
||||
}
|
||||
|
||||
type MockLink = {
|
||||
id: string
|
||||
origin_id: string
|
||||
origin_slot: number
|
||||
target_id: string
|
||||
target_slot: number
|
||||
}
|
||||
|
||||
type MockGraph = {
|
||||
_nodes: LGraphNode[]
|
||||
reroutes: Map<string, MockReroute>
|
||||
_links: Map<string, MockLink>
|
||||
onNodeAdded?: (node: LGraphNode) => void
|
||||
}
|
||||
|
||||
type MockCanvas = {
|
||||
graph?: MockGraph
|
||||
setDirty: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mockWheneverCallbacks = vi.hoisted(() => ({
|
||||
values: [] as Array<() => void>
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof VueUse>()
|
||||
return {
|
||||
...actual,
|
||||
createSharedComposable: <Args extends unknown[], Return>(
|
||||
fn: (...args: Args) => Return
|
||||
) => fn,
|
||||
whenever: (_source: () => boolean, callback: () => void) => {
|
||||
mockWheneverCallbacks.values.push(callback)
|
||||
return vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseGraphNodeManager = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/composables/graph/useGraphNodeManager', () => ({
|
||||
useGraphNodeManager: mockUseGraphNodeManager
|
||||
}))
|
||||
|
||||
const mockShouldRenderVueNodes = vi.hoisted(() => ({ value: false }))
|
||||
vi.mock('@/composables/useVueFeatureFlags', () => ({
|
||||
useVueFeatureFlags: () => ({
|
||||
shouldRenderVueNodes: mockShouldRenderVueNodes
|
||||
})
|
||||
}))
|
||||
|
||||
const mockCanvasStoreCanvas = vi.hoisted(() => ({
|
||||
value: undefined as MockCanvas | undefined
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: mockCanvasStoreCanvas.value
|
||||
})
|
||||
}))
|
||||
|
||||
const mockCreateReroute = vi.hoisted(() => vi.fn())
|
||||
const mockCreateLink = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => ({
|
||||
useLayoutMutations: () => ({
|
||||
createReroute: mockCreateReroute,
|
||||
createLink: mockCreateLink
|
||||
})
|
||||
}))
|
||||
|
||||
const mockInitializeFromLiteGraph = vi.hoisted(() => vi.fn())
|
||||
const mockClearAllSlotLayouts = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
initializeFromLiteGraph: mockInitializeFromLiteGraph,
|
||||
clearAllSlotLayouts: mockClearAllSlotLayouts
|
||||
}
|
||||
}))
|
||||
|
||||
const mockStartSync = vi.hoisted(() => vi.fn())
|
||||
const mockStopSync = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/renderer/core/layout/sync/useLayoutSync', () => ({
|
||||
useLayoutSync: () => ({
|
||||
startSync: mockStartSync,
|
||||
stopSync: mockStopSync
|
||||
})
|
||||
}))
|
||||
|
||||
const mockComfyCanvas = vi.hoisted(() => ({
|
||||
value: undefined as MockCanvas | undefined
|
||||
}))
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
get canvas() {
|
||||
return mockComfyCanvas.value
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const mockManagerCleanup = vi.hoisted(() => vi.fn())
|
||||
|
||||
function createNode(id: number, overrides: object = {}): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id: toNodeId(id),
|
||||
pos: [id * 10, id * 20],
|
||||
size: [100 + id, 200 + id],
|
||||
flags: { collapsed: false },
|
||||
arrange: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
function createGraph(overrides: Partial<MockGraph> = {}): MockGraph {
|
||||
return {
|
||||
_nodes: [],
|
||||
reroutes: new Map(),
|
||||
_links: new Map(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLifecycle() {
|
||||
vi.resetModules()
|
||||
const { useVueNodeLifecycle } = await import('./useVueNodeLifecycle')
|
||||
return useVueNodeLifecycle()
|
||||
}
|
||||
|
||||
describe('useVueNodeLifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWheneverCallbacks.values = []
|
||||
mockShouldRenderVueNodes.value = false
|
||||
mockCanvasStoreCanvas.value = undefined
|
||||
mockComfyCanvas.value = undefined
|
||||
mockManagerCleanup.mockReset()
|
||||
mockUseGraphNodeManager.mockReset()
|
||||
mockUseGraphNodeManager.mockReturnValue({ cleanup: mockManagerCleanup })
|
||||
})
|
||||
|
||||
it('initializes the node manager from the active graph', async () => {
|
||||
const node = createNode(1)
|
||||
const graph = createGraph({
|
||||
_nodes: [node],
|
||||
reroutes: new Map([
|
||||
[
|
||||
'reroute-1',
|
||||
{
|
||||
id: 'reroute-1',
|
||||
pos: [12, 34],
|
||||
parentId: null,
|
||||
linkIds: new Set(['link-1'])
|
||||
}
|
||||
]
|
||||
]),
|
||||
_links: new Map([
|
||||
[
|
||||
'link-1',
|
||||
{
|
||||
id: 'link-1',
|
||||
origin_id: toNodeId(1),
|
||||
origin_slot: 0,
|
||||
target_id: toNodeId(2),
|
||||
target_slot: 1
|
||||
}
|
||||
],
|
||||
[
|
||||
'link-2',
|
||||
{
|
||||
id: 'link-2',
|
||||
origin_id: UNASSIGNED_NODE_ID,
|
||||
origin_slot: 0,
|
||||
target_id: toNodeId(2),
|
||||
target_slot: 1
|
||||
}
|
||||
],
|
||||
[
|
||||
'link-3',
|
||||
{
|
||||
id: 'link-3',
|
||||
origin_id: toNodeId(1),
|
||||
origin_slot: 0,
|
||||
target_id: UNASSIGNED_NODE_ID,
|
||||
target_slot: 1
|
||||
}
|
||||
]
|
||||
])
|
||||
})
|
||||
const canvas = { graph, setDirty: vi.fn() }
|
||||
mockComfyCanvas.value = canvas
|
||||
mockCanvasStoreCanvas.value = canvas
|
||||
mockShouldRenderVueNodes.value = true
|
||||
|
||||
const lifecycle = await loadLifecycle()
|
||||
|
||||
expect(mockUseGraphNodeManager).toHaveBeenCalledWith(graph)
|
||||
expect(lifecycle.nodeManager.value).toEqual({
|
||||
cleanup: mockManagerCleanup
|
||||
})
|
||||
expect(mockInitializeFromLiteGraph).toHaveBeenCalledWith([
|
||||
{
|
||||
id: toNodeId(1),
|
||||
pos: [10, 20],
|
||||
size: [101, 201]
|
||||
}
|
||||
])
|
||||
expect(mockCreateReroute).toHaveBeenCalledWith(
|
||||
'reroute-1',
|
||||
{ x: 12, y: 34 },
|
||||
undefined,
|
||||
['link-1']
|
||||
)
|
||||
expect(mockCreateLink).toHaveBeenCalledOnce()
|
||||
expect(mockCreateLink).toHaveBeenCalledWith(
|
||||
'link-1',
|
||||
toNodeId(1),
|
||||
0,
|
||||
toNodeId(2),
|
||||
1
|
||||
)
|
||||
expect(mockStartSync).toHaveBeenCalledWith(canvas)
|
||||
})
|
||||
|
||||
it('does not initialize without an active graph', async () => {
|
||||
mockShouldRenderVueNodes.value = true
|
||||
const lifecycle = await loadLifecycle()
|
||||
|
||||
lifecycle.initializeNodeManager()
|
||||
|
||||
expect(mockUseGraphNodeManager).not.toHaveBeenCalled()
|
||||
expect(mockStartSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops sync and tolerates manager cleanup errors', async () => {
|
||||
mockManagerCleanup.mockImplementation(() => {
|
||||
throw new Error('cleanup failed')
|
||||
})
|
||||
mockComfyCanvas.value = {
|
||||
graph: createGraph(),
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
mockShouldRenderVueNodes.value = true
|
||||
const lifecycle = await loadLifecycle()
|
||||
|
||||
expect(() => lifecycle.disposeNodeManagerAndSyncs()).not.toThrow()
|
||||
|
||||
expect(mockStopSync).toHaveBeenCalled()
|
||||
expect(lifecycle.nodeManager.value).toBeNull()
|
||||
})
|
||||
|
||||
it('arranges legacy nodes when the Vue node mode is disabled', async () => {
|
||||
const arrangeVisible = vi.fn()
|
||||
const arrangeThrowing = vi.fn(() => {
|
||||
throw new Error('not ready')
|
||||
})
|
||||
const graph = createGraph({
|
||||
_nodes: [
|
||||
createNode(1, { arrange: arrangeVisible }),
|
||||
createNode(2, { flags: { collapsed: true }, arrange: vi.fn() }),
|
||||
createNode(3, { arrange: arrangeThrowing })
|
||||
]
|
||||
})
|
||||
const canvas = { graph, setDirty: vi.fn() }
|
||||
mockComfyCanvas.value = canvas
|
||||
mockShouldRenderVueNodes.value = true
|
||||
await loadLifecycle()
|
||||
|
||||
mockWheneverCallbacks.values[0]()
|
||||
|
||||
expect(arrangeVisible).toHaveBeenCalled()
|
||||
expect(arrangeThrowing).toHaveBeenCalled()
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('marks the canvas dirty when disabling without a graph', async () => {
|
||||
const canvas = { setDirty: vi.fn() }
|
||||
mockComfyCanvas.value = canvas
|
||||
await loadLifecycle()
|
||||
|
||||
mockWheneverCallbacks.values[0]()
|
||||
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('initializes on the first node added to an empty graph', async () => {
|
||||
mockShouldRenderVueNodes.value = true
|
||||
const originalOnNodeAdded = vi.fn()
|
||||
const graph = createGraph({ onNodeAdded: originalOnNodeAdded })
|
||||
const canvas = { graph, setDirty: vi.fn() }
|
||||
const node = createNode(1)
|
||||
const lifecycle = await loadLifecycle()
|
||||
mockComfyCanvas.value = canvas
|
||||
|
||||
lifecycle.setupEmptyGraphListener()
|
||||
graph.onNodeAdded?.(node)
|
||||
|
||||
expect(mockUseGraphNodeManager).toHaveBeenCalledWith(graph)
|
||||
expect(graph.onNodeAdded).toBe(originalOnNodeAdded)
|
||||
expect(originalOnNodeAdded).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('does not replace onNodeAdded when the empty-graph guard fails', async () => {
|
||||
const originalOnNodeAdded = vi.fn()
|
||||
const graph = createGraph({
|
||||
_nodes: [createNode(1)],
|
||||
onNodeAdded: originalOnNodeAdded
|
||||
})
|
||||
mockComfyCanvas.value = { graph, setDirty: vi.fn() }
|
||||
mockShouldRenderVueNodes.value = true
|
||||
const lifecycle = await loadLifecycle()
|
||||
|
||||
lifecycle.setupEmptyGraphListener()
|
||||
|
||||
expect(graph.onNodeAdded).toBe(originalOnNodeAdded)
|
||||
})
|
||||
|
||||
it('cleans up the node manager on unmount', async () => {
|
||||
mockComfyCanvas.value = {
|
||||
graph: createGraph(),
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
mockShouldRenderVueNodes.value = true
|
||||
const lifecycle = await loadLifecycle()
|
||||
|
||||
lifecycle.cleanup()
|
||||
lifecycle.cleanup()
|
||||
|
||||
expect(mockManagerCleanup).toHaveBeenCalledOnce()
|
||||
expect(lifecycle.nodeManager.value).toBeNull()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,8 +23,8 @@ const mockStore = reactive({
|
||||
gpuTexturesNeedRecreation: false,
|
||||
gpuTextureWidth: 0,
|
||||
gpuTextureHeight: 0,
|
||||
pendingGPUMaskData: null as null,
|
||||
pendingGPURgbData: null as null,
|
||||
pendingGPUMaskData: null as Uint8Array | null,
|
||||
pendingGPURgbData: null as Uint8Array | null,
|
||||
brushSettings: {
|
||||
size: 20,
|
||||
hardness: 0.9,
|
||||
@@ -42,6 +42,9 @@ vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStore)
|
||||
}))
|
||||
|
||||
import { tgpu } from 'typegpu'
|
||||
|
||||
import { GPUBrushRenderer } from './gpu/GPUBrushRenderer'
|
||||
import { resetDirtyRect } from './brushDrawingUtils'
|
||||
import { useGPUResources } from './useGPUResources'
|
||||
|
||||
@@ -52,8 +55,120 @@ function setup() {
|
||||
return scope.run(() => useGPUResources())!
|
||||
}
|
||||
|
||||
class TestImageData {
|
||||
data: Uint8ClampedArray
|
||||
width: number
|
||||
height: number
|
||||
|
||||
constructor(data: Uint8ClampedArray, width: number, height: number) {
|
||||
this.data = data
|
||||
this.width = width
|
||||
this.height = height
|
||||
}
|
||||
}
|
||||
|
||||
function createMockTexture() {
|
||||
return {
|
||||
createView: vi.fn(() => ({})),
|
||||
destroy: vi.fn()
|
||||
} as unknown as GPUTexture
|
||||
}
|
||||
|
||||
function createMockBuffer(byteLength = 16) {
|
||||
return {
|
||||
destroy: vi.fn(),
|
||||
mapAsync: vi.fn().mockResolvedValue(undefined),
|
||||
getMappedRange: vi.fn(() => new Uint8Array(byteLength).buffer),
|
||||
unmap: vi.fn()
|
||||
} as unknown as GPUBuffer
|
||||
}
|
||||
|
||||
function createMockDevice() {
|
||||
return {
|
||||
limits: {},
|
||||
queue: {
|
||||
writeTexture: vi.fn(),
|
||||
submit: vi.fn()
|
||||
},
|
||||
createTexture: vi.fn(() => createMockTexture()),
|
||||
createBuffer: vi.fn(() => createMockBuffer()),
|
||||
createCommandEncoder: vi.fn(() => ({
|
||||
copyBufferToBuffer: vi.fn(),
|
||||
finish: vi.fn(() => ({}))
|
||||
}))
|
||||
} as unknown as GPUDevice
|
||||
}
|
||||
|
||||
function createMockRenderer() {
|
||||
return {
|
||||
destroy: vi.fn(),
|
||||
prepareStroke: vi.fn(),
|
||||
clearPreview: vi.fn(),
|
||||
compositeStroke: vi.fn(),
|
||||
prepareReadback: vi.fn(),
|
||||
renderStrokeToAccumulator: vi.fn(),
|
||||
blitToCanvas: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function mockGpuBrushRenderer(renderer: ReturnType<typeof createMockRenderer>) {
|
||||
vi.mocked(GPUBrushRenderer).mockImplementation(
|
||||
function GPUBrushRendererMock() {
|
||||
return renderer as unknown as GPUBrushRenderer
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function createCanvasContext(width: number, height: number) {
|
||||
return {
|
||||
globalCompositeOperation: 'source-over',
|
||||
getImageData: vi.fn(
|
||||
() =>
|
||||
new ImageData(new Uint8ClampedArray(width * height * 4), width, height)
|
||||
),
|
||||
putImageData: vi.fn()
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
function setReadyCanvases(width = 2, height = 2) {
|
||||
const maskCanvas = document.createElement('canvas')
|
||||
maskCanvas.width = width
|
||||
maskCanvas.height = height
|
||||
const rgbCanvas = document.createElement('canvas')
|
||||
rgbCanvas.width = width
|
||||
rgbCanvas.height = height
|
||||
|
||||
mockStore.maskCanvas = maskCanvas
|
||||
mockStore.rgbCanvas = rgbCanvas
|
||||
mockStore.maskCtx = createCanvasContext(width, height)
|
||||
mockStore.rgbCtx = createCanvasContext(width, height)
|
||||
}
|
||||
|
||||
function installGpuGlobals() {
|
||||
vi.stubGlobal('GPUTextureUsage', {
|
||||
TEXTURE_BINDING: 1,
|
||||
STORAGE_BINDING: 2,
|
||||
RENDER_ATTACHMENT: 4,
|
||||
COPY_DST: 8,
|
||||
COPY_SRC: 16
|
||||
})
|
||||
vi.stubGlobal('GPUBufferUsage', {
|
||||
STORAGE: 1,
|
||||
COPY_SRC: 2,
|
||||
COPY_DST: 4,
|
||||
MAP_READ: 8
|
||||
})
|
||||
vi.stubGlobal('GPUMapMode', { READ: 1 })
|
||||
vi.stubGlobal('ImageData', TestImageData)
|
||||
Object.defineProperty(navigator, 'gpu', {
|
||||
value: { getPreferredCanvasFormat: vi.fn(() => 'rgba8unorm') },
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
installGpuGlobals()
|
||||
mockStore.tgpuRoot = null
|
||||
mockStore.maskCanvas = null
|
||||
mockStore.rgbCanvas = null
|
||||
@@ -62,11 +177,28 @@ beforeEach(() => {
|
||||
mockStore.clearTrigger = 0
|
||||
mockStore.canvasHistory.currentStateIndex = 0
|
||||
mockStore.gpuTexturesNeedRecreation = false
|
||||
mockStore.gpuTextureWidth = 0
|
||||
mockStore.gpuTextureHeight = 0
|
||||
mockStore.pendingGPUMaskData = null
|
||||
mockStore.pendingGPURgbData = null
|
||||
mockStore.activeLayer = 'mask'
|
||||
mockStore.currentTool = 'pen'
|
||||
mockStore.maskColor = { r: 0, g: 0, b: 0 }
|
||||
mockStore.rgbColor = '#FF0000'
|
||||
mockStore.brushSettings = {
|
||||
size: 20,
|
||||
hardness: 0.9,
|
||||
opacity: 1,
|
||||
stepSize: 5,
|
||||
type: 'arc'
|
||||
}
|
||||
vi.mocked(tgpu.init).mockRejectedValue(new Error('WebGPU not supported'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope?.stop()
|
||||
scope = null
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('initial reactive state', () => {
|
||||
@@ -131,6 +263,34 @@ describe('initGPUResources', () => {
|
||||
await initGPUResources()
|
||||
expect(hasRenderer.value).toBe(false)
|
||||
})
|
||||
|
||||
it('handles non-error TypeGPU initialisation failures', async () => {
|
||||
vi.mocked(tgpu.init).mockRejectedValueOnce('WebGPU unavailable')
|
||||
|
||||
const { initGPUResources, hasRenderer } = setup()
|
||||
await initGPUResources()
|
||||
|
||||
expect(hasRenderer.value).toBe(false)
|
||||
})
|
||||
|
||||
it('initializes renderer when a root and canvas contexts are ready', async () => {
|
||||
const device = createMockDevice()
|
||||
const renderer = createMockRenderer()
|
||||
mockStore.tgpuRoot = {
|
||||
device,
|
||||
destroy: vi.fn()
|
||||
}
|
||||
setReadyCanvases()
|
||||
mockGpuBrushRenderer(renderer)
|
||||
|
||||
const { initGPUResources, hasRenderer } = setup()
|
||||
await initGPUResources()
|
||||
|
||||
expect(hasRenderer.value).toBe(true)
|
||||
expect(device.createTexture).toHaveBeenCalledTimes(2)
|
||||
expect(device.queue.writeTexture).toHaveBeenCalledTimes(2)
|
||||
expect(GPUBrushRenderer).toHaveBeenCalledWith(device, 'rgba8unorm')
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyGpuToCanvas', () => {
|
||||
@@ -174,6 +334,18 @@ describe('initGPUResources with pre-existing tgpuRoot', () => {
|
||||
await initGPUResources()
|
||||
expect(hasRenderer.value).toBe(false)
|
||||
})
|
||||
|
||||
it('texture recreation watcher returns early when mask canvas is missing', async () => {
|
||||
const device = createMockDevice()
|
||||
const { initGPUResources } = setup()
|
||||
mockStore.tgpuRoot = { device, destroy: vi.fn() }
|
||||
|
||||
await initGPUResources()
|
||||
mockStore.gpuTexturesNeedRecreation = true
|
||||
await nextTick()
|
||||
|
||||
expect(device.createTexture).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('initPreviewCanvas', () => {
|
||||
@@ -182,6 +354,47 @@ describe('initPreviewCanvas', () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
expect(() => initPreviewCanvas(canvas)).not.toThrow()
|
||||
})
|
||||
|
||||
it('returns early when a WebGPU canvas context is unavailable', async () => {
|
||||
const device = createMockDevice()
|
||||
mockStore.tgpuRoot = { device, destroy: vi.fn() }
|
||||
setReadyCanvases()
|
||||
mockGpuBrushRenderer(createMockRenderer())
|
||||
|
||||
const { initGPUResources, initPreviewCanvas, previewCanvas } = setup()
|
||||
await initGPUResources()
|
||||
const canvas = document.createElement('canvas')
|
||||
Object.defineProperty(canvas, 'getContext', { value: vi.fn(() => null) })
|
||||
|
||||
initPreviewCanvas(canvas)
|
||||
|
||||
expect(previewCanvas.value).toBeNull()
|
||||
})
|
||||
|
||||
it('stores the preview canvas when a WebGPU context is available', async () => {
|
||||
const device = createMockDevice()
|
||||
const renderer = createMockRenderer()
|
||||
const previewContext = { configure: vi.fn() }
|
||||
mockStore.tgpuRoot = { device, destroy: vi.fn() }
|
||||
setReadyCanvases()
|
||||
mockGpuBrushRenderer(renderer)
|
||||
|
||||
const { initGPUResources, initPreviewCanvas, previewCanvas } = setup()
|
||||
await initGPUResources()
|
||||
const canvas = document.createElement('canvas')
|
||||
Object.defineProperty(canvas, 'getContext', {
|
||||
value: vi.fn(() => previewContext)
|
||||
})
|
||||
|
||||
initPreviewCanvas(canvas)
|
||||
|
||||
expect(previewContext.configure).toHaveBeenCalledWith({
|
||||
device,
|
||||
format: 'rgba8unorm',
|
||||
alphaMode: 'premultiplied'
|
||||
})
|
||||
expect(previewCanvas.value).toBe(canvas)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gpuDrawPoint', () => {
|
||||
@@ -189,4 +402,70 @@ describe('gpuDrawPoint', () => {
|
||||
const { gpuDrawPoint } = setup()
|
||||
await expect(gpuDrawPoint({ x: 10, y: 20 })).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('delegates renderer operations when GPU resources are initialized', async () => {
|
||||
const device = createMockDevice()
|
||||
const renderer = createMockRenderer()
|
||||
const previewContext = { configure: vi.fn() }
|
||||
mockStore.tgpuRoot = { device, destroy: vi.fn() }
|
||||
setReadyCanvases()
|
||||
mockGpuBrushRenderer(renderer)
|
||||
|
||||
const resources = setup()
|
||||
await resources.initGPUResources()
|
||||
const canvas = document.createElement('canvas')
|
||||
Object.defineProperty(canvas, 'getContext', {
|
||||
value: vi.fn(() => previewContext)
|
||||
})
|
||||
resources.initPreviewCanvas(canvas)
|
||||
|
||||
resources.prepareStroke()
|
||||
resources.clearPreview()
|
||||
resources.compositeStroke(false, false)
|
||||
resources.gpuRender([{ x: 1, y: 1 }])
|
||||
await resources.gpuDrawPoint({ x: 1, y: 1 })
|
||||
|
||||
expect(renderer.prepareStroke).toHaveBeenCalledWith(2, 2)
|
||||
expect(renderer.clearPreview).toHaveBeenCalledWith(previewContext)
|
||||
expect(renderer.compositeStroke).toHaveBeenCalled()
|
||||
expect(renderer.renderStrokeToAccumulator).toHaveBeenCalled()
|
||||
expect(renderer.blitToCanvas).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('copies initialized GPU readback data to canvases', async () => {
|
||||
const device = createMockDevice()
|
||||
const renderer = createMockRenderer()
|
||||
mockStore.tgpuRoot = { device, destroy: vi.fn() }
|
||||
setReadyCanvases()
|
||||
mockGpuBrushRenderer(renderer)
|
||||
|
||||
const resources = setup()
|
||||
await resources.initGPUResources()
|
||||
const result = await resources.copyGpuToCanvas()
|
||||
|
||||
expect(result.maskData.width).toBe(2)
|
||||
expect(result.rgbData.height).toBe(2)
|
||||
expect(renderer.prepareReadback).toHaveBeenCalledTimes(2)
|
||||
expect(mockStore.maskCtx?.putImageData).toHaveBeenCalled()
|
||||
expect(mockStore.rgbCtx?.putImageData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('destroys initialized GPU resources and root state', async () => {
|
||||
const device = createMockDevice()
|
||||
const renderer = createMockRenderer()
|
||||
const root = { device, destroy: vi.fn() }
|
||||
mockStore.tgpuRoot = root
|
||||
setReadyCanvases()
|
||||
mockGpuBrushRenderer(renderer)
|
||||
|
||||
const { initGPUResources, destroy, hasRenderer } = setup()
|
||||
await initGPUResources()
|
||||
|
||||
destroy()
|
||||
|
||||
expect(renderer.destroy).toHaveBeenCalled()
|
||||
expect(root.destroy).toHaveBeenCalled()
|
||||
expect(mockStore.tgpuRoot).toBeNull()
|
||||
expect(hasRenderer.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
408
src/composables/maskeditor/useMaskEditorLoader.test.ts
Normal file
408
src/composables/maskeditor/useMaskEditorLoader.test.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
extractWidgetStringValue,
|
||||
useMaskEditorLoader
|
||||
} from '@/composables/maskeditor/useMaskEditorLoader'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
interface MockInputData {
|
||||
baseLayer: { url: string }
|
||||
maskLayer: { url: string }
|
||||
paintLayer?: { url: string }
|
||||
sourceRef: { filename: string; subfolder?: string; type?: string }
|
||||
nodeId: unknown
|
||||
}
|
||||
|
||||
const mockDataStore = vi.hoisted(() => ({
|
||||
inputData: undefined as unknown,
|
||||
sourceNode: undefined as unknown,
|
||||
setLoading: vi.fn()
|
||||
}))
|
||||
|
||||
const mockNodeOutputStore = vi.hoisted(() => ({
|
||||
getNodeOutputs: vi.fn()
|
||||
}))
|
||||
|
||||
const mockCloudState = vi.hoisted(() => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/maskEditorDataStore', () => ({
|
||||
useMaskEditorDataStore: () => mockDataStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => mockNodeOutputStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockCloudState.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: vi.fn((path: string) => `http://comfy.test${path}`),
|
||||
fetchApi: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
getPreviewFormatParam: vi.fn(() => '&preview=png'),
|
||||
getRandParam: vi.fn(() => '&rand=1')
|
||||
}
|
||||
}))
|
||||
|
||||
function createMockImageClass(handler: 'onload' | 'onerror') {
|
||||
return class {
|
||||
crossOrigin = ''
|
||||
onerror: (() => void) | null = null
|
||||
onload: (() => void) | null = null
|
||||
private imageSrc = ''
|
||||
|
||||
get src() {
|
||||
return this.imageSrc
|
||||
}
|
||||
|
||||
set src(value: string) {
|
||||
this.imageSrc = value
|
||||
queueMicrotask(() => this[handler]?.())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MockImage = createMockImageClass('onload')
|
||||
|
||||
function makeNode(overrides: object = {}): LGraphNode {
|
||||
return fromAny({
|
||||
id: 42,
|
||||
imgs: [{ src: 'http://images.test/render.png?filename=render.png' }],
|
||||
imageIndex: 0,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
function getInputData(): MockInputData {
|
||||
return mockDataStore.inputData as MockInputData
|
||||
}
|
||||
|
||||
describe('extractWidgetStringValue', () => {
|
||||
it('extracts strings and filename objects', () => {
|
||||
expect(extractWidgetStringValue('image.png')).toBe('image.png')
|
||||
expect(extractWidgetStringValue({ filename: 'object.png' })).toBe(
|
||||
'object.png'
|
||||
)
|
||||
expect(extractWidgetStringValue({ filename: 123 })).toBeUndefined()
|
||||
expect(extractWidgetStringValue(null)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMaskEditorLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('Image', MockImage)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
vi.mocked(api.apiURL).mockClear()
|
||||
vi.mocked(api.fetchApi).mockReset()
|
||||
mockDataStore.inputData = undefined
|
||||
mockDataStore.sourceNode = undefined
|
||||
mockDataStore.setLoading.mockClear()
|
||||
mockNodeOutputStore.getNodeOutputs.mockReset()
|
||||
mockCloudState.isCloud = false
|
||||
})
|
||||
|
||||
it('loads base and mask layers from a node image reference', async () => {
|
||||
const node = makeNode({
|
||||
images: [
|
||||
{
|
||||
filename: 'node-output.png',
|
||||
subfolder: 'outputs',
|
||||
type: 'output'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(node)
|
||||
|
||||
expect(mockDataStore.setLoading).toHaveBeenNthCalledWith(1, true)
|
||||
expect(mockDataStore.setLoading).toHaveBeenLastCalledWith(false)
|
||||
expect(mockDataStore.sourceNode).toBe(node)
|
||||
expect(mockDataStore.inputData).toMatchObject({
|
||||
nodeId: 42,
|
||||
sourceRef: {
|
||||
filename: 'node-output.png',
|
||||
subfolder: 'outputs',
|
||||
type: 'output'
|
||||
},
|
||||
paintLayer: undefined
|
||||
})
|
||||
expect(getInputData().baseLayer.url).toContain('channel=rgb')
|
||||
expect(getInputData().maskLayer.url).toContain('channel=a')
|
||||
})
|
||||
|
||||
it('uses a concrete image widget value instead of a stale node image', async () => {
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'stale.png', type: 'output', subfolder: '' }],
|
||||
widgets: [
|
||||
{
|
||||
name: 'image',
|
||||
value: 'clipspace/current.png [input]'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef).toMatchObject({
|
||||
filename: 'current.png',
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
expect(getInputData().baseLayer.url).toContain('filename=current.png')
|
||||
})
|
||||
|
||||
it('keeps internal widget references from replacing the node image', async () => {
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'real-output.png', type: 'output' }],
|
||||
widgets: [{ name: 'image', value: '$35-0' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef.filename).toBe('real-output.png')
|
||||
})
|
||||
|
||||
it('loads image references from node output store data', async () => {
|
||||
mockNodeOutputStore.getNodeOutputs.mockReturnValue({
|
||||
images: [
|
||||
{
|
||||
filename: 'store-output.png',
|
||||
subfolder: 'store',
|
||||
type: 'temp'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: undefined,
|
||||
imgs: [{ src: 'data:image/png;base64,abc' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef).toMatchObject({
|
||||
filename: 'store-output.png',
|
||||
subfolder: 'store',
|
||||
type: 'temp'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the current non-data preview image when no image reference exists', async () => {
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: undefined,
|
||||
imageIndex: 1,
|
||||
imgs: [
|
||||
{ src: 'http://images.test/first.png?filename=first.png' },
|
||||
{ src: '/view?filename=second.png&type=input' }
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef).toMatchObject({
|
||||
filename: 'second.png',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses cloud mask layer metadata when available', async () => {
|
||||
mockCloudState.isCloud = true
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
painted_masked: 'painted-masked.png',
|
||||
painted: 'painted.png',
|
||||
paint: 'paint.png'
|
||||
})
|
||||
} as unknown as Response)
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'cloud.png', type: 'output' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/files/mask-layers?filename=cloud.png'
|
||||
)
|
||||
expect(getInputData().sourceRef.filename).toBe('painted-masked.png')
|
||||
expect(getInputData().paintLayer?.url).toContain('filename=paint.png')
|
||||
})
|
||||
|
||||
it('loads clipspace layer filenames from painted-masked images', async () => {
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [
|
||||
{
|
||||
filename: 'clipspace-painted-masked-123.png',
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef).toMatchObject({
|
||||
filename: 'clipspace-mask-123.png',
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
expect(getInputData().paintLayer?.url).toContain(
|
||||
'filename=clipspace-paint-123.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses painted cloud metadata when painted-masked metadata is absent', async () => {
|
||||
mockCloudState.isCloud = true
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
painted: 'painted-only.png'
|
||||
})
|
||||
} as unknown as Response)
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'cloud.png', type: 'output' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef.filename).toBe('painted-only.png')
|
||||
expect(getInputData().paintLayer).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the node image when cloud mask metadata is unavailable', async () => {
|
||||
mockCloudState.isCloud = true
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: false
|
||||
} as Response)
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'cloud.png', type: 'output' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef.filename).toBe('cloud.png')
|
||||
expect(getInputData().paintLayer).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the node image when cloud mask metadata lookup rejects', async () => {
|
||||
mockCloudState.isCloud = true
|
||||
vi.mocked(api.fetchApi).mockRejectedValue(new Error('offline'))
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'cloud.png', type: 'output' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef.filename).toBe('cloud.png')
|
||||
})
|
||||
|
||||
it('loads widget filenames without explicit folder metadata as inputs', async () => {
|
||||
await useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'stale.png', type: 'output' }],
|
||||
widgets: [
|
||||
{
|
||||
name: 'image',
|
||||
value: 'plain.png'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getInputData().sourceRef).toMatchObject({
|
||||
filename: 'plain.png',
|
||||
type: 'input'
|
||||
})
|
||||
expect(getInputData().sourceRef.subfolder).toBeUndefined()
|
||||
})
|
||||
|
||||
it('surfaces validation failures and clears loading state', async () => {
|
||||
await expect(
|
||||
useMaskEditorLoader().loadFromNode(makeNode({ imgs: [], images: [] }))
|
||||
).rejects.toThrow('Node has no images')
|
||||
|
||||
expect(mockDataStore.setLoading).toHaveBeenNthCalledWith(1, true)
|
||||
expect(mockDataStore.setLoading).toHaveBeenLastCalledWith(
|
||||
false,
|
||||
'Node has no images'
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces null node validation failures', async () => {
|
||||
await expect(
|
||||
useMaskEditorLoader().loadFromNode(null as unknown as LGraphNode)
|
||||
).rejects.toThrow('Node is null or undefined')
|
||||
})
|
||||
|
||||
it('surfaces missing output filenames', async () => {
|
||||
mockNodeOutputStore.getNodeOutputs.mockReturnValue({
|
||||
images: [
|
||||
{
|
||||
filename: '',
|
||||
type: 'output'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await expect(
|
||||
useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: undefined,
|
||||
imgs: [{ src: 'data:image/png;base64,abc' }]
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('nodeOutputStore image missing filename')
|
||||
})
|
||||
|
||||
it('rejects data previews without output metadata', async () => {
|
||||
await expect(
|
||||
useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: undefined,
|
||||
imgs: [{ src: 'data:image/png;base64,abc' }]
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('Unable to get image URL from node')
|
||||
})
|
||||
|
||||
it('rejects preview URLs without filename metadata', async () => {
|
||||
await expect(
|
||||
useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: undefined,
|
||||
imgs: [{ src: '/view?type=input' }]
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('Invalid image URL: /view?type=input')
|
||||
})
|
||||
|
||||
it('propagates image load failures', async () => {
|
||||
vi.stubGlobal('Image', createMockImageClass('onerror'))
|
||||
|
||||
await expect(
|
||||
useMaskEditorLoader().loadFromNode(
|
||||
makeNode({
|
||||
images: [{ filename: 'broken.png', type: 'output' }]
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('Failed to load image:')
|
||||
})
|
||||
})
|
||||
@@ -201,4 +201,108 @@ describe('useMaskEditorSaver', () => {
|
||||
expect(body.get('type')).toBe('input')
|
||||
expect(body.get('subfolder')).toBeNull()
|
||||
})
|
||||
|
||||
it('throws before saving when the source node is missing', async () => {
|
||||
mockDataStore.sourceNode = null
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow('No source node or input data')
|
||||
expect(api.fetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws before saving when the input data is missing', async () => {
|
||||
mockDataStore.inputData = null
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow('No source node or input data')
|
||||
expect(api.fetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails when canvases are not initialized', async () => {
|
||||
mockEditorStore.maskCanvas = null
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow('Canvas not initialized')
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'[MaskEditorSaver] Save failed:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('reports upload failures with the response body', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 413,
|
||||
text: () => Promise.resolve('too large')
|
||||
} as Response)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow(
|
||||
/Failed to upload clipspace-mask-.*: too large/
|
||||
)
|
||||
})
|
||||
|
||||
it('reports upload failures when the response body cannot be read', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.reject(new Error('body unavailable'))
|
||||
} as Response)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow(/Failed to upload .+ \(500\)/)
|
||||
})
|
||||
|
||||
it('reports invalid upload JSON responses', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.reject(new Error('bad json'))
|
||||
} as Response)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow(/Invalid upload response.*bad json/)
|
||||
})
|
||||
|
||||
it('reports upload responses without a name', async () => {
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ subfolder: 'clipspace', type: 'input' })
|
||||
} as Response)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
|
||||
await expect(save()).rejects.toThrow(
|
||||
"Upload response missing 'name' for clipspace-mask-"
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults missing upload ref fields and skips missing image widget state', async () => {
|
||||
mockNode.widgets = [fromAny({ name: 'other', value: 'unchanged' })]
|
||||
mockNode.widgets_values = ['unchanged']
|
||||
mockNode.properties = fromAny(undefined)
|
||||
mockNode.graph = fromAny(undefined)
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ name: 'uploaded.png' })
|
||||
} as Response)
|
||||
|
||||
const { save } = useMaskEditorSaver()
|
||||
await save()
|
||||
|
||||
expect(mockNode.images).toEqual([
|
||||
{ filename: 'uploaded.png', subfolder: '', type: 'input' }
|
||||
])
|
||||
expect(mockNode.widgets_values).toEqual(['unchanged'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -181,6 +181,50 @@ describe('usePanAndZoom', () => {
|
||||
expect(rgbCanvas.width).toBe(800)
|
||||
expect(rgbCanvas.height).toBe(600)
|
||||
})
|
||||
|
||||
it('returns before publishing pan when the canvas container is unavailable', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(800, 600),
|
||||
createMockElement()
|
||||
)
|
||||
|
||||
expect(mockStore.setPanOffset).not.toHaveBeenCalled()
|
||||
expect(mockStore.setZoomRatio).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps rgbCanvas dimensions when they already match the image', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
const rgbCanvas = createMockCanvas(800, 600)
|
||||
mockStore.canvasContainer = createMockElement() as unknown as HTMLElement
|
||||
mockStore.rgbCanvas = rgbCanvas
|
||||
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(800, 600),
|
||||
createMockElement()
|
||||
)
|
||||
|
||||
expect(rgbCanvas.width).toBe(800)
|
||||
expect(rgbCanvas.height).toBe(600)
|
||||
expect(rgbCanvas.style.width).not.toBe('')
|
||||
})
|
||||
|
||||
it('can be initialized again without replacing the current image reference', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
mockStore.canvasContainer = createMockElement() as unknown as HTMLElement
|
||||
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(800, 600),
|
||||
createMockElement()
|
||||
)
|
||||
await pz.initializeCanvasPanZoom(
|
||||
createMockImage(400, 300),
|
||||
createMockElement()
|
||||
)
|
||||
|
||||
expect(mockStore.setZoomRatio).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePanStart / handlePanMove', () => {
|
||||
@@ -345,6 +389,13 @@ describe('usePanAndZoom', () => {
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts touch starts without active touches', () => {
|
||||
const pz = usePanAndZoom()
|
||||
pz.handleTouchStart(createTouchEvent(createTouchList()))
|
||||
expect(mockStore.brushVisible).toBe(false)
|
||||
expect(mockStore.canvasHistory.undo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores touch when pen pointer is active', () => {
|
||||
const pz = usePanAndZoom()
|
||||
pz.addPenPointerId(1)
|
||||
@@ -401,6 +452,49 @@ describe('usePanAndZoom', () => {
|
||||
expect(mockStore.setZoomRatio).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns from pinch zoom when the mask canvas is unavailable', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
mockStore.maskCanvas = null
|
||||
|
||||
pz.handleTouchStart(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 200, y: 300 }, { x: 400, y: 300 })
|
||||
)
|
||||
)
|
||||
|
||||
await pz.handleTouchMove(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 150, y: 300 }, { x: 450, y: 300 })
|
||||
)
|
||||
)
|
||||
|
||||
expect(mockStore.setZoomRatio).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the cached mask canvas for consecutive pinch moves', async () => {
|
||||
const { pz } = await initComposable()
|
||||
|
||||
pz.handleTouchStart(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 200, y: 300 }, { x: 400, y: 300 })
|
||||
)
|
||||
)
|
||||
await pz.handleTouchMove(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 150, y: 300 }, { x: 450, y: 300 })
|
||||
)
|
||||
)
|
||||
vi.clearAllMocks()
|
||||
|
||||
await pz.handleTouchMove(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 140, y: 300 }, { x: 460, y: 300 })
|
||||
)
|
||||
)
|
||||
|
||||
expect(mockStore.setZoomRatio).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('touch move is ignored when pen is active', async () => {
|
||||
const pz = usePanAndZoom()
|
||||
pz.addPenPointerId(1)
|
||||
@@ -418,6 +512,26 @@ describe('usePanAndZoom', () => {
|
||||
pz.handleTouchEnd(event)
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops pinch zooming when all touches end', async () => {
|
||||
const { pz } = await initComposable()
|
||||
|
||||
pz.handleTouchStart(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 200, y: 300 }, { x: 400, y: 300 })
|
||||
)
|
||||
)
|
||||
pz.handleTouchEnd(createTouchEvent(createTouchList()))
|
||||
vi.clearAllMocks()
|
||||
|
||||
await pz.handleTouchMove(
|
||||
createTouchEvent(
|
||||
createTouchList({ x: 150, y: 300 }, { x: 450, y: 300 })
|
||||
)
|
||||
)
|
||||
|
||||
expect(mockStore.setZoomRatio).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pen pointer management', () => {
|
||||
|
||||
@@ -73,4 +73,45 @@ describe('useNodeAnimatedImage', () => {
|
||||
expect(canvasInteractionsMock.handlePointerDown).not.toHaveBeenCalled()
|
||||
expect(canvasInteractionsMock.forwardEventToCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when a node has no images or widgets', () => {
|
||||
const { showAnimatedPreview, removeAnimatedPreview } =
|
||||
useNodeAnimatedImage()
|
||||
const noImageNode = createMockMediaNode({ imgs: [] })
|
||||
const noWidgetNode = Object.assign(
|
||||
createMockMediaNode({ imgs: [document.createElement('img')] }),
|
||||
{ widgets: undefined }
|
||||
)
|
||||
|
||||
showAnimatedPreview(noImageNode)
|
||||
showAnimatedPreview(noWidgetNode)
|
||||
removeAnimatedPreview(noWidgetNode)
|
||||
|
||||
expect(noImageNode.widgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('replaces the image in an existing preview widget', () => {
|
||||
const { node, showAnimatedPreview } = setup()
|
||||
const firstWidget = node.widgets[0]
|
||||
const nextImage = document.createElement('img')
|
||||
node.imgs = [nextImage]
|
||||
|
||||
showAnimatedPreview(node)
|
||||
|
||||
expect(node.widgets).toHaveLength(1)
|
||||
expect(node.widgets[0]).toBe(firstWidget)
|
||||
expect(firstWidget.element.firstChild).toBe(nextImage)
|
||||
})
|
||||
|
||||
it('leaves an existing non-DOM preview widget untouched', () => {
|
||||
const node = createMockMediaNode({ imgs: [document.createElement('img')] })
|
||||
node.widgets.push({
|
||||
name: '$$comfy_animation_preview',
|
||||
element: undefined as unknown as HTMLElement
|
||||
})
|
||||
|
||||
useNodeAnimatedImage().showAnimatedPreview(node)
|
||||
|
||||
expect(node.widgets).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
430
src/composables/node/useNodeBadge.test.ts
Normal file
430
src/composables/node/useNodeBadge.test.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick } from 'vue'
|
||||
import type { App as VueApp } from 'vue'
|
||||
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { BadgePosition, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
const {
|
||||
settings,
|
||||
appState,
|
||||
extensionState,
|
||||
nodeDefState,
|
||||
pricingState,
|
||||
setDirtyMock,
|
||||
addEventListenerMock,
|
||||
registerExtensionMock,
|
||||
getCreditsBadgeMock,
|
||||
updateSubgraphCreditsMock,
|
||||
getNodePricingConfigMock,
|
||||
getNodeDisplayPriceMock,
|
||||
getRelevantWidgetNamesMock,
|
||||
triggerPriceRecalculationMock,
|
||||
useComputedWithWidgetWatchMock
|
||||
} = vi.hoisted(() => ({
|
||||
settings: {} as Record<string, unknown>,
|
||||
appState: {
|
||||
graph: {
|
||||
nodes: [] as unknown[]
|
||||
}
|
||||
},
|
||||
extensionState: {
|
||||
installed: false,
|
||||
registered: undefined as ComfyExtension | undefined
|
||||
},
|
||||
nodeDefState: {
|
||||
value: null as Record<string, unknown> | null
|
||||
},
|
||||
pricingState: {
|
||||
revision: { value: 0 },
|
||||
config: undefined as
|
||||
| {
|
||||
depends_on?: {
|
||||
widgets?: string[]
|
||||
inputs?: string[]
|
||||
input_groups?: string[]
|
||||
}
|
||||
}
|
||||
| undefined,
|
||||
label: '1 credit'
|
||||
},
|
||||
setDirtyMock: vi.fn(),
|
||||
addEventListenerMock: vi.fn(),
|
||||
registerExtensionMock: vi.fn((extension: ComfyExtension) => {
|
||||
extensionState.registered = extension
|
||||
}),
|
||||
getCreditsBadgeMock: vi.fn((text: string) => ({ text })),
|
||||
updateSubgraphCreditsMock: vi.fn(),
|
||||
getNodePricingConfigMock: vi.fn(() => pricingState.config),
|
||||
getNodeDisplayPriceMock: vi.fn(() => pricingState.label),
|
||||
getRelevantWidgetNamesMock: vi.fn(() => ['seed']),
|
||||
triggerPriceRecalculationMock: vi.fn(),
|
||||
useComputedWithWidgetWatchMock: vi.fn(() => vi.fn())
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
setDirty: setDirtyMock,
|
||||
canvas: {
|
||||
addEventListener: addEventListenerMock
|
||||
},
|
||||
graph: appState.graph
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => settings[key]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/extensionStore', () => ({
|
||||
useExtensionStore: () => ({
|
||||
isExtensionInstalled: () => extensionState.installed,
|
||||
registerExtension: registerExtensionMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
useNodeDefStore: () => ({
|
||||
fromLGraphNode: () => nodeDefState.value
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => ({
|
||||
completedActivePalette: {
|
||||
colors: {
|
||||
litegraph_base: {
|
||||
BADGE_FG_COLOR: '#fff',
|
||||
BADGE_BG_COLOR: '#000'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodePricing', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const pricingRevision = ref(pricingState.revision.value)
|
||||
Object.defineProperty(pricingState.revision, 'value', {
|
||||
get: () => pricingRevision.value,
|
||||
set: (value: number) => {
|
||||
pricingRevision.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
useNodePricing: () => ({
|
||||
pricingRevision,
|
||||
getNodePricingConfig: getNodePricingConfigMock,
|
||||
getNodeDisplayPrice: getNodeDisplayPriceMock,
|
||||
getRelevantWidgetNames: getRelevantWidgetNamesMock,
|
||||
triggerPriceRecalculation: triggerPriceRecalculationMock
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/node/usePriceBadge', () => ({
|
||||
usePriceBadge: () => ({
|
||||
getCreditsBadge: getCreditsBadgeMock,
|
||||
updateSubgraphCredits: updateSubgraphCreditsMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useWatchWidget', () => ({
|
||||
useComputedWithWidgetWatch: useComputedWithWidgetWatchMock
|
||||
}))
|
||||
|
||||
class ApiNode extends LGraphNode {
|
||||
static override nodeData = { name: 'ApiNode', api_node: true }
|
||||
}
|
||||
|
||||
function mountBadge(): VueApp {
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
useNodeBadge()
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
)
|
||||
app.mount(document.createElement('div'))
|
||||
return app
|
||||
}
|
||||
|
||||
function registeredExtension(): ComfyExtension {
|
||||
if (!extensionState.registered)
|
||||
throw new Error('Missing registered extension')
|
||||
return extensionState.registered
|
||||
}
|
||||
|
||||
function comfyApp(): Parameters<NonNullable<ComfyExtension['init']>>[0] {
|
||||
return {} as Parameters<NonNullable<ComfyExtension['init']>>[0]
|
||||
}
|
||||
|
||||
function callNodeCreated(node: LGraphNode) {
|
||||
registeredExtension().nodeCreated?.(node, comfyApp())
|
||||
}
|
||||
|
||||
function inputSlot(name: string) {
|
||||
return new LGraphNode('slot').addInput(name, '*')
|
||||
}
|
||||
|
||||
function defaultSettings() {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.None
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = false
|
||||
}
|
||||
|
||||
describe('useNodeBadge', () => {
|
||||
let mountedApp: VueApp | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
defaultSettings()
|
||||
extensionState.installed = false
|
||||
extensionState.registered = undefined
|
||||
appState.graph.nodes = []
|
||||
nodeDefState.value = null
|
||||
pricingState.revision.value = 0
|
||||
pricingState.config = undefined
|
||||
pricingState.label = '1 credit'
|
||||
setDirtyMock.mockClear()
|
||||
addEventListenerMock.mockClear()
|
||||
registerExtensionMock.mockClear()
|
||||
getCreditsBadgeMock.mockClear()
|
||||
updateSubgraphCreditsMock.mockClear()
|
||||
getNodePricingConfigMock.mockClear()
|
||||
getNodeDisplayPriceMock.mockClear()
|
||||
getRelevantWidgetNamesMock.mockClear()
|
||||
triggerPriceRecalculationMock.mockClear()
|
||||
useComputedWithWidgetWatchMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mountedApp?.unmount()
|
||||
mountedApp = undefined
|
||||
})
|
||||
|
||||
it('does not register the badge extension twice', async () => {
|
||||
extensionState.installed = true
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
|
||||
expect(registerExtensionMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds the configured node identity badge', async () => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] =
|
||||
NodeBadgeMode.HideBuiltIn
|
||||
nodeDefState.value = {
|
||||
isCoreNode: false,
|
||||
nodeLifeCycleBadgeText: 'Beta',
|
||||
nodeSource: { badgeText: 'Pack' }
|
||||
}
|
||||
const node = new LGraphNode('Test')
|
||||
node.id = toNodeId('7')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
const badge = node.badges[0] as () => LGraphBadge
|
||||
|
||||
expect(node.badgePosition).toBe(BadgePosition.TopRight)
|
||||
expect(badge().text).toBe('#7 Beta Pack')
|
||||
})
|
||||
|
||||
it('hides built-in badge text when the mode excludes core nodes', async () => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.HideBuiltIn
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] =
|
||||
NodeBadgeMode.HideBuiltIn
|
||||
nodeDefState.value = {
|
||||
isCoreNode: true,
|
||||
nodeLifeCycleBadgeText: 'Core',
|
||||
nodeSource: { badgeText: 'Built-in' }
|
||||
}
|
||||
const node = new LGraphNode('Core')
|
||||
node.id = toNodeId('11')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
const badge = node.badges[0] as () => LGraphBadge
|
||||
|
||||
expect(badge().text).toBe('#11')
|
||||
})
|
||||
|
||||
it('keeps optional node definition badge text empty', async () => {
|
||||
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.ShowAll
|
||||
nodeDefState.value = null
|
||||
const node = new LGraphNode('NoDef')
|
||||
node.id = toNodeId('13')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
const badge = node.badges[0] as () => LGraphBadge
|
||||
|
||||
expect(badge().text).toBe('#13')
|
||||
})
|
||||
|
||||
it('marks the canvas dirty when pricing changes while pricing badges are visible', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = true
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
|
||||
pricingState.revision.value++
|
||||
await nextTick()
|
||||
|
||||
expect(setDirtyMock).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('does not add API pricing badges when the pricing setting is disabled', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = false
|
||||
const node = new ApiNode('API')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
|
||||
expect(node.badges).toHaveLength(1)
|
||||
expect(getCreditsBadgeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds static API pricing badges without widget watchers', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = true
|
||||
pricingState.config = undefined
|
||||
const node = new ApiNode('API')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
|
||||
expect(node.badges).toHaveLength(2)
|
||||
expect(useComputedWithWidgetWatchMock).not.toHaveBeenCalled()
|
||||
expect(getCreditsBadgeMock).toHaveBeenCalledWith('1 credit')
|
||||
})
|
||||
|
||||
it('adds dynamic widget pricing without connection hooks when no inputs matter', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = true
|
||||
pricingState.config = {
|
||||
depends_on: {
|
||||
widgets: ['seed']
|
||||
}
|
||||
}
|
||||
const node = new ApiNode('API')
|
||||
const originalOnConnectionsChange = node.onConnectionsChange
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
|
||||
expect(useComputedWithWidgetWatchMock).toHaveBeenCalled()
|
||||
expect(node.onConnectionsChange).toBe(originalOnConnectionsChange)
|
||||
})
|
||||
|
||||
it('adds dynamic API pricing badges and refreshes relevant input changes', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = true
|
||||
pricingState.config = {
|
||||
depends_on: {
|
||||
widgets: ['seed'],
|
||||
inputs: ['image'],
|
||||
input_groups: ['lora']
|
||||
}
|
||||
}
|
||||
const originalOnConnectionsChange = vi.fn()
|
||||
const node = new ApiNode('API')
|
||||
node.onConnectionsChange = originalOnConnectionsChange
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
|
||||
expect(useComputedWithWidgetWatchMock).toHaveBeenCalledWith(node, {
|
||||
widgetNames: ['seed'],
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
expect(getCreditsBadgeMock).toHaveBeenCalledWith('1 credit')
|
||||
|
||||
const priceBadge = node.badges[1] as () => { text: string }
|
||||
expect(priceBadge().text).toBe('1 credit')
|
||||
pricingState.label = '2 credits'
|
||||
expect(priceBadge().text).toBe('2 credits')
|
||||
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('image'))
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('lora.0'))
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('clip'))
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot(''))
|
||||
|
||||
expect(originalOnConnectionsChange).toHaveBeenCalledTimes(4)
|
||||
expect(triggerPriceRecalculationMock).toHaveBeenCalledTimes(2)
|
||||
expect(triggerPriceRecalculationMock).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('refreshes dynamic pricing inputs without an existing connection hook', async () => {
|
||||
settings['Comfy.NodeBadge.ShowApiPricing'] = true
|
||||
pricingState.config = {
|
||||
depends_on: {
|
||||
inputs: ['image']
|
||||
}
|
||||
}
|
||||
const node = new ApiNode('API')
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
callNodeCreated(node)
|
||||
|
||||
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('image'))
|
||||
|
||||
expect(triggerPriceRecalculationMock).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('updates subgraph credit badges from registered extension hooks', async () => {
|
||||
const nodes = [new LGraphNode('one'), new LGraphNode('two')]
|
||||
appState.graph.nodes = nodes
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
await registeredExtension().init?.(comfyApp())
|
||||
await registeredExtension().afterConfigureGraph?.([], comfyApp())
|
||||
|
||||
const setGraphHandler = addEventListenerMock.mock.calls.find(
|
||||
([event]) => event === 'litegraph:set-graph'
|
||||
)?.[1]
|
||||
const convertedHandler = addEventListenerMock.mock.calls.find(
|
||||
([event]) => event === 'subgraph-converted'
|
||||
)?.[1]
|
||||
setGraphHandler?.()
|
||||
convertedHandler?.({ detail: { subgraphNode: nodes[0] } })
|
||||
|
||||
expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[0])
|
||||
expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[1])
|
||||
})
|
||||
|
||||
it('handles empty graph nodes during registered extension hooks', async () => {
|
||||
appState.graph.nodes = undefined as unknown as LGraphNode[]
|
||||
|
||||
mountedApp = mountBadge()
|
||||
await nextTick()
|
||||
await registeredExtension().init?.(comfyApp())
|
||||
await registeredExtension().afterConfigureGraph?.([], comfyApp())
|
||||
|
||||
const setGraphHandler = addEventListenerMock.mock.calls.find(
|
||||
([event]) => event === 'litegraph:set-graph'
|
||||
)?.[1]
|
||||
setGraphHandler?.()
|
||||
|
||||
expect(updateSubgraphCreditsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -65,4 +65,29 @@ describe('useNodeCanvasImagePreview', () => {
|
||||
|
||||
expect(imagePreviewWidget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when removing from a node without widgets', () => {
|
||||
const node = Object.assign(new LGraphNode('test'), { widgets: undefined })
|
||||
|
||||
useNodeCanvasImagePreview().removeCanvasImagePreview(node)
|
||||
|
||||
expect(imagePreviewWidget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes an existing preview widget and calls its cleanup', () => {
|
||||
const node = new LGraphNode('test')
|
||||
const widget = node.addWidget(
|
||||
'text',
|
||||
'$$canvas-image-preview',
|
||||
'',
|
||||
() => undefined,
|
||||
{}
|
||||
)
|
||||
widget.onRemove = vi.fn()
|
||||
|
||||
useNodeCanvasImagePreview().removeCanvasImagePreview(node)
|
||||
|
||||
expect(widget.onRemove).toHaveBeenCalledOnce()
|
||||
expect(node.widgets).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
|
||||
import { useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||
import { createMockMediaNode } from '@/renderer/extensions/vueNodes/widgets/composables/domWidgetTestUtils'
|
||||
|
||||
const { canvasInteractionsMock, nodeOutputStoreMock } = vi.hoisted(() => ({
|
||||
@@ -28,8 +28,24 @@ describe('useNodeVideo', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function installMockImage() {
|
||||
const images: HTMLImageElement[] = []
|
||||
class MockImage {
|
||||
onload: ((event: Event) => void) | null = null
|
||||
onerror: ((event: Event) => void) | null = null
|
||||
src = ''
|
||||
|
||||
constructor() {
|
||||
images.push(this as unknown as HTMLImageElement)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Image', MockImage)
|
||||
return images
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
@@ -93,4 +109,103 @@ describe('useNodeVideo', () => {
|
||||
expect(canvasInteractionsMock.handlePointerMove).not.toHaveBeenCalled()
|
||||
expect(canvasInteractionsMock.handlePointerDown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads image previews and marks the graph dirty', async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
const images = installMockImage()
|
||||
const graph = { setDirtyCanvas: vi.fn() }
|
||||
const node = createMockMediaNode({ graph })
|
||||
const callback = vi.fn()
|
||||
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(['http://image/1.png'])
|
||||
|
||||
const { showPreview } = useNodeImage(node, callback)
|
||||
showPreview({ block: true })
|
||||
images[0].onload?.(new Event('load'))
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(node.previewMediaType).toBe('image')
|
||||
expect(node.imageIndex).toBeNull()
|
||||
expect(node.imgs).toEqual([images[0]])
|
||||
expect(node.isLoading).toBe(false)
|
||||
expect(callback).toHaveBeenCalledTimes(1)
|
||||
expect(graph.setDirtyCanvas).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('does not start image loads while already loading or without output URLs', () => {
|
||||
vi.clearAllMocks()
|
||||
const images = installMockImage()
|
||||
const node = createMockMediaNode()
|
||||
const { showPreview } = useNodeImage(node)
|
||||
|
||||
node.isLoading = true
|
||||
showPreview()
|
||||
node.isLoading = false
|
||||
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(undefined)
|
||||
showPreview()
|
||||
|
||||
expect(images).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('retries image loading once when the first load fails', async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
const images = installMockImage()
|
||||
const graph = { setDirtyCanvas: vi.fn() }
|
||||
const node = createMockMediaNode({ graph })
|
||||
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue([
|
||||
'http://image/missing.png'
|
||||
])
|
||||
|
||||
const staleImgs = [document.createElement('img')]
|
||||
node.imgs = staleImgs
|
||||
|
||||
const { showPreview } = useNodeImage(node)
|
||||
showPreview()
|
||||
images[0].onerror?.(new Event('error'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
images[1].onerror?.(new Event('error'))
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(images).toHaveLength(2)
|
||||
// Failed loads never resolve to elements, so existing previews are untouched
|
||||
expect(node.imgs).toBe(staleImgs)
|
||||
expect(graph.setDirtyCanvas).not.toHaveBeenCalled()
|
||||
expect(node.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('reuses an existing video-preview widget when loading a video', async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
nodeOutputStoreMock.getNodeImageUrls.mockReturnValue(['http://video/1.mp4'])
|
||||
const node = createMockMediaNode({
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
})
|
||||
node.widgets.push({
|
||||
name: 'video-preview',
|
||||
element: document.createElement('div')
|
||||
})
|
||||
const callback = vi.fn()
|
||||
|
||||
const createdVideos: HTMLVideoElement[] = []
|
||||
const realCreateElement = document.createElement.bind(document)
|
||||
vi.spyOn(document, 'createElement').mockImplementation(
|
||||
(tag: string, opts?: ElementCreationOptions) => {
|
||||
const el = realCreateElement(tag, opts)
|
||||
if (tag === 'video') createdVideos.push(el as HTMLVideoElement)
|
||||
return el
|
||||
}
|
||||
)
|
||||
|
||||
const { showPreview } = useNodeVideo(node, callback)
|
||||
showPreview()
|
||||
const video = createdVideos[0]
|
||||
video.onloadeddata?.(new Event('loadeddata'))
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(node.addDOMWidget).not.toHaveBeenCalled()
|
||||
expect(node.videoContainer?.firstChild).toBe(video)
|
||||
expect(callback).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ResultItem } from '@/schemas/apiSchema'
|
||||
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
|
||||
|
||||
const { mockFetchApi, mockAddAlert, mockUpdateInputs } = vi.hoisted(() => ({
|
||||
mockFetchApi: vi.fn(),
|
||||
@@ -11,22 +11,41 @@ const { mockFetchApi, mockAddAlert, mockUpdateInputs } = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
let capturedDragOnDrop: (files: File[]) => Promise<string[]>
|
||||
let capturedResultItemDrop: (item: ResultItem) => void
|
||||
let capturedPasteOnPaste: (files: File[]) => Promise<string[]>
|
||||
let capturedFileInputOnSelect: (files: File[]) => Promise<string[]>
|
||||
const mockOpenFileSelection = vi.fn()
|
||||
|
||||
vi.mock('@/composables/node/useNodeDragAndDrop', () => ({
|
||||
useNodeDragAndDrop: (
|
||||
_node: LGraphNode,
|
||||
opts: { onDrop: typeof capturedDragOnDrop }
|
||||
opts: {
|
||||
onDrop: typeof capturedDragOnDrop
|
||||
onResultItemDrop: typeof capturedResultItemDrop
|
||||
}
|
||||
) => {
|
||||
capturedDragOnDrop = opts.onDrop
|
||||
capturedResultItemDrop = opts.onResultItemDrop
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodeFileInput', () => ({
|
||||
useNodeFileInput: () => ({ openFileSelection: vi.fn() })
|
||||
useNodeFileInput: (
|
||||
_node: LGraphNode,
|
||||
opts: { onSelect: typeof capturedFileInputOnSelect }
|
||||
) => {
|
||||
capturedFileInputOnSelect = opts.onSelect
|
||||
return { openFileSelection: mockOpenFileSelection }
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodePaste', () => ({
|
||||
useNodePaste: vi.fn()
|
||||
useNodePaste: (
|
||||
_node: LGraphNode,
|
||||
opts: { onPaste: typeof capturedPasteOnPaste }
|
||||
) => {
|
||||
capturedPasteOnPaste = opts.onPaste
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
@@ -78,6 +97,26 @@ describe('useNodeImageUpload', () => {
|
||||
let onUploadStart: (files: File[]) => void
|
||||
let onUploadError: () => void
|
||||
|
||||
async function mountImageUpload(
|
||||
options: { folder?: ResultItemType } = { folder: 'input' }
|
||||
) {
|
||||
const { useNodeImageUpload } = await import('./useNodeImageUpload')
|
||||
return useNodeImageUpload(node, {
|
||||
onUploadComplete,
|
||||
onUploadStart,
|
||||
onUploadError,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
function lastUploadBody() {
|
||||
const body = mockFetchApi.mock.calls.at(-1)?.[1]?.body
|
||||
if (!(body instanceof FormData)) {
|
||||
throw new Error('Expected upload body to be FormData')
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
@@ -86,13 +125,7 @@ describe('useNodeImageUpload', () => {
|
||||
onUploadStart = vi.fn()
|
||||
onUploadError = vi.fn()
|
||||
|
||||
const { useNodeImageUpload } = await import('./useNodeImageUpload')
|
||||
useNodeImageUpload(node, {
|
||||
onUploadComplete,
|
||||
onUploadStart,
|
||||
onUploadError,
|
||||
folder: 'input'
|
||||
})
|
||||
await mountImageUpload()
|
||||
})
|
||||
|
||||
it.for([
|
||||
@@ -180,4 +213,60 @@ describe('useNodeImageUpload', () => {
|
||||
await capturedDragOnDrop([createFile()])
|
||||
expect(node.graph?.setDirtyCanvas).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('passes dropped result items through without uploading', () => {
|
||||
const resultItem = fromAny<ResultItem, unknown>({
|
||||
filename: 'existing.png',
|
||||
subfolder: '',
|
||||
type: 'input'
|
||||
})
|
||||
|
||||
capturedResultItemDrop(resultItem)
|
||||
|
||||
expect(onUploadComplete).toHaveBeenCalledWith([resultItem])
|
||||
expect(mockFetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uploads pasted images to the pasted subfolder', async () => {
|
||||
const { handleUpload } = await mountImageUpload({})
|
||||
mockFetchApi.mockResolvedValueOnce(successResponse('image.png'))
|
||||
|
||||
await handleUpload(createFile('image.png'))
|
||||
|
||||
const body = lastUploadBody()
|
||||
expect(body.get('subfolder')).toBe('pasted')
|
||||
expect(body.get('type')).toBeNull()
|
||||
expect(mockUpdateInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes input assets for default non-pasted uploads', async () => {
|
||||
const { handleUpload } = await mountImageUpload({})
|
||||
mockFetchApi.mockResolvedValueOnce(successResponse('upload.png'))
|
||||
|
||||
await handleUpload(createFile('upload.png'))
|
||||
|
||||
const body = lastUploadBody()
|
||||
expect(body.get('subfolder')).toBeNull()
|
||||
expect(body.get('type')).toBeNull()
|
||||
expect(mockUpdateInputs).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not refresh input assets for explicit output uploads', async () => {
|
||||
await mountImageUpload({ folder: 'output' })
|
||||
mockFetchApi.mockResolvedValueOnce(successResponse('output.png'))
|
||||
|
||||
await capturedFileInputOnSelect([createFile('output.png')])
|
||||
|
||||
const body = lastUploadBody()
|
||||
expect(body.get('type')).toBe('output')
|
||||
expect(mockUpdateInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a specific alert for upload timeouts', async () => {
|
||||
mockFetchApi.mockRejectedValueOnce(new DOMException('', 'TimeoutError'))
|
||||
|
||||
await capturedPasteOnPaste([createFile()])
|
||||
|
||||
expect(mockAddAlert).toHaveBeenCalledWith('g.uploadTimedOut')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
|
||||
import {
|
||||
@@ -12,6 +15,7 @@ import {
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDef, PriceBadge } from '@/schemas/nodeDefSchema'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
@@ -123,6 +127,35 @@ function createMockNode(
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveDisplayPrice(
|
||||
node: LGraphNode,
|
||||
widgetOverrides?: ReadonlyMap<string, unknown>
|
||||
): Promise<string> {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
getNodeDisplayPrice(node, widgetOverrides)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
return getNodeDisplayPrice(node, widgetOverrides)
|
||||
}
|
||||
|
||||
function createStoredNodeDef(
|
||||
name: string,
|
||||
price_badge?: PriceBadge
|
||||
): ComfyNodeDef {
|
||||
return {
|
||||
name,
|
||||
display_name: name,
|
||||
description: '',
|
||||
category: 'test',
|
||||
input: { required: {}, optional: {} },
|
||||
output: [],
|
||||
output_name: [],
|
||||
output_is_list: [],
|
||||
output_node: false,
|
||||
python_module: 'test',
|
||||
price_badge
|
||||
} as ComfyNodeDef
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -189,6 +222,32 @@ describe('useNodePricing', () => {
|
||||
expect(price).toBe(creditsLabel(0.5))
|
||||
})
|
||||
|
||||
it('should parse numeric strings and reject blank or invalid numbers', async () => {
|
||||
const expression =
|
||||
'{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.20}'
|
||||
const badge = priceBadge(expression, [{ name: 'count', type: 'INT' }])
|
||||
|
||||
const parsedNode = createMockNodeWithPriceBadge(
|
||||
'TestNumericStringNode',
|
||||
badge,
|
||||
[{ name: 'count', value: ' 5 ' }]
|
||||
)
|
||||
const blankNode = createMockNodeWithPriceBadge(
|
||||
'TestBlankNumericStringNode',
|
||||
badge,
|
||||
[{ name: 'count', value: ' ' }]
|
||||
)
|
||||
const invalidNode = createMockNodeWithPriceBadge(
|
||||
'TestInvalidNumericStringNode',
|
||||
badge,
|
||||
[{ name: 'count', value: 'five' }]
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(parsedNode)).toBe(creditsLabel(0.05))
|
||||
expect(await resolveDisplayPrice(blankNode)).toBe(creditsLabel(0.2))
|
||||
expect(await resolveDisplayPrice(invalidNode)).toBe(creditsLabel(0.2))
|
||||
})
|
||||
|
||||
it('should handle COMBO widget with numeric value', async () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
@@ -222,6 +281,19 @@ describe('useNodePricing', () => {
|
||||
expect(price).toBe(creditsLabel(0.1))
|
||||
})
|
||||
|
||||
it('should preserve boolean combo values', async () => {
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestComboBooleanNode',
|
||||
priceBadge(
|
||||
'(widgets.enabled = false) ? {"type":"usd","usd":0.04} : {"type":"usd","usd":0.08}',
|
||||
[{ name: 'enabled', type: 'COMBO' }]
|
||||
),
|
||||
[{ name: 'enabled', value: false }]
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.04))
|
||||
})
|
||||
|
||||
it('should handle BOOLEAN widget', async () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
@@ -238,6 +310,64 @@ describe('useNodePricing', () => {
|
||||
expect(price).toBe(creditsLabel(0.1))
|
||||
})
|
||||
|
||||
it('should parse BOOLEAN widget string values', async () => {
|
||||
const badge = priceBadge(
|
||||
'{"type":"usd","usd": widgets.premium ? 0.10 : 0.05}',
|
||||
[{ name: 'premium', type: 'BOOLEAN' }]
|
||||
)
|
||||
const enabledNode = createMockNodeWithPriceBadge(
|
||||
'TestBooleanStringTrueNode',
|
||||
badge,
|
||||
[{ name: 'premium', value: ' TRUE ' }]
|
||||
)
|
||||
const disabledNode = createMockNodeWithPriceBadge(
|
||||
'TestBooleanStringFalseNode',
|
||||
badge,
|
||||
[{ name: 'premium', value: 'false' }]
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(enabledNode)).toBe(creditsLabel(0.1))
|
||||
expect(await resolveDisplayPrice(disabledNode)).toBe(creditsLabel(0.05))
|
||||
})
|
||||
|
||||
it('should reject invalid BOOLEAN strings', async () => {
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestInvalidBooleanStringNode',
|
||||
priceBadge(
|
||||
'{"type":"usd","usd": widgets.premium = null ? 0.05 : 0.10}',
|
||||
[{ name: 'premium', type: 'BOOLEAN' }]
|
||||
),
|
||||
[{ name: 'premium', value: 'sometimes' }]
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
|
||||
})
|
||||
|
||||
it('should reject non-boolean values for BOOLEAN widgets', async () => {
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestInvalidBooleanNumberNode',
|
||||
priceBadge(
|
||||
'{"type":"usd","usd": widgets.premium = null ? 0.05 : 0.10}',
|
||||
[{ name: 'premium', type: 'BOOLEAN' }]
|
||||
),
|
||||
[{ name: 'premium', value: 1 }]
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
|
||||
})
|
||||
|
||||
it('should reject object values for numeric widgets', async () => {
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestObjectNumericNode',
|
||||
priceBadge('{"type":"usd","usd": widgets.count = null ? 0.05 : 0.10}', [
|
||||
{ name: 'count', type: 'INT' }
|
||||
]),
|
||||
[{ name: 'count', value: { count: 5 } }]
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
|
||||
})
|
||||
|
||||
it('should handle STRING widget (lowercased)', async () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
@@ -468,6 +598,42 @@ describe('useNodePricing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('dependency context', () => {
|
||||
it('should prefer widget overrides over node widget values', async () => {
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestWidgetOverrideNode',
|
||||
priceBadge('{"type":"usd","usd": widgets.count * 0.01}', [
|
||||
{ name: 'count', type: 'INT' }
|
||||
]),
|
||||
[{ name: 'count', value: 2 }]
|
||||
)
|
||||
|
||||
const price = await resolveDisplayPrice(node, new Map([['count', '7']]))
|
||||
|
||||
expect(price).toBe(creditsLabel(0.07))
|
||||
})
|
||||
|
||||
it('should treat missing input group arrays as zero connected inputs', async () => {
|
||||
const node = Object.assign(createMockLGraphNode(), {
|
||||
widgets: [],
|
||||
constructor: {
|
||||
nodeData: {
|
||||
name: 'TestMissingInputGroupArrayNode',
|
||||
api_node: true,
|
||||
price_badge: priceBadge(
|
||||
'{"type":"usd","usd": (inputGroups.images = 0) ? 0.05 : 0.10}',
|
||||
[],
|
||||
[],
|
||||
['images']
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return empty string for non-API nodes', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
@@ -541,6 +707,43 @@ describe('useNodePricing', () => {
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(creditsLabel(0.05))
|
||||
})
|
||||
|
||||
it('should default missing price badge engine and dependency arrays', async () => {
|
||||
const bareBadge = {
|
||||
expr: '{"type":"usd","usd":0.05}'
|
||||
} as PriceBadge
|
||||
const node = createMockNodeWithPriceBadge('TestBareBadgeNode', bareBadge)
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
|
||||
|
||||
const { getNodePricingConfig } = useNodePricing()
|
||||
expect(getNodePricingConfig(node)).toMatchObject({
|
||||
engine: 'jsonata',
|
||||
depends_on: {
|
||||
widgets: [],
|
||||
inputs: [],
|
||||
input_groups: []
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore non-jsonata pricing engines', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestUnsupportedEngineNode',
|
||||
fromAny<PriceBadge, unknown>({
|
||||
engine: 'literal',
|
||||
expr: '{"type":"usd","usd":0.05}',
|
||||
depends_on: {
|
||||
widgets: [],
|
||||
inputs: [],
|
||||
input_groups: []
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(getNodeDisplayPrice(node)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodePricingConfig', () => {
|
||||
@@ -595,6 +798,107 @@ describe('useNodePricing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('node type pricing dependencies', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('returns empty dependency metadata for node types without pricing', () => {
|
||||
const store = useNodeDefStore()
|
||||
store.addNodeDef(createStoredNodeDef('UnpricedNode'))
|
||||
const {
|
||||
getInputGroupPrefixes,
|
||||
getInputNames,
|
||||
getRelevantWidgetNames,
|
||||
hasDynamicPricing
|
||||
} = useNodePricing()
|
||||
|
||||
expect(getRelevantWidgetNames('UnpricedNode')).toEqual([])
|
||||
expect(hasDynamicPricing('UnpricedNode')).toBe(false)
|
||||
expect(getInputGroupPrefixes('UnpricedNode')).toEqual([])
|
||||
expect(getInputNames('UnpricedNode')).toEqual([])
|
||||
})
|
||||
|
||||
it('dedupes dynamic pricing dependencies while preserving order', () => {
|
||||
const store = useNodeDefStore()
|
||||
store.addNodeDef(
|
||||
createStoredNodeDef(
|
||||
'DynamicPricingNode',
|
||||
priceBadge(
|
||||
'{"type":"usd","usd":0.05}',
|
||||
[
|
||||
{ name: 'seed', type: 'INT' },
|
||||
{ name: 'quality', type: 'COMBO' }
|
||||
],
|
||||
['image', 'seed'],
|
||||
['clips', 'image']
|
||||
)
|
||||
)
|
||||
)
|
||||
const {
|
||||
getInputGroupPrefixes,
|
||||
getInputNames,
|
||||
getRelevantWidgetNames,
|
||||
hasDynamicPricing
|
||||
} = useNodePricing()
|
||||
|
||||
expect(getRelevantWidgetNames('DynamicPricingNode')).toEqual([
|
||||
'seed',
|
||||
'quality',
|
||||
'image',
|
||||
'clips'
|
||||
])
|
||||
expect(hasDynamicPricing('DynamicPricingNode')).toBe(true)
|
||||
expect(getInputGroupPrefixes('DynamicPricingNode')).toEqual([
|
||||
'clips',
|
||||
'image'
|
||||
])
|
||||
expect(getInputNames('DynamicPricingNode')).toEqual(['image', 'seed'])
|
||||
})
|
||||
|
||||
it('handles fixed pricing metadata without dependencies', () => {
|
||||
const store = useNodeDefStore()
|
||||
store.addNodeDef(
|
||||
createStoredNodeDef(
|
||||
'FixedPricingNode',
|
||||
priceBadge('{"type":"usd","usd":0.05}')
|
||||
)
|
||||
)
|
||||
const {
|
||||
getInputGroupPrefixes,
|
||||
getInputNames,
|
||||
getRelevantWidgetNames,
|
||||
hasDynamicPricing
|
||||
} = useNodePricing()
|
||||
|
||||
expect(getRelevantWidgetNames('FixedPricingNode')).toEqual([])
|
||||
expect(hasDynamicPricing('FixedPricingNode')).toBe(false)
|
||||
expect(getInputGroupPrefixes('FixedPricingNode')).toEqual([])
|
||||
expect(getInputNames('FixedPricingNode')).toEqual([])
|
||||
})
|
||||
|
||||
it('handles price badges with omitted dependency metadata', () => {
|
||||
const store = useNodeDefStore()
|
||||
store.addNodeDef(
|
||||
createStoredNodeDef('BareDependencyNode', {
|
||||
engine: 'jsonata',
|
||||
expr: '{"type":"usd","usd":0.05}'
|
||||
} as PriceBadge)
|
||||
)
|
||||
const {
|
||||
getInputGroupPrefixes,
|
||||
getInputNames,
|
||||
getRelevantWidgetNames,
|
||||
hasDynamicPricing
|
||||
} = useNodePricing()
|
||||
|
||||
expect(getRelevantWidgetNames('BareDependencyNode')).toEqual([])
|
||||
expect(hasDynamicPricing('BareDependencyNode')).toBe(false)
|
||||
expect(getInputGroupPrefixes('BareDependencyNode')).toEqual([])
|
||||
expect(getInputNames('BareDependencyNode')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reactive revision', () => {
|
||||
it('bumps pricingRevision after an async evaluation resolves (Nodes 1.0 mode)', async () => {
|
||||
const { getNodeDisplayPrice, pricingRevision } = useNodePricing()
|
||||
@@ -655,6 +959,20 @@ describe('useNodePricing', () => {
|
||||
expect(second).toBe(first)
|
||||
expect(pricingRevision.value).toBe(tickAfterFirst)
|
||||
})
|
||||
|
||||
it('does not schedule duplicate work for the same in-flight signature', async () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestInFlightSignatureNode',
|
||||
priceBadge('{"type":"usd","usd":0.05}')
|
||||
)
|
||||
|
||||
expect(getNodeDisplayPrice(node)).toBe('')
|
||||
expect(getNodeDisplayPrice(node)).toBe('')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
expect(getNodeDisplayPrice(node)).toBe(creditsLabel(0.05))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeRevisionRef', () => {
|
||||
@@ -743,6 +1061,16 @@ describe('useNodePricing', () => {
|
||||
expect(price).toBe('')
|
||||
})
|
||||
|
||||
it('should reuse the cached empty label after runtime failures', async () => {
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestCachedRuntimeErrorNode',
|
||||
priceBadge('$lookup(undefined, "key")')
|
||||
)
|
||||
|
||||
expect(await resolveDisplayPrice(node)).toBe('')
|
||||
expect(await resolveDisplayPrice(node)).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for invalid PricingResult type', async () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
@@ -968,8 +1296,21 @@ describe('formatPricingResult', () => {
|
||||
expect(result).toBe('~10.6')
|
||||
})
|
||||
|
||||
it('should parse string usd values with default approximate formatting', () => {
|
||||
const result = formatPricingResult(
|
||||
{ type: 'usd', usd: '0.05' },
|
||||
{ valueOnly: true, defaults: { approximate: true } }
|
||||
)
|
||||
expect(result).toBe('~10.6')
|
||||
})
|
||||
|
||||
it('should return empty for null usd', () => {
|
||||
const result = formatPricingResult({ type: 'usd', usd: null as never })
|
||||
const result = formatPricingResult({ type: 'usd', usd: null })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty for blank string usd', () => {
|
||||
const result = formatPricingResult({ type: 'usd', usd: ' ' })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -999,6 +1340,14 @@ describe('formatPricingResult', () => {
|
||||
)
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should parse string range values with default approximate formatting', () => {
|
||||
const result = formatPricingResult(
|
||||
{ type: 'range_usd', min_usd: '0.05', max_usd: '0.1' },
|
||||
{ valueOnly: true, defaults: { approximate: true } }
|
||||
)
|
||||
expect(result).toBe('~10.6-21.1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('type: list_usd', () => {
|
||||
@@ -1017,6 +1366,22 @@ describe('formatPricingResult', () => {
|
||||
)
|
||||
expect(result).toBe('10.6/21.1')
|
||||
})
|
||||
|
||||
it('should return valueOnly format with approximate prefix', () => {
|
||||
const result = formatPricingResult(
|
||||
{ type: 'list_usd', usd: [0.05, 0.1] },
|
||||
{ valueOnly: true, defaults: { approximate: true } }
|
||||
)
|
||||
expect(result).toBe('~10.6/21.1')
|
||||
})
|
||||
|
||||
it('should return empty when list value is not an array', () => {
|
||||
const result = formatPricingResult({
|
||||
type: 'list_usd',
|
||||
usd: 'not-a-list'
|
||||
})
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('type: text', () => {
|
||||
@@ -1024,6 +1389,11 @@ describe('formatPricingResult', () => {
|
||||
const result = formatPricingResult({ type: 'text', text: 'Free' })
|
||||
expect(result).toBe('Free')
|
||||
})
|
||||
|
||||
it('should return empty when text is missing', () => {
|
||||
const result = formatPricingResult({ type: 'text' })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy format', () => {
|
||||
@@ -1168,6 +1538,20 @@ describe('evaluateNodeDefPricing', () => {
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should evaluate price badges with omitted dependency metadata', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'BareNodeDefPriceBadge',
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
expr: '{"type":"usd","usd":0.05}'
|
||||
} as PriceBadge
|
||||
})
|
||||
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should use default value from input spec', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'DefaultValueNode',
|
||||
@@ -1190,6 +1574,29 @@ describe('evaluateNodeDefPricing', () => {
|
||||
expect(result).toBe('21.1') // 10 * 0.01 = 0.1 USD = 21.1 credits
|
||||
})
|
||||
|
||||
it('should use default value from optional input spec', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'OptionalDefaultValueNode',
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
expr: '{"type":"usd","usd": widgets.count * 0.01}',
|
||||
depends_on: {
|
||||
widgets: [{ name: 'count', type: 'INT' }],
|
||||
inputs: [],
|
||||
input_groups: []
|
||||
}
|
||||
},
|
||||
input: {
|
||||
required: {},
|
||||
optional: {
|
||||
count: ['INT', { default: 4 }]
|
||||
}
|
||||
}
|
||||
})
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
expect(result).toBe('8.4')
|
||||
})
|
||||
|
||||
it('should use first option for COMBO without default', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'ComboNode',
|
||||
@@ -1265,6 +1672,30 @@ describe('evaluateNodeDefPricing', () => {
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should handle combo option arrays with primitive values', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'PrimitiveOptionsNode',
|
||||
price_badge: {
|
||||
engine: 'jsonata',
|
||||
expr: '{"type":"usd","usd": widgets.mode = "fast" ? 0.05 : 0.10}',
|
||||
depends_on: {
|
||||
widgets: [{ name: 'mode', type: 'COMBO' }],
|
||||
inputs: [],
|
||||
input_groups: []
|
||||
}
|
||||
},
|
||||
input: {
|
||||
required: {
|
||||
mode: ['COMBO', { options: ['fast', 'slow'] }]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = await evaluateNodeDefPricing(nodeDef)
|
||||
|
||||
expect(result).toBe('10.6')
|
||||
})
|
||||
|
||||
it('should assume inputs disconnected in preview', async () => {
|
||||
const nodeDef = createMockNodeDef({
|
||||
name: 'InputConnectedNode',
|
||||
|
||||
89
src/composables/node/useNodeProgressText.test.ts
Normal file
89
src/composables/node/useNodeProgressText.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
const mockTextPreviewWidget = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/composables/useProgressTextWidget',
|
||||
() => ({
|
||||
useTextPreviewWidget: () => mockTextPreviewWidget
|
||||
})
|
||||
)
|
||||
|
||||
import { useNodeProgressText } from './useNodeProgressText'
|
||||
|
||||
function node(widgets?: IBaseWidget[]): LGraphNode {
|
||||
return fromAny({
|
||||
widgets,
|
||||
setDirtyCanvas: vi.fn()
|
||||
})
|
||||
}
|
||||
|
||||
describe('useNodeProgressText', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTextPreviewWidget.mockImplementation(
|
||||
(_node: LGraphNode, spec: { name: string; type: string }) => ({
|
||||
name: spec.name,
|
||||
type: spec.type,
|
||||
value: ''
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('updates an existing text preview widget', () => {
|
||||
const existing = { name: '$$node-text-preview', value: '' } as IBaseWidget
|
||||
const graphNode = node([existing])
|
||||
const { showTextPreview } = useNodeProgressText()
|
||||
|
||||
showTextPreview(graphNode, 'running')
|
||||
|
||||
expect(existing.value).toBe('running')
|
||||
expect(mockTextPreviewWidget).not.toHaveBeenCalled()
|
||||
expect(graphNode.setDirtyCanvas).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('creates a text preview widget when one is missing', () => {
|
||||
const graphNode = node([])
|
||||
const { showTextPreview } = useNodeProgressText()
|
||||
|
||||
showTextPreview(graphNode, 'queued')
|
||||
|
||||
expect(mockTextPreviewWidget).toHaveBeenCalledWith(graphNode, {
|
||||
name: '$$node-text-preview',
|
||||
type: 'progressText'
|
||||
})
|
||||
expect(mockTextPreviewWidget.mock.results[0].value.value).toBe('queued')
|
||||
})
|
||||
|
||||
it('removes an existing preview widget and calls its cleanup', () => {
|
||||
const onRemove = vi.fn()
|
||||
const keep = { name: 'other' } as IBaseWidget
|
||||
const preview = fromAny<IBaseWidget, unknown>({
|
||||
name: '$$node-text-preview',
|
||||
onRemove
|
||||
})
|
||||
const graphNode = node([keep, preview])
|
||||
const { removeTextPreview } = useNodeProgressText()
|
||||
|
||||
removeTextPreview(graphNode)
|
||||
|
||||
expect(onRemove).toHaveBeenCalledOnce()
|
||||
expect(graphNode.widgets).toEqual([keep])
|
||||
})
|
||||
|
||||
it('does nothing when there are no widgets or no preview widget', () => {
|
||||
const { removeTextPreview } = useNodeProgressText()
|
||||
const withoutWidgets = node()
|
||||
const withoutPreview = node([{ name: 'other' } as IBaseWidget])
|
||||
|
||||
removeTextPreview(withoutWidgets)
|
||||
removeTextPreview(withoutPreview)
|
||||
|
||||
expect(withoutWidgets.widgets).toBeUndefined()
|
||||
expect(withoutPreview.widgets).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
@@ -6,26 +6,34 @@ import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { subgraphTest } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphFixtures'
|
||||
|
||||
import { usePriceBadge } from '@/composables/node/usePriceBadge'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
|
||||
const getNodeDisplayPrice = vi.fn(
|
||||
(_node: LGraphNode, overrides?: ReadonlyMap<string, unknown>) =>
|
||||
String(overrides?.get('prompt') ?? 'missing override')
|
||||
)
|
||||
|
||||
const mockPalette = vi.hoisted(() => ({
|
||||
completedActivePalette: {
|
||||
light_theme: false,
|
||||
colors: {
|
||||
litegraph_base: {
|
||||
BADGE_FG_COLOR: '#ffffff'
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/node/useNodePricing', () => ({
|
||||
useNodePricing: () => ({ getNodeDisplayPrice })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => ({
|
||||
completedActivePalette: {
|
||||
light_theme: false,
|
||||
colors: { litegraph_base: {} }
|
||||
}
|
||||
})
|
||||
useColorPaletteStore: () => mockPalette
|
||||
}))
|
||||
|
||||
const { updateSubgraphCredits, getCreditsBadge } = usePriceBadge()
|
||||
const { updateSubgraphCredits, getCreditsBadge, isCreditsBadge } =
|
||||
usePriceBadge()
|
||||
|
||||
const mockNode = new LGraphNode('mock node')
|
||||
mockNode.badges = [getCreditsBadge('$0.05/Run')]
|
||||
@@ -36,6 +44,34 @@ function getBadgeText(node: LGraphNode): string {
|
||||
}
|
||||
|
||||
describe('subgraph pricing', () => {
|
||||
beforeEach(() => {
|
||||
mockPalette.completedActivePalette.light_theme = false
|
||||
})
|
||||
|
||||
it('identifies credit badges and ignores unrelated badges', () => {
|
||||
expect(isCreditsBadge(getCreditsBadge('$0.05/Run'))).toBe(true)
|
||||
expect(isCreditsBadge(() => getCreditsBadge('$0.05/Run'))).toBe(true)
|
||||
expect(isCreditsBadge({ text: 'other' })).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the adjusted credits background in light themes', () => {
|
||||
mockPalette.completedActivePalette.light_theme = true
|
||||
|
||||
expect(getCreditsBadge('$0.05/Run').bgColor).toBe(
|
||||
adjustColor('#8D6932', { lightness: 0.5 })
|
||||
)
|
||||
})
|
||||
|
||||
it('does nothing for non-subgraph nodes', () => {
|
||||
const node = new LGraphNode('plain node')
|
||||
const badge = getCreditsBadge('$0.05/Run')
|
||||
node.badges = [badge]
|
||||
|
||||
updateSubgraphCredits(node)
|
||||
|
||||
expect(node.badges).toEqual([badge])
|
||||
})
|
||||
|
||||
subgraphTest(
|
||||
'should not display badge for subgraphs without API nodes',
|
||||
({ subgraphWithNode }) => {
|
||||
|
||||
@@ -151,7 +151,9 @@ describe('useComputedWithWidgetWatch', () => {
|
||||
})
|
||||
|
||||
it('should handle nodes without widgets gracefully', () => {
|
||||
const mockNode = createMockNode([])
|
||||
const mockNode = Object.assign(createMockLGraphNode(), {
|
||||
widgets: undefined
|
||||
}) as LGraphNode
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode)
|
||||
|
||||
@@ -160,6 +162,85 @@ describe('useComputedWithWidgetWatch', () => {
|
||||
expect(computedValue.value).toBe('no widgets')
|
||||
})
|
||||
|
||||
it('observes named input connection changes when requested', async () => {
|
||||
const mockNode = Object.assign(
|
||||
createMockNode([{ name: 'width', value: 1 }]),
|
||||
{
|
||||
inputs: [{ name: 'image' }],
|
||||
onConnectionsChange: undefined as
|
||||
| ((type: unknown, index: number, isConnected: boolean) => void)
|
||||
| undefined
|
||||
}
|
||||
)
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
|
||||
widgetNames: ['width', 'image'],
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
|
||||
let runs = 0
|
||||
const computedValue = computedWithWidgetWatch(() => ++runs)
|
||||
expect(computedValue.value).toBe(1)
|
||||
|
||||
mockNode.onConnectionsChange?.('input', 0, true)
|
||||
await nextTick()
|
||||
|
||||
expect(computedValue.value).toBe(2)
|
||||
expect(mockNode.graph?.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('observes connection changes for watched inputs at non-zero slots', async () => {
|
||||
const mockNode = Object.assign(
|
||||
createMockNode([{ name: 'width', value: 1 }]),
|
||||
{
|
||||
inputs: [{ name: 'other' }, { name: 'image' }],
|
||||
onConnectionsChange: undefined as
|
||||
| ((type: unknown, index: number, isConnected: boolean) => void)
|
||||
| undefined
|
||||
}
|
||||
)
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
|
||||
widgetNames: ['width', 'image'],
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
|
||||
let runs = 0
|
||||
const computedValue = computedWithWidgetWatch(() => ++runs)
|
||||
expect(computedValue.value).toBe(1)
|
||||
|
||||
mockNode.onConnectionsChange?.('input', 1, true)
|
||||
await nextTick()
|
||||
|
||||
expect(computedValue.value).toBe(2)
|
||||
expect(mockNode.graph?.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('ignores unobserved input connection changes', async () => {
|
||||
const mockNode = Object.assign(
|
||||
createMockNode([{ name: 'width', value: 1 }]),
|
||||
{
|
||||
inputs: [{ name: 'image' }],
|
||||
onConnectionsChange: undefined as
|
||||
| ((type: unknown, index: number, isConnected: boolean) => void)
|
||||
| undefined
|
||||
}
|
||||
)
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
|
||||
widgetNames: ['width', 'image']
|
||||
})
|
||||
|
||||
let runs = 0
|
||||
const computedValue = computedWithWidgetWatch(() => ++runs)
|
||||
expect(computedValue.value).toBe(1)
|
||||
|
||||
mockNode.onConnectionsChange?.('input', 1, true)
|
||||
await nextTick()
|
||||
|
||||
expect(computedValue.value).toBe(1)
|
||||
})
|
||||
|
||||
it('should chain with existing widget callbacks', async () => {
|
||||
const existingCallback = vi.fn()
|
||||
const mockNode = createMockNode([
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render } from '@testing-library/vue'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick, ref } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { StrokeProcessor } from '@/composables/maskeditor/StrokeProcessor'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
@@ -27,10 +30,12 @@ vi.mock('@vueuse/core', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/maskeditor/StrokeProcessor', () => ({
|
||||
StrokeProcessor: vi.fn(() => ({
|
||||
addPoint: vi.fn(() => []),
|
||||
endStroke: vi.fn(() => [])
|
||||
}))
|
||||
StrokeProcessor: vi.fn(function StrokeProcessor() {
|
||||
return {
|
||||
addPoint: vi.fn(() => []),
|
||||
endStroke: vi.fn(() => [])
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
@@ -42,14 +47,15 @@ vi.mock('@/platform/updates/common/toastStore', () => {
|
||||
return { useToastStore: () => store }
|
||||
})
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => {
|
||||
const store = {
|
||||
getNodeImageUrls: vi.fn(() => undefined),
|
||||
nodeOutputs: {},
|
||||
nodePreviewImages: {}
|
||||
}
|
||||
return { useNodeOutputStore: () => store }
|
||||
})
|
||||
const mockNodeOutputStore = vi.hoisted(() => ({
|
||||
getNodeImageUrls: vi.fn(() => undefined as string[] | undefined),
|
||||
nodeOutputs: {},
|
||||
nodePreviewImages: {}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => mockNodeOutputStore
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
@@ -61,7 +67,7 @@ vi.mock('@/scripts/api', () => ({
|
||||
const mockWidgets: IBaseWidget[] = []
|
||||
const mockProperties: Record<string, unknown> = {}
|
||||
const mockIsInputConnected = vi.fn(() => false)
|
||||
const mockGetInputNode = vi.fn(() => null)
|
||||
const mockGetInputNode = vi.fn((): LGraphNode | null => null)
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
@@ -93,9 +99,6 @@ function makeWidget(name: string, value: unknown = null): IBaseWidget {
|
||||
} as unknown as IBaseWidget
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts a thin wrapper component so Vue lifecycle hooks fire.
|
||||
*/
|
||||
function mountPainter(
|
||||
nodeId: NodeId = toNodeId('test-node'),
|
||||
initialModelValue = ''
|
||||
@@ -119,11 +122,94 @@ function mountPainter(
|
||||
}
|
||||
})
|
||||
|
||||
render(Wrapper)
|
||||
return { painter, canvasEl, cursorEl, modelValue }
|
||||
const rendered = render(Wrapper)
|
||||
return { painter, canvasEl, cursorEl, modelValue, unmount: rendered.unmount }
|
||||
}
|
||||
|
||||
function createCanvasContext() {
|
||||
const gradient = { addColorStop: vi.fn() }
|
||||
return {
|
||||
beginPath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
createRadialGradient: vi.fn(() => gradient),
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
globalCompositeOperation: '',
|
||||
globalAlpha: 1,
|
||||
lineWidth: 1,
|
||||
lineCap: 'butt',
|
||||
lineJoin: 'miter'
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
function createCanvasElement(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width = 100,
|
||||
height = 100
|
||||
) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
vi.spyOn(canvas, 'getContext').mockReturnValue(fromAny(ctx))
|
||||
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width,
|
||||
height,
|
||||
right: width,
|
||||
bottom: height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: vi.fn()
|
||||
})
|
||||
return canvas
|
||||
}
|
||||
|
||||
function createPointerEvent(
|
||||
type: string,
|
||||
values: {
|
||||
clientX?: number
|
||||
clientY?: number
|
||||
offsetX?: number
|
||||
offsetY?: number
|
||||
button?: number
|
||||
pointerId?: number
|
||||
target?: Pick<HTMLElement, 'setPointerCapture' | 'releasePointerCapture'>
|
||||
} = {}
|
||||
) {
|
||||
const event = new PointerEvent(type, {
|
||||
button: values.button ?? 0,
|
||||
clientX: values.clientX ?? 0,
|
||||
clientY: values.clientY ?? 0,
|
||||
pointerId: values.pointerId ?? 1
|
||||
})
|
||||
Object.defineProperty(event, 'offsetX', { value: values.offsetX ?? 0 })
|
||||
Object.defineProperty(event, 'offsetY', { value: values.offsetY ?? 0 })
|
||||
Object.defineProperty(event, 'target', {
|
||||
value:
|
||||
values.target ??
|
||||
({
|
||||
setPointerCapture: vi.fn(),
|
||||
releasePointerCapture: vi.fn()
|
||||
} as Pick<HTMLElement, 'setPointerCapture' | 'releasePointerCapture'>)
|
||||
})
|
||||
return event
|
||||
}
|
||||
|
||||
describe('usePainter', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.resetAllMocks()
|
||||
@@ -133,6 +219,7 @@ describe('usePainter', () => {
|
||||
}
|
||||
mockIsInputConnected.mockReturnValue(false)
|
||||
mockGetInputNode.mockReturnValue(null)
|
||||
mockNodeOutputStore.getNodeImageUrls.mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
describe('syncCanvasSizeFromWidgets', () => {
|
||||
@@ -151,6 +238,25 @@ describe('usePainter', () => {
|
||||
expect(painter.canvasWidth.value).toBe(512)
|
||||
expect(painter.canvasHeight.value).toBe(512)
|
||||
})
|
||||
|
||||
it('keeps defaults when the node id is empty', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
const { painter } = mountPainter(toNodeId(''))
|
||||
|
||||
expect(app.canvas.graph!.getNodeById).not.toHaveBeenCalled()
|
||||
expect(painter.canvasWidth.value).toBe(512)
|
||||
expect(painter.canvasHeight.value).toBe(512)
|
||||
expect(painter.inputImageUrl.value).toBeNull()
|
||||
expect(painter.isImageInputConnected.value).toBe(false)
|
||||
expect(maskWidget.serializeValue).toBeUndefined()
|
||||
|
||||
painter.brushSize.value = 36
|
||||
await nextTick()
|
||||
|
||||
expect(mockProperties.painterBrushSize).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreSettingsFromProperties', () => {
|
||||
@@ -226,6 +332,18 @@ describe('usePainter', () => {
|
||||
expect(widthWidget.callback).toHaveBeenCalledWith(800)
|
||||
expect(heightWidget.callback).toHaveBeenCalledWith(600)
|
||||
})
|
||||
|
||||
it('skips widget callbacks when dimensions are unchanged', async () => {
|
||||
const widthWidget = makeWidget('width', 512)
|
||||
const heightWidget = makeWidget('height', 512)
|
||||
mockWidgets.push(widthWidget, heightWidget)
|
||||
|
||||
mountPainter()
|
||||
await nextTick()
|
||||
|
||||
expect(widthWidget.callback).not.toHaveBeenCalled()
|
||||
expect(heightWidget.callback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncBackgroundColorToWidget', () => {
|
||||
@@ -241,6 +359,16 @@ describe('usePainter', () => {
|
||||
expect(bgWidget.value).toBe('#ff00ff')
|
||||
expect(bgWidget.callback).toHaveBeenCalledWith('#ff00ff')
|
||||
})
|
||||
|
||||
it('skips widget callbacks when the background color is unchanged', async () => {
|
||||
const bgWidget = makeWidget('bg_color', '#000000')
|
||||
mockWidgets.push(bgWidget)
|
||||
|
||||
mountPainter()
|
||||
await nextTick()
|
||||
|
||||
expect(bgWidget.callback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateInputImageUrl', () => {
|
||||
@@ -258,6 +386,34 @@ describe('usePainter', () => {
|
||||
|
||||
expect(painter.isImageInputConnected.value).toBe(true)
|
||||
})
|
||||
|
||||
it('sets inputImageUrl from the connected input node output', () => {
|
||||
const inputNode = {} as LGraphNode
|
||||
mockIsInputConnected.mockReturnValue(true)
|
||||
mockGetInputNode.mockReturnValue(inputNode)
|
||||
mockNodeOutputStore.getNodeImageUrls.mockReturnValue([
|
||||
'http://localhost:8188/view?filename=input.png'
|
||||
])
|
||||
|
||||
const { painter } = mountPainter()
|
||||
|
||||
expect(mockNodeOutputStore.getNodeImageUrls).toHaveBeenCalledWith(
|
||||
inputNode
|
||||
)
|
||||
expect(painter.inputImageUrl.value).toBe(
|
||||
'http://localhost:8188/view?filename=input.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps inputImageUrl null when a connected input has no images', () => {
|
||||
mockIsInputConnected.mockReturnValue(true)
|
||||
mockGetInputNode.mockReturnValue({} as LGraphNode)
|
||||
mockNodeOutputStore.getNodeImageUrls.mockReturnValue([])
|
||||
|
||||
const { painter } = mountPainter()
|
||||
|
||||
expect(painter.inputImageUrl.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleInputImageLoad', () => {
|
||||
@@ -282,6 +438,20 @@ describe('usePainter', () => {
|
||||
expect(widthWidget.value).toBe(1920)
|
||||
expect(heightWidget.value).toBe(1080)
|
||||
})
|
||||
|
||||
it('updates canvas size when dimension widgets are absent', () => {
|
||||
const { painter } = mountPainter()
|
||||
|
||||
painter.handleInputImageLoad({
|
||||
target: {
|
||||
naturalWidth: 320,
|
||||
naturalHeight: 240
|
||||
}
|
||||
} as unknown as Event)
|
||||
|
||||
expect(painter.canvasWidth.value).toBe(320)
|
||||
expect(painter.canvasHeight.value).toBe(240)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cursor visibility', () => {
|
||||
@@ -299,6 +469,17 @@ describe('usePainter', () => {
|
||||
painter.handlePointerLeave()
|
||||
expect(painter.cursorVisible.value).toBe(false)
|
||||
})
|
||||
|
||||
it('positions the custom cursor on pointer movement', () => {
|
||||
const { painter, cursorEl } = mountPainter()
|
||||
cursorEl.value = document.createElement('div')
|
||||
|
||||
painter.handlePointerMove(
|
||||
createPointerEvent('pointermove', { offsetX: 25, offsetY: 30 })
|
||||
)
|
||||
|
||||
expect(cursorEl.value.style.transform).toBe('translate(15px, 20px)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('displayBrushSize', () => {
|
||||
@@ -422,6 +603,123 @@ describe('usePainter', () => {
|
||||
).rejects.toThrow(/missing 'name'/)
|
||||
})
|
||||
|
||||
it('throws when the upload request fails', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
vi.mocked(api.fetchApi).mockRejectedValueOnce(new Error('offline'))
|
||||
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const { canvasEl } = mountPainter(toNodeId('test-node'), '')
|
||||
canvasEl.value = fakeCanvas
|
||||
await nextTick()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/painter\.uploadError/)
|
||||
})
|
||||
|
||||
it('reports non-error upload rejections', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
vi.mocked(api.fetchApi).mockRejectedValueOnce('offline')
|
||||
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const { canvasEl } = mountPainter(toNodeId('test-node'), '')
|
||||
canvasEl.value = fakeCanvas
|
||||
await nextTick()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/offline/)
|
||||
})
|
||||
|
||||
it('throws when the upload response is not successful', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
text: async () => 'upload failed'
|
||||
} as Response)
|
||||
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const { canvasEl } = mountPainter(toNodeId('test-node'), '')
|
||||
canvasEl.value = fakeCanvas
|
||||
await nextTick()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/upload failed/)
|
||||
})
|
||||
|
||||
it('uses statusText when an unsuccessful upload response has no body', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
status: 502,
|
||||
statusText: 'Bad Gateway',
|
||||
text: async () => ''
|
||||
} as Response)
|
||||
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const { canvasEl } = mountPainter(toNodeId('test-node'), '')
|
||||
canvasEl.value = fakeCanvas
|
||||
await nextTick()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/Bad Gateway/)
|
||||
})
|
||||
|
||||
it('uses unknown error when an unsuccessful upload response has no detail', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
status: 500,
|
||||
statusText: '',
|
||||
text: async () => ''
|
||||
} as Response)
|
||||
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const { canvasEl } = mountPainter(toNodeId('test-node'), '')
|
||||
canvasEl.value = fakeCanvas
|
||||
await nextTick()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/unknown error/)
|
||||
})
|
||||
|
||||
it('throws when the upload response body is not valid JSON', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
@@ -448,6 +746,80 @@ describe('usePainter', () => {
|
||||
).rejects.toThrow(/painter\.uploadError/)
|
||||
})
|
||||
|
||||
it('reports non-error JSON parse failures', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw 'bad json'
|
||||
}
|
||||
} as unknown as Response)
|
||||
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const { canvasEl } = mountPainter(toNodeId('test-node'), '')
|
||||
canvasEl.value = fakeCanvas
|
||||
await nextTick()
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).rejects.toThrow(/bad json/)
|
||||
})
|
||||
|
||||
it('returns modelValue when dirty canvas serialization produces no blob', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
getContext: vi.fn(() => ({
|
||||
clearRect: vi.fn()
|
||||
})),
|
||||
toBlob: (cb: BlobCallback) => cb(null)
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const { painter, canvasEl } = mountPainter(toNodeId('test-node'), '')
|
||||
canvasEl.value = fakeCanvas
|
||||
|
||||
painter.handleClear()
|
||||
await nextTick()
|
||||
|
||||
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
|
||||
expect(result).toBe('')
|
||||
expect(api.fetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns existing modelValue when canvas serialization produces no blob', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
|
||||
const fakeCanvas = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
toBlob: (cb: BlobCallback) => cb(null)
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
const { canvasEl } = mountPainter(
|
||||
toNodeId('test-node'),
|
||||
'painter/cached.png [temp]'
|
||||
)
|
||||
canvasEl.value = fakeCanvas
|
||||
await nextTick()
|
||||
|
||||
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
|
||||
expect(result).toBe('painter/cached.png [temp]')
|
||||
expect(api.fetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns existing modelValue when canvas element is unmounted at serialize time', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
mockWidgets.push(maskWidget)
|
||||
@@ -498,6 +870,113 @@ describe('usePainter', () => {
|
||||
expect.stringContaining('type=temp')
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults restored mask type to input when no type suffix exists', () => {
|
||||
vi.mocked(api.apiURL).mockClear()
|
||||
|
||||
mountPainter(toNodeId('test-node'), 'plain.png')
|
||||
|
||||
expect(api.apiURL).toHaveBeenCalledWith(
|
||||
expect.stringContaining('filename=plain.png')
|
||||
)
|
||||
expect(api.apiURL).toHaveBeenCalledWith(
|
||||
expect.stringContaining('type=input')
|
||||
)
|
||||
expect(api.apiURL).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('subfolder=')
|
||||
)
|
||||
})
|
||||
|
||||
it('does not restore a canvas when the mask value is blank', () => {
|
||||
vi.mocked(api.apiURL).mockClear()
|
||||
|
||||
mountPainter(toNodeId('test-node'), ' ')
|
||||
|
||||
expect(api.apiURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws a restored mask after the image loads', () => {
|
||||
const images: Array<{ onload: (() => void) | null }> = []
|
||||
class FakeImage {
|
||||
crossOrigin = ''
|
||||
naturalWidth = 64
|
||||
naturalHeight = 32
|
||||
onload: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
src = ''
|
||||
|
||||
constructor() {
|
||||
images.push(this)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Image', FakeImage)
|
||||
const ctx = createCanvasContext()
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
fromAny(ctx)
|
||||
)
|
||||
|
||||
const { painter, canvasEl } = mountPainter(
|
||||
toNodeId('test-node'),
|
||||
'painter/mask.png [temp]'
|
||||
)
|
||||
canvasEl.value = createCanvasElement(ctx)
|
||||
images[0].onload?.()
|
||||
|
||||
expect(painter.canvasWidth.value).toBe(64)
|
||||
expect(painter.canvasHeight.value).toBe(32)
|
||||
expect(ctx.drawImage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores restored image loads after the canvas unmounts', () => {
|
||||
const images: Array<{ onload: (() => void) | null }> = []
|
||||
class FakeImage {
|
||||
crossOrigin = ''
|
||||
naturalWidth = 64
|
||||
naturalHeight = 32
|
||||
onload: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
src = ''
|
||||
|
||||
constructor() {
|
||||
images.push(this)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Image', FakeImage)
|
||||
|
||||
const { painter } = mountPainter(
|
||||
toNodeId('test-node'),
|
||||
'painter/mask.png [temp]'
|
||||
)
|
||||
images[0].onload?.()
|
||||
|
||||
expect(painter.canvasWidth.value).toBe(512)
|
||||
expect(painter.canvasHeight.value).toBe(512)
|
||||
})
|
||||
|
||||
it('clears stale modelValue when restored image loading fails', () => {
|
||||
const images: Array<{ onerror: (() => void) | null }> = []
|
||||
class FakeImage {
|
||||
crossOrigin = ''
|
||||
naturalWidth = 64
|
||||
naturalHeight = 32
|
||||
onload: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
src = ''
|
||||
|
||||
constructor() {
|
||||
images.push(this)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Image', FakeImage)
|
||||
|
||||
const { modelValue } = mountPainter(
|
||||
toNodeId('test-node'),
|
||||
'painter/mask.png [temp]'
|
||||
)
|
||||
images[0].onerror?.()
|
||||
|
||||
expect(modelValue.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleClear', () => {
|
||||
@@ -506,6 +985,36 @@ describe('usePainter', () => {
|
||||
|
||||
expect(() => painter.handleClear()).not.toThrow()
|
||||
})
|
||||
|
||||
it('clears the canvas and marks the current mask dirty', async () => {
|
||||
const maskWidget = makeWidget('mask', '')
|
||||
const ctx = createCanvasContext()
|
||||
mockWidgets.push(maskWidget)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
fromAny(ctx)
|
||||
)
|
||||
|
||||
const { painter, canvasEl, modelValue } = mountPainter(
|
||||
toNodeId('test-node'),
|
||||
'painter/cached.png [temp]'
|
||||
)
|
||||
canvasEl.value = createCanvasElement(ctx, 50, 40)
|
||||
|
||||
painter.handleClear()
|
||||
await nextTick()
|
||||
|
||||
expect(ctx.clearRect).toHaveBeenCalledWith(0, 0, 50, 40)
|
||||
expect(modelValue.value).toBe('')
|
||||
|
||||
vi.mocked(api.fetchApi).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: async () => ({ name: 'cleared.png' })
|
||||
} as Response)
|
||||
|
||||
await expect(
|
||||
maskWidget.serializeValue!({} as LGraphNode, 0)
|
||||
).resolves.toBe('cleared.png [input]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePointerDown', () => {
|
||||
@@ -547,6 +1056,176 @@ describe('usePainter', () => {
|
||||
|
||||
expect(() => painter.handlePointerDown(event)).not.toThrow()
|
||||
})
|
||||
|
||||
it('draws a hard brush stroke across pointer events', () => {
|
||||
const ctx = createCanvasContext()
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
fromAny(ctx)
|
||||
)
|
||||
const { painter, canvasEl } = mountPainter()
|
||||
canvasEl.value = createCanvasElement(ctx)
|
||||
|
||||
painter.handlePointerDown(
|
||||
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
painter.handlePointerMove(
|
||||
createPointerEvent('pointermove', {
|
||||
clientX: 60,
|
||||
clientY: 10,
|
||||
offsetX: 60,
|
||||
offsetY: 10
|
||||
})
|
||||
)
|
||||
painter.handlePointerUp(createPointerEvent('pointerup'))
|
||||
|
||||
expect(ctx.arc).toHaveBeenCalled()
|
||||
expect(ctx.moveTo).toHaveBeenCalled()
|
||||
expect(ctx.lineTo).toHaveBeenCalled()
|
||||
expect(ctx.stroke).toHaveBeenCalled()
|
||||
expect(ctx.drawImage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws a soft brush stroke with radial dabs', () => {
|
||||
const ctx = createCanvasContext()
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
fromAny(ctx)
|
||||
)
|
||||
const { painter, canvasEl } = mountPainter()
|
||||
canvasEl.value = createCanvasElement(ctx)
|
||||
painter.brushHardness.value = 0.5
|
||||
|
||||
painter.handlePointerDown(
|
||||
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
painter.handlePointerMove(
|
||||
createPointerEvent('pointermove', { clientX: 70, clientY: 10 })
|
||||
)
|
||||
painter.handlePointerUp(createPointerEvent('pointerup'))
|
||||
|
||||
expect(ctx.createRadialGradient).toHaveBeenCalled()
|
||||
expect(ctx.arc).toHaveBeenCalled()
|
||||
expect(ctx.drawImage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses destination-out composition for eraser strokes', () => {
|
||||
const ctx = createCanvasContext()
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
fromAny(ctx)
|
||||
)
|
||||
const { painter, canvasEl } = mountPainter()
|
||||
canvasEl.value = createCanvasElement(ctx)
|
||||
painter.tool.value = 'eraser'
|
||||
|
||||
painter.handlePointerDown(
|
||||
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
|
||||
expect(ctx.globalCompositeOperation).toBe('destination-out')
|
||||
})
|
||||
|
||||
it('does not start drawing when a canvas context is unavailable', () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
vi.spyOn(canvas, 'getContext').mockReturnValue(null)
|
||||
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
right: 100,
|
||||
bottom: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: vi.fn()
|
||||
})
|
||||
const { painter, canvasEl } = mountPainter()
|
||||
canvasEl.value = canvas
|
||||
|
||||
painter.handlePointerDown(
|
||||
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
painter.handlePointerMove(
|
||||
createPointerEvent('pointermove', { clientX: 20, clientY: 20 })
|
||||
)
|
||||
painter.handlePointerUp(createPointerEvent('pointerup'))
|
||||
|
||||
expect(canvas.getContext).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses one animation frame for pending pointer movement', () => {
|
||||
const ctx = createCanvasContext()
|
||||
let frameCallback: FrameRequestCallback | undefined
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
|
||||
(callback) => {
|
||||
frameCallback = callback
|
||||
return 7
|
||||
}
|
||||
)
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
fromAny(ctx)
|
||||
)
|
||||
const { painter, canvasEl } = mountPainter()
|
||||
canvasEl.value = createCanvasElement(ctx)
|
||||
|
||||
painter.handlePointerDown(
|
||||
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
painter.handlePointerMove(
|
||||
createPointerEvent('pointermove', { clientX: 20, clientY: 20 })
|
||||
)
|
||||
painter.handlePointerMove(
|
||||
createPointerEvent('pointermove', { clientX: 30, clientY: 30 })
|
||||
)
|
||||
|
||||
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1)
|
||||
|
||||
frameCallback?.(0)
|
||||
|
||||
expect(ctx.lineTo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('flushes a pending pointer movement when leaving the canvas', () => {
|
||||
const ctx = createCanvasContext()
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(7)
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
fromAny(ctx)
|
||||
)
|
||||
const { painter, canvasEl } = mountPainter()
|
||||
canvasEl.value = createCanvasElement(ctx)
|
||||
|
||||
painter.handlePointerDown(
|
||||
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
painter.handlePointerMove(
|
||||
createPointerEvent('pointermove', { clientX: 20, clientY: 20 })
|
||||
)
|
||||
painter.handlePointerLeave()
|
||||
|
||||
expect(window.cancelAnimationFrame).toHaveBeenCalledWith(7)
|
||||
expect(ctx.lineTo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels a pending pointer movement when unmounted', () => {
|
||||
const ctx = createCanvasContext()
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(7)
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
fromAny(ctx)
|
||||
)
|
||||
const { painter, canvasEl, unmount } = mountPainter()
|
||||
canvasEl.value = createCanvasElement(ctx)
|
||||
|
||||
painter.handlePointerDown(
|
||||
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
painter.handlePointerMove(
|
||||
createPointerEvent('pointermove', { clientX: 20, clientY: 20 })
|
||||
)
|
||||
unmount()
|
||||
|
||||
expect(window.cancelAnimationFrame).toHaveBeenCalledWith(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePointerUp', () => {
|
||||
@@ -581,5 +1260,32 @@ describe('usePainter', () => {
|
||||
|
||||
expect(() => painter.handlePointerUp(event)).not.toThrow()
|
||||
})
|
||||
|
||||
it('draws final stroke processor points on release', () => {
|
||||
const ctx = createCanvasContext()
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
fromAny(ctx)
|
||||
)
|
||||
vi.mocked(StrokeProcessor).mockImplementationOnce(
|
||||
class MockStrokeProcessor {
|
||||
addPoint = vi.fn(() => [])
|
||||
endStroke = vi.fn(() => [
|
||||
{ x: 40, y: 10 },
|
||||
{ x: 80, y: 10 }
|
||||
])
|
||||
} as unknown as typeof StrokeProcessor
|
||||
)
|
||||
const { painter, canvasEl } = mountPainter()
|
||||
canvasEl.value = createCanvasElement(ctx)
|
||||
|
||||
painter.handlePointerDown(
|
||||
createPointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
painter.handlePointerUp(createPointerEvent('pointerup'))
|
||||
|
||||
expect(ctx.moveTo).toHaveBeenCalledWith(10, 10)
|
||||
expect(ctx.lineTo).toHaveBeenCalledWith(40, 10)
|
||||
expect(ctx.lineTo).toHaveBeenCalledWith(80, 10)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,8 +12,12 @@ afterEach(() => {
|
||||
|
||||
function setup(initial: string[]) {
|
||||
const modelValue = ref(initial)
|
||||
const container = shallowRef(document.createElement('div'))
|
||||
const picker = shallowRef(document.createElement('input'))
|
||||
const container = shallowRef<HTMLDivElement | null>(
|
||||
document.createElement('div')
|
||||
)
|
||||
const picker = shallowRef<HTMLInputElement | null>(
|
||||
document.createElement('input')
|
||||
)
|
||||
const scope = effectScope()
|
||||
scopes.push(scope)
|
||||
const api = scope.run(() =>
|
||||
@@ -51,6 +55,19 @@ describe('usePaletteSwatchRow', () => {
|
||||
expect(picker.value!.value).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('tracks the picker index even when the input is unavailable', () => {
|
||||
const { modelValue, picker, openPicker, onPickerInput } = setup([
|
||||
'#000000',
|
||||
'#111111'
|
||||
])
|
||||
picker.value = null
|
||||
|
||||
openPicker(1, mouseEvent())
|
||||
onPickerInput({ target: { value: '#222222' } } as unknown as Event)
|
||||
|
||||
expect(modelValue.value).toEqual(['#000000', '#222222'])
|
||||
})
|
||||
|
||||
it('writes the picked color back to the open slot', () => {
|
||||
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
|
||||
openPicker(1, mouseEvent())
|
||||
@@ -100,6 +117,82 @@ describe('usePaletteSwatchRow', () => {
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('ignores pointer movement before a drag starts', () => {
|
||||
const { modelValue } = setup(['#a', '#b'])
|
||||
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
|
||||
)
|
||||
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('waits until movement passes the drag threshold', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
const swatch = document.createElement('div')
|
||||
swatch.setAttribute('data-index', '1')
|
||||
container.value!.appendChild(swatch)
|
||||
|
||||
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 12, clientY: 11, buttons: 1 })
|
||||
)
|
||||
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('ignores active drags when the row container is gone', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
container.value = null
|
||||
|
||||
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
|
||||
)
|
||||
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('ignores invalid target rows during drag', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
const current = document.createElement('div')
|
||||
current.setAttribute('data-index', '0')
|
||||
const invalid = document.createElement('div')
|
||||
invalid.setAttribute('data-index', '-1')
|
||||
container.value!.append(current, invalid)
|
||||
invalid.getBoundingClientRect = () =>
|
||||
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
|
||||
|
||||
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
|
||||
)
|
||||
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('cancels drags on pointerup and pointercancel', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
const swatch = document.createElement('div')
|
||||
swatch.setAttribute('data-index', '1')
|
||||
container.value!.appendChild(swatch)
|
||||
swatch.getBoundingClientRect = () =>
|
||||
({ left: 100, right: 140, top: 0, bottom: 20, width: 40 }) as DOMRect
|
||||
|
||||
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(new PointerEvent('pointerup'))
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
|
||||
)
|
||||
onPointerDown(0, { button: 0, clientX: 10, clientY: 10 } as PointerEvent)
|
||||
document.dispatchEvent(new PointerEvent('pointercancel'))
|
||||
document.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: 130, clientY: 10, buttons: 1 })
|
||||
)
|
||||
|
||||
expect(modelValue.value).toEqual(['#a', '#b'])
|
||||
})
|
||||
|
||||
it('ignores non-left-button pointer downs', () => {
|
||||
const { modelValue, container, onPointerDown } = setup(['#a', '#b'])
|
||||
const swatch = document.createElement('div')
|
||||
|
||||
132
src/composables/queue/useJobActions.test.ts
Normal file
132
src/composables/queue/useJobActions.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { createApp, h, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useJobActions } from '@/composables/queue/useJobActions'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
const { cancelJob, removeFailedJob, wrapWithErrorHandlingAsync } = vi.hoisted(
|
||||
() => ({
|
||||
cancelJob: vi.fn(),
|
||||
removeFailedJob: vi.fn(),
|
||||
wrapWithErrorHandlingAsync: vi.fn(
|
||||
<T extends (...args: never[]) => Promise<unknown>>(fn: T) => fn
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({ wrapWithErrorHandlingAsync })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/queue/useJobMenu', () => ({
|
||||
useJobMenu: () => ({ cancelJob, removeFailedJob })
|
||||
}))
|
||||
|
||||
function mountJobActions(job: Ref<JobListItem | null | undefined>) {
|
||||
let result: ReturnType<typeof useJobActions> | undefined
|
||||
const app = createApp({
|
||||
setup() {
|
||||
result = useJobActions(job)
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
app.use(
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
)
|
||||
app.mount(document.createElement('div'))
|
||||
if (!result) throw new Error('useJobActions did not initialize')
|
||||
return {
|
||||
result,
|
||||
unmount: () => app.unmount()
|
||||
}
|
||||
}
|
||||
|
||||
function job(overrides: Partial<JobListItem> = {}): JobListItem {
|
||||
return {
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: '',
|
||||
state: 'pending',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
cancelJob.mockReset().mockResolvedValue(undefined)
|
||||
removeFailedJob.mockReset().mockResolvedValue(undefined)
|
||||
wrapWithErrorHandlingAsync.mockClear()
|
||||
})
|
||||
|
||||
describe('useJobActions', () => {
|
||||
it('exposes localized action metadata', () => {
|
||||
const { result, unmount } = mountJobActions(ref(job()))
|
||||
|
||||
expect(result.cancelAction).toMatchObject({
|
||||
icon: 'icon-[lucide--x]',
|
||||
label: 'sideToolbar.queueProgressOverlay.cancelJobTooltip',
|
||||
variant: 'destructive'
|
||||
})
|
||||
expect(result.deleteAction).toMatchObject({
|
||||
icon: 'icon-[lucide--circle-minus]',
|
||||
label: 'queue.jobMenu.removeJob',
|
||||
variant: 'destructive'
|
||||
})
|
||||
expect(wrapWithErrorHandlingAsync).toHaveBeenCalledTimes(2)
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('cancels active jobs unless clearing is hidden', async () => {
|
||||
const currentJob = ref(job({ state: 'running' }))
|
||||
const { result, unmount } = mountJobActions(currentJob)
|
||||
|
||||
expect(result.canCancelJob.value).toBe(true)
|
||||
await result.runCancelJob()
|
||||
expect(cancelJob).toHaveBeenCalledWith(currentJob.value)
|
||||
|
||||
currentJob.value = job({ state: 'pending', showClear: false })
|
||||
expect(result.canCancelJob.value).toBe(false)
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('ignores cancel and delete requests without a current job or task', async () => {
|
||||
const currentJob = ref<JobListItem | null>(null)
|
||||
const { result, unmount } = mountJobActions(currentJob)
|
||||
|
||||
expect(result.canCancelJob.value).toBe(false)
|
||||
expect(result.canDeleteJob.value).toBe(false)
|
||||
await result.runCancelJob()
|
||||
await result.runDeleteJob()
|
||||
|
||||
currentJob.value = job({ state: 'failed' })
|
||||
await result.runDeleteJob()
|
||||
|
||||
expect(cancelJob).not.toHaveBeenCalled()
|
||||
expect(removeFailedJob).not.toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('removes failed jobs through their queue task', async () => {
|
||||
const task = fromPartial<TaskItemImpl>({ job: { id: 'prompt-1' } })
|
||||
const { result, unmount } = mountJobActions(
|
||||
ref(job({ state: 'failed', taskRef: task }))
|
||||
)
|
||||
|
||||
expect(result.canDeleteJob.value).toBe(true)
|
||||
await result.runDeleteJob()
|
||||
|
||||
expect(removeFailedJob).toHaveBeenCalledWith(task)
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@@ -280,6 +280,20 @@ describe('useJobMenu', () => {
|
||||
expect(copyToClipboardMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses an empty menu with the default current item getter', async () => {
|
||||
const { jobMenuEntries, openJobWorkflow, copyJobId, cancelJob } =
|
||||
useJobMenu()
|
||||
|
||||
await openJobWorkflow()
|
||||
await copyJobId()
|
||||
await cancelJob()
|
||||
|
||||
expect(jobMenuEntries.value).toEqual([])
|
||||
expect(getJobWorkflowMock).not.toHaveBeenCalled()
|
||||
expect(copyToClipboardMock).not.toHaveBeenCalled()
|
||||
expect(queueStoreMock.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.for([['running'], ['initialization'], ['pending']])(
|
||||
'cancels %s job via the state-agnostic jobs-namespace endpoint',
|
||||
async ([state]) => {
|
||||
@@ -393,6 +407,26 @@ describe('useJobMenu', () => {
|
||||
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
|
||||
})
|
||||
|
||||
it('ignores failed report action when item disappears before click', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: {
|
||||
errorMessage: 'Job failed with error'
|
||||
} as Partial<TaskItemImpl>
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
|
||||
setCurrentItem(null)
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||
expect(dialogServiceMock.showErrorDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores error actions when message missing', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
@@ -544,6 +578,74 @@ describe('useJobMenu', () => {
|
||||
expect(createAnnotatedPathMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses no root folder when preview type is not an API result type', async () => {
|
||||
const node = {
|
||||
widgets: [{ name: 'image', value: null, callback: vi.fn() }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(node)
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
previewOutput: {
|
||||
isImage: true,
|
||||
filename: 'foo.png',
|
||||
subfolder: 'bar',
|
||||
type: 'archive',
|
||||
url: 'http://asset'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(createAnnotatedPathMock).toHaveBeenCalledWith(
|
||||
{
|
||||
filename: 'foo.png',
|
||||
subfolder: 'bar',
|
||||
type: undefined
|
||||
},
|
||||
{ rootFolder: undefined },
|
||||
undefined
|
||||
)
|
||||
expect(node.graph.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('marks graph dirty when created loader node has no matching widget', async () => {
|
||||
const node = {
|
||||
widgets: [{ name: 'other', value: null, callback: vi.fn() }],
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(node)
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
previewOutput: {
|
||||
isImage: true,
|
||||
filename: 'foo.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(node.widgets[0].value).toBeNull()
|
||||
expect(node.widgets[0].callback).not.toHaveBeenCalled()
|
||||
expect(node.graph.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('ignores add-to-current entry when preview missing entirely', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
@@ -594,6 +696,45 @@ describe('useJobMenu', () => {
|
||||
expect(downloadFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores completed asset actions when item disappears before click', async () => {
|
||||
const inspectSpy = vi.fn()
|
||||
const { jobMenuEntries } = mountJobMenu(inspectSpy)
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
previewOutput: {
|
||||
isImage: true,
|
||||
filename: 'foo.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
url: 'https://asset'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
|
||||
const addEntry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
const downloadEntry = findActionEntry(jobMenuEntries.value, 'download')
|
||||
const exportEntry = findActionEntry(jobMenuEntries.value, 'export-workflow')
|
||||
const deleteEntry = findActionEntry(jobMenuEntries.value, 'delete')
|
||||
setCurrentItem(null)
|
||||
|
||||
void inspectEntry?.onClick?.()
|
||||
await addEntry?.onClick?.()
|
||||
void downloadEntry?.onClick?.()
|
||||
await exportEntry?.onClick?.()
|
||||
await deleteEntry?.onClick?.()
|
||||
|
||||
expect(inspectSpy).not.toHaveBeenCalled()
|
||||
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
|
||||
expect(downloadFileMock).not.toHaveBeenCalled()
|
||||
expect(getJobWorkflowMock).not.toHaveBeenCalled()
|
||||
expect(mediaAssetActionsMock.deleteAssets).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exports workflow with default filename when prompting disabled', async () => {
|
||||
const workflow = { foo: 'bar' }
|
||||
getJobWorkflowMock.mockResolvedValue(workflow)
|
||||
@@ -618,6 +759,17 @@ describe('useJobMenu', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not export workflow when workflow data is unavailable', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'completed' }))
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(downloadBlobMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prompts for filename when setting enabled', async () => {
|
||||
settingStoreMock.get.mockReturnValue(true)
|
||||
dialogServiceMock.prompt.mockResolvedValue('custom-name')
|
||||
@@ -713,6 +865,24 @@ describe('useJobMenu', () => {
|
||||
expect(queueStoreMock.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not delete asset when preview disappears before click', async () => {
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { previewOutput: {} }
|
||||
})
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'delete')
|
||||
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} }))
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(mediaAssetActionsMock.deleteAssets).not.toHaveBeenCalled()
|
||||
expect(queueStoreMock.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes failed job via menu entry', async () => {
|
||||
const taskRef = { id: 'task-1' }
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
|
||||
@@ -186,6 +186,75 @@ describe(useQueueNotificationBanners, () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('converts a queued-pending notification waiting behind the active one', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
mockApi.dispatchEvent(
|
||||
new CustomEvent('promptQueued', {
|
||||
detail: { requestId: 1, batchCount: 1 }
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
mockApi.dispatchEvent(
|
||||
new CustomEvent('promptQueueing', {
|
||||
detail: { requestId: 2, batchCount: 3 }
|
||||
})
|
||||
)
|
||||
mockApi.dispatchEvent(
|
||||
new CustomEvent('promptQueued', {
|
||||
detail: { requestId: 2, batchCount: 5 }
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'queued',
|
||||
count: 1,
|
||||
requestId: 1
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4000)
|
||||
await nextTick()
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'queued',
|
||||
count: 5,
|
||||
requestId: 2
|
||||
})
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('converts queued-pending notifications without request ids', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
mockApi.dispatchEvent(
|
||||
new CustomEvent('promptQueueing', {
|
||||
detail: { batchCount: 2 }
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
mockApi.dispatchEvent(
|
||||
new CustomEvent('promptQueued', {
|
||||
detail: { batchCount: 3 }
|
||||
})
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'queued',
|
||||
count: 3
|
||||
})
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to 1 when queued batch count is invalid', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
@@ -306,6 +375,64 @@ describe(useQueueNotificationBanners, () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('shows failed notifications for failed-only batches', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
await runBatch({
|
||||
start: 5_000,
|
||||
finish: 5_200,
|
||||
tasks: [createTask({ state: 'Failed', ts: 5_050 })]
|
||||
})
|
||||
|
||||
expect(composable.currentNotification.value).toEqual({
|
||||
type: 'failed',
|
||||
count: 1
|
||||
})
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not notify for old or unfinished history entries', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
await runBatch({
|
||||
start: 6_000,
|
||||
finish: 6_200,
|
||||
tasks: [
|
||||
createTask({ ts: 5_999 }),
|
||||
createTask({ state: 'Running', ts: 6_050 }),
|
||||
createTask({ state: 'Pending', ts: undefined })
|
||||
]
|
||||
})
|
||||
|
||||
expect(composable.currentNotification.value).toBeNull()
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps no notification visible when an idle window has no finished tasks', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
try {
|
||||
vi.setSystemTime(7_000)
|
||||
executionStore().isIdle = false
|
||||
await nextTick()
|
||||
|
||||
vi.setSystemTime(7_100)
|
||||
executionStore().isIdle = true
|
||||
queueStore().historyTasks = []
|
||||
await nextTick()
|
||||
|
||||
expect(composable.currentNotification.value).toBeNull()
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses up to two completion thumbnails for notification icon previews', async () => {
|
||||
const { unmount, composable } = mountComposable()
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { distribution, downloads } = vi.hoisted(() => ({
|
||||
distribution: { isDesktop: false },
|
||||
downloads: { values: [] as unknown[] }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/sidebar/tabs/ModelLibrarySidebarTab.vue', () => ({
|
||||
default: {}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isDesktop() {
|
||||
return distribution.isDesktop
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/electronDownloadStore', () => ({
|
||||
useElectronDownloadStore: () => ({
|
||||
inProgressDownloads: downloads.values
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useModelLibrarySidebarTab', () => {
|
||||
beforeEach(() => {
|
||||
distribution.isDesktop = false
|
||||
downloads.values = []
|
||||
})
|
||||
|
||||
it('hides the badge outside desktop builds', async () => {
|
||||
distribution.isDesktop = false
|
||||
downloads.values = [{ id: 'download-1' }]
|
||||
const { useModelLibrarySidebarTab } =
|
||||
await import('./useModelLibrarySidebarTab')
|
||||
|
||||
const sidebarTab = useModelLibrarySidebarTab()
|
||||
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows active desktop download count', async () => {
|
||||
distribution.isDesktop = true
|
||||
downloads.values = [{ id: 'a' }, { id: 'b' }]
|
||||
const { useModelLibrarySidebarTab } =
|
||||
await import('./useModelLibrarySidebarTab')
|
||||
|
||||
const sidebarTab = useModelLibrarySidebarTab()
|
||||
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBe('2')
|
||||
})
|
||||
|
||||
it('hides the badge when desktop has no active downloads', async () => {
|
||||
distribution.isDesktop = true
|
||||
const { useModelLibrarySidebarTab } =
|
||||
await import('./useModelLibrarySidebarTab')
|
||||
|
||||
const sidebarTab = useModelLibrarySidebarTab()
|
||||
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
|
||||
})
|
||||
})
|
||||
48
src/composables/sidebarTabs/useNodeLibrarySidebarTab.test.ts
Normal file
48
src/composables/sidebarTabs/useNodeLibrarySidebarTab.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { settings } = vi.hoisted(() => ({
|
||||
settings: { newDesign: false }
|
||||
}))
|
||||
|
||||
const legacyComponent = { name: 'NodeLibrarySidebarTab' }
|
||||
const newDesignComponent = { name: 'NodeLibrarySidebarTabV2' }
|
||||
|
||||
vi.mock('@/components/sidebar/tabs/NodeLibrarySidebarTab.vue', () => ({
|
||||
default: legacyComponent
|
||||
}))
|
||||
|
||||
vi.mock('@/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue', () => ({
|
||||
default: newDesignComponent
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) =>
|
||||
key === 'Comfy.NodeLibrary.NewDesign' && settings.newDesign
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useNodeLibrarySidebarTab', () => {
|
||||
beforeEach(() => {
|
||||
settings.newDesign = false
|
||||
})
|
||||
|
||||
it('uses the legacy node library component by default', async () => {
|
||||
const { useNodeLibrarySidebarTab } =
|
||||
await import('./useNodeLibrarySidebarTab')
|
||||
|
||||
const tab = useNodeLibrarySidebarTab()
|
||||
if (tab.type !== 'vue') throw new Error('Expected a vue sidebar tab')
|
||||
expect(tab.component).toBe(legacyComponent)
|
||||
})
|
||||
|
||||
it('uses the new node library component when the setting is enabled', async () => {
|
||||
settings.newDesign = true
|
||||
const { useNodeLibrarySidebarTab } =
|
||||
await import('./useNodeLibrarySidebarTab')
|
||||
|
||||
const tab = useNodeLibrarySidebarTab()
|
||||
if (tab.type !== 'vue') throw new Error('Expected a vue sidebar tab')
|
||||
expect(tab.component).toBe(newDesignComponent)
|
||||
})
|
||||
})
|
||||
112
src/composables/tree/useTreeFolderOperations.test.ts
Normal file
112
src/composables/tree/useTreeFolderOperations.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
import { useTreeFolderOperations } from './useTreeFolderOperations'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
function makeFolder(
|
||||
overrides: Partial<RenderedTreeExplorerNode> = {}
|
||||
): RenderedTreeExplorerNode {
|
||||
return {
|
||||
key: 'root',
|
||||
label: 'Root',
|
||||
leaf: false,
|
||||
children: [],
|
||||
icon: 'pi pi-folder',
|
||||
type: 'folder',
|
||||
totalLeaves: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('useTreeFolderOperations', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(123)
|
||||
})
|
||||
|
||||
it('creates a temporary editable folder under the selected target', () => {
|
||||
const expandNode = vi.fn()
|
||||
const target = makeFolder({ key: 'models', handleAddFolder: vi.fn() })
|
||||
const operations = useTreeFolderOperations(expandNode)
|
||||
|
||||
operations.addFolderCommand(target)
|
||||
|
||||
expect(expandNode).toHaveBeenCalledWith(target)
|
||||
expect(operations.newFolderNode.value).toMatchObject({
|
||||
key: 'models/new_folder_123',
|
||||
label: '',
|
||||
leaf: false,
|
||||
icon: 'pi pi-folder',
|
||||
type: 'folder',
|
||||
isEditingLabel: true
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the confirmed name to the target and clears temporary state', async () => {
|
||||
const handleAddFolder = vi.fn()
|
||||
const target = makeFolder({ handleAddFolder })
|
||||
const operations = useTreeFolderOperations(vi.fn())
|
||||
|
||||
operations.addFolderCommand(target)
|
||||
await operations.handleFolderCreation('New Folder')
|
||||
|
||||
expect(handleAddFolder).toHaveBeenCalledWith('New Folder')
|
||||
expect(operations.newFolderNode.value).toBeNull()
|
||||
})
|
||||
|
||||
it('clears temporary state even when folder creation fails', async () => {
|
||||
const handleAddFolder = vi.fn().mockRejectedValue(new Error('failed'))
|
||||
const target = makeFolder({ handleAddFolder })
|
||||
const operations = useTreeFolderOperations(vi.fn())
|
||||
|
||||
operations.addFolderCommand(target)
|
||||
|
||||
await expect(operations.handleFolderCreation('New Folder')).rejects.toThrow(
|
||||
'failed'
|
||||
)
|
||||
expect(operations.newFolderNode.value).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores folder creation when no target is pending', async () => {
|
||||
const operations = useTreeFolderOperations(vi.fn())
|
||||
|
||||
await operations.handleFolderCreation('Unused')
|
||||
|
||||
expect(operations.newFolderNode.value).toBeNull()
|
||||
})
|
||||
|
||||
it('returns a hidden menu item when the target cannot add folders', () => {
|
||||
const operations = useTreeFolderOperations(vi.fn())
|
||||
|
||||
expect(operations.getAddFolderMenuItem(null)).toMatchObject({
|
||||
label: 'g.newFolder',
|
||||
visible: false,
|
||||
isAsync: false
|
||||
})
|
||||
expect(
|
||||
operations.getAddFolderMenuItem(makeFolder({ leaf: true }))
|
||||
).toMatchObject({ visible: false })
|
||||
expect(
|
||||
operations.getAddFolderMenuItem(makeFolder({ leaf: false }))
|
||||
).toMatchObject({ visible: false })
|
||||
})
|
||||
|
||||
it('runs the add folder command from a visible menu item', () => {
|
||||
const expandNode = vi.fn()
|
||||
const target = makeFolder({ handleAddFolder: vi.fn() })
|
||||
const operations = useTreeFolderOperations(expandNode)
|
||||
const item = operations.getAddFolderMenuItem(target)
|
||||
|
||||
expect(item.visible).toBe(true)
|
||||
item.command?.({ originalEvent: new Event('click'), item })
|
||||
|
||||
expect(expandNode).toHaveBeenCalledWith(target)
|
||||
expect(operations.newFolderNode.value?.key).toBe('root/new_folder_123')
|
||||
})
|
||||
})
|
||||
285
src/composables/useCanvasDrop.test.ts
Normal file
285
src/composables/useCanvasDrop.test.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||
|
||||
type DropInput = {
|
||||
clientX: number
|
||||
clientY: number
|
||||
}
|
||||
|
||||
type DropEvent = {
|
||||
location: { current: { input: DropInput } }
|
||||
source: { data: { type: string; data?: unknown } }
|
||||
}
|
||||
|
||||
type DroppableOptions = {
|
||||
getDropEffect: (
|
||||
args: DropEvent
|
||||
) => Exclude<DataTransfer['dropEffect'], 'none'>
|
||||
onDrop: (event: DropEvent) => Promise<void>
|
||||
}
|
||||
|
||||
const {
|
||||
MockComfyModelDef,
|
||||
MockComfyNodeDefImpl,
|
||||
MockComfyWorkflow,
|
||||
captured,
|
||||
graph,
|
||||
insertWorkflow,
|
||||
addNodeOnGraph,
|
||||
getNodeProvider,
|
||||
getAllNodeProviders,
|
||||
withNodeAddSource
|
||||
} = vi.hoisted(() => {
|
||||
class MockComfyNodeDefImpl {
|
||||
name: string
|
||||
|
||||
constructor(name = 'NodeDef') {
|
||||
this.name = name
|
||||
}
|
||||
}
|
||||
|
||||
class MockComfyModelDef {
|
||||
directory: string
|
||||
file_name: string
|
||||
|
||||
constructor(directory = 'checkpoints', fileName = 'model.safetensors') {
|
||||
this.directory = directory
|
||||
this.file_name = fileName
|
||||
}
|
||||
}
|
||||
|
||||
class MockComfyWorkflow {
|
||||
id: string
|
||||
|
||||
constructor(id = 'workflow') {
|
||||
this.id = id
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
MockComfyModelDef,
|
||||
MockComfyNodeDefImpl,
|
||||
MockComfyWorkflow,
|
||||
captured: {
|
||||
options: undefined as DroppableOptions | undefined
|
||||
},
|
||||
graph: {
|
||||
getNodeOnPos: vi.fn()
|
||||
},
|
||||
insertWorkflow: vi.fn(),
|
||||
addNodeOnGraph: vi.fn(),
|
||||
getNodeProvider: vi.fn(),
|
||||
getAllNodeProviders: vi.fn(),
|
||||
withNodeAddSource: vi.fn((_source: string, callback: () => unknown) =>
|
||||
callback()
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
|
||||
useSharedCanvasPositionConversion: () => ({
|
||||
clientPosToCanvasPos: ([x, y]: [number, number]) => [x / 2, y / 2]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/usePragmaticDragAndDrop', () => ({
|
||||
usePragmaticDroppable: vi.fn(
|
||||
(_target: () => HTMLCanvasElement | null, options: DroppableOptions) => {
|
||||
captured.options = options
|
||||
}
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LiteGraph: { NODE_TITLE_HEIGHT: 24 }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/nodeAdded/nodeAddSource', () => ({
|
||||
withNodeAddSource
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({ insertWorkflow })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
ComfyWorkflow: MockComfyWorkflow
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { graph } }
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ addNodeOnGraph })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelStore', () => ({
|
||||
ComfyModelDef: MockComfyModelDef
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({
|
||||
getNodeProvider,
|
||||
getAllNodeProviders
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
ComfyNodeDefImpl: MockComfyNodeDefImpl
|
||||
}))
|
||||
|
||||
function dropEvent(
|
||||
data: unknown,
|
||||
input: DropInput = { clientX: 20, clientY: 40 }
|
||||
) {
|
||||
return {
|
||||
location: { current: { input } },
|
||||
source: { data: { type: 'tree-explorer-node', data: { data } } }
|
||||
}
|
||||
}
|
||||
|
||||
function options() {
|
||||
useCanvasDrop(ref(document.createElement('canvas')))
|
||||
const value = captured.options
|
||||
if (!value) throw new Error('droppable options were not registered')
|
||||
return value
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
captured.options = undefined
|
||||
graph.getNodeOnPos.mockReset()
|
||||
insertWorkflow.mockReset()
|
||||
addNodeOnGraph.mockReset()
|
||||
getNodeProvider.mockReset()
|
||||
getAllNodeProviders.mockReset()
|
||||
withNodeAddSource.mockClear()
|
||||
})
|
||||
|
||||
describe('useCanvasDrop', () => {
|
||||
it('uses copy effect only for tree explorer nodes', () => {
|
||||
const droppable = options()
|
||||
|
||||
expect(
|
||||
droppable.getDropEffect({
|
||||
...dropEvent(undefined),
|
||||
source: { data: { type: 'tree-explorer-node' } }
|
||||
})
|
||||
).toBe('copy')
|
||||
expect(
|
||||
droppable.getDropEffect({
|
||||
...dropEvent(undefined),
|
||||
source: { data: { type: 'other' } }
|
||||
})
|
||||
).toBe('move')
|
||||
})
|
||||
|
||||
it('adds dropped node definitions below the cursor', async () => {
|
||||
const nodeDef = new MockComfyNodeDefImpl('KSampler')
|
||||
const droppable = options()
|
||||
|
||||
await droppable.onDrop(dropEvent(nodeDef))
|
||||
|
||||
expect(withNodeAddSource).toHaveBeenCalledWith(
|
||||
'sidebar_drag',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(addNodeOnGraph).toHaveBeenCalledWith(nodeDef, {
|
||||
pos: [10, 44]
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores drops that do not come from tree explorer nodes', async () => {
|
||||
const nodeDef = new MockComfyNodeDefImpl('KSampler')
|
||||
const droppable = options()
|
||||
|
||||
await droppable.onDrop({
|
||||
...dropEvent(nodeDef),
|
||||
source: { data: { type: 'other', data: { data: nodeDef } } }
|
||||
})
|
||||
|
||||
expect(addNodeOnGraph).not.toHaveBeenCalled()
|
||||
expect(insertWorkflow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets a model widget on an existing compatible node', async () => {
|
||||
const widget = { name: 'ckpt_name', value: '' }
|
||||
const node = { comfyClass: 'CheckpointLoaderSimple', widgets: [widget] }
|
||||
const provider = {
|
||||
key: 'ckpt_name',
|
||||
nodeDef: { name: 'CheckpointLoaderSimple' }
|
||||
}
|
||||
graph.getNodeOnPos.mockReturnValue(node)
|
||||
getAllNodeProviders.mockReturnValue([provider])
|
||||
const droppable = options()
|
||||
|
||||
await droppable.onDrop(
|
||||
dropEvent(new MockComfyModelDef('checkpoints', 'dream.safetensors'))
|
||||
)
|
||||
|
||||
expect(widget.value).toBe('dream.safetensors')
|
||||
expect(addNodeOnGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a provider node when the model has no compatible target', async () => {
|
||||
const widget = { name: 'lora_name', value: '' }
|
||||
const createdNode = { widgets: [widget] }
|
||||
const provider = { key: 'lora_name', nodeDef: { name: 'LoraLoader' } }
|
||||
graph.getNodeOnPos.mockReturnValue(undefined)
|
||||
getNodeProvider.mockReturnValue(provider)
|
||||
addNodeOnGraph.mockReturnValue(createdNode)
|
||||
const droppable = options()
|
||||
|
||||
await droppable.onDrop(
|
||||
dropEvent(new MockComfyModelDef('loras', 'style.safetensors'))
|
||||
)
|
||||
|
||||
expect(addNodeOnGraph).toHaveBeenCalledWith(provider.nodeDef, {
|
||||
pos: [10, 20]
|
||||
})
|
||||
expect(widget.value).toBe('style.safetensors')
|
||||
})
|
||||
|
||||
it('does nothing for model drops without a compatible or default provider', async () => {
|
||||
graph.getNodeOnPos.mockReturnValue({ comfyClass: 'OtherNode' })
|
||||
getAllNodeProviders.mockReturnValue([
|
||||
{ key: 'ckpt_name', nodeDef: { name: 'CheckpointLoaderSimple' } }
|
||||
])
|
||||
getNodeProvider.mockReturnValue(null)
|
||||
const droppable = options()
|
||||
|
||||
await droppable.onDrop(
|
||||
dropEvent(new MockComfyModelDef('checkpoints', 'dream.safetensors'))
|
||||
)
|
||||
|
||||
expect(addNodeOnGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not set a model value when the target node lacks the provider widget', async () => {
|
||||
const provider = { key: 'lora_name', nodeDef: { name: 'LoraLoader' } }
|
||||
const createdNode = { widgets: [{ name: 'other', value: '' }] }
|
||||
graph.getNodeOnPos.mockReturnValue(undefined)
|
||||
getNodeProvider.mockReturnValue(provider)
|
||||
addNodeOnGraph.mockReturnValue(createdNode)
|
||||
const droppable = options()
|
||||
|
||||
await droppable.onDrop(
|
||||
dropEvent(new MockComfyModelDef('loras', 'style.safetensors'))
|
||||
)
|
||||
|
||||
expect(createdNode.widgets[0].value).toBe('')
|
||||
})
|
||||
|
||||
it('inserts dropped workflows at the canvas position', async () => {
|
||||
const workflow = new MockComfyWorkflow('wf-1')
|
||||
const droppable = options()
|
||||
|
||||
await droppable.onDrop(dropEvent(workflow))
|
||||
|
||||
expect(insertWorkflow).toHaveBeenCalledWith(workflow, {
|
||||
position: [10, 20]
|
||||
})
|
||||
})
|
||||
})
|
||||
250
src/composables/useContextMenuTranslation.test.ts
Normal file
250
src/composables/useContextMenuTranslation.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { IContextMenuOptions } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
const mockInstall = vi.hoisted(() => vi.fn())
|
||||
const mockRegisterWrapper = vi.hoisted(() => vi.fn())
|
||||
const mockExtractLegacyItems = vi.hoisted(() => vi.fn())
|
||||
const mockCollectCanvasMenuItems = vi.hoisted(() => vi.fn())
|
||||
const mockCollectNodeMenuItems = vi.hoisted(() => vi.fn())
|
||||
const mockSt = vi.hoisted(() => vi.fn())
|
||||
const mockTe = vi.hoisted(() => vi.fn())
|
||||
const mockContextMenuConstructor = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockClasses = vi.hoisted(() => {
|
||||
class LGraphCanvas {
|
||||
getCanvasMenuOptions() {
|
||||
return [{ content: 'Base' }, null]
|
||||
}
|
||||
|
||||
getNodeMenuOptions(node: unknown) {
|
||||
return [{ content: `Node:${String(node)}` }]
|
||||
}
|
||||
}
|
||||
|
||||
class ContextMenu {
|
||||
constructor(values: unknown, options: unknown) {
|
||||
mockContextMenuConstructor(values, options)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
LGraphCanvas,
|
||||
ContextMenu,
|
||||
LiteGraph: {
|
||||
ContextMenu
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/litegraph/src/contextMenuCompat', () => ({
|
||||
legacyMenuCompat: {
|
||||
install: mockInstall,
|
||||
registerWrapper: mockRegisterWrapper,
|
||||
extractLegacyItems: mockExtractLegacyItems
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LGraphCanvas: mockClasses.LGraphCanvas,
|
||||
LiteGraph: mockClasses.LiteGraph
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
collectCanvasMenuItems: mockCollectCanvasMenuItems,
|
||||
collectNodeMenuItems: mockCollectNodeMenuItems
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: mockSt,
|
||||
te: mockTe
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/formatUtil', () => ({
|
||||
normalizeI18nKey: (value: string) => `normalized-${value}`
|
||||
}))
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useContextMenuTranslation } from './useContextMenuTranslation'
|
||||
|
||||
describe('useContextMenuTranslation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCollectCanvasMenuItems.mockReturnValue([{ content: 'NewApi' }])
|
||||
mockCollectNodeMenuItems.mockReturnValue([{ content: 'NodeApi' }])
|
||||
mockExtractLegacyItems.mockReturnValue([{ content: 'Legacy' }])
|
||||
mockSt.mockImplementation((_key: string, fallback: string) => {
|
||||
return `translated:${fallback}`
|
||||
})
|
||||
mockTe.mockImplementation(
|
||||
(key: string) => key === 'contextMenu.TranslateMe'
|
||||
)
|
||||
mockClasses.LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||
return [{ content: 'Base' }, null]
|
||||
}
|
||||
mockClasses.LGraphCanvas.prototype.getNodeMenuOptions = function (
|
||||
node: unknown
|
||||
) {
|
||||
return [{ content: `Node:${String(node)}` }]
|
||||
}
|
||||
mockClasses.LiteGraph.ContextMenu = mockClasses.ContextMenu
|
||||
})
|
||||
|
||||
it('wraps canvas menu options with new API, legacy, and translated items', () => {
|
||||
useContextMenuTranslation()
|
||||
const canvas = new mockClasses.LGraphCanvas()
|
||||
|
||||
const result = canvas.getCanvasMenuOptions()
|
||||
|
||||
expect(mockInstall).toHaveBeenCalledWith(
|
||||
mockClasses.LGraphCanvas.prototype,
|
||||
'getCanvasMenuOptions'
|
||||
)
|
||||
expect(mockCollectCanvasMenuItems).toHaveBeenCalledWith(canvas)
|
||||
expect(mockExtractLegacyItems).toHaveBeenCalledWith(
|
||||
'getCanvasMenuOptions',
|
||||
canvas
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{ content: 'translated:Base' },
|
||||
null,
|
||||
{ content: 'translated:NewApi' },
|
||||
{ content: 'translated:Legacy' }
|
||||
])
|
||||
})
|
||||
|
||||
it('wraps node menu options with new API and legacy extension items', () => {
|
||||
useContextMenuTranslation()
|
||||
const canvas = new mockClasses.LGraphCanvas()
|
||||
|
||||
const result = canvas.getNodeMenuOptions('node')
|
||||
|
||||
expect(mockInstall).toHaveBeenCalledWith(
|
||||
mockClasses.LGraphCanvas.prototype,
|
||||
'getNodeMenuOptions'
|
||||
)
|
||||
expect(mockCollectNodeMenuItems).toHaveBeenCalledWith('node')
|
||||
expect(mockExtractLegacyItems).toHaveBeenCalledWith(
|
||||
'getNodeMenuOptions',
|
||||
canvas,
|
||||
'node'
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{ content: 'Node:node' },
|
||||
{ content: 'NodeApi' },
|
||||
{ content: 'Legacy' }
|
||||
])
|
||||
})
|
||||
|
||||
it('translates LiteGraph context menu titles, nested items, and conversion labels', () => {
|
||||
useContextMenuTranslation()
|
||||
const values = [
|
||||
{ content: 'TranslateMe' },
|
||||
{ content: 'Convert seed to input' },
|
||||
{ content: 'Convert value to widget' },
|
||||
{
|
||||
content: '',
|
||||
submenu: {
|
||||
options: [{ content: 'TranslateMe' }]
|
||||
}
|
||||
},
|
||||
'separator'
|
||||
]
|
||||
const options = {
|
||||
title: 'KSampler',
|
||||
extra: {
|
||||
inputs: [{ name: 'seed', label: 'Seed Label' }],
|
||||
widgets: [{ name: 'value', label: 'Value Label' }]
|
||||
}
|
||||
}
|
||||
|
||||
new LiteGraph.ContextMenu(values, options)
|
||||
|
||||
expect(options.title).toBe('translated:KSampler')
|
||||
expect(values).toMatchObject([
|
||||
{ content: 'translated:TranslateMe' },
|
||||
{ content: 'translated:Convert Seed Labeltranslated: to input' },
|
||||
{ content: 'translated:Convert Value Labeltranslated: to widget' },
|
||||
{
|
||||
submenu: {
|
||||
options: [{ content: 'translated:TranslateMe' }]
|
||||
}
|
||||
},
|
||||
'separator'
|
||||
])
|
||||
expect(mockContextMenuConstructor).toHaveBeenCalledWith(values, options)
|
||||
})
|
||||
|
||||
it('uses parent menu extra data when direct options do not provide it', () => {
|
||||
useContextMenuTranslation()
|
||||
const values = [{ content: 'Convert latent to input' }]
|
||||
const options = {
|
||||
parentMenu: {
|
||||
options: {
|
||||
extra: {
|
||||
inputs: [{ name: 'latent', label: 'Latent Label' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
new LiteGraph.ContextMenu(
|
||||
values,
|
||||
fromAny<IContextMenuOptions<unknown, unknown>, unknown>(options)
|
||||
)
|
||||
|
||||
expect(values[0].content).toBe(
|
||||
'translated:Convert Latent Labeltranslated: to input'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps conversion names when matching inputs and widgets have no label', () => {
|
||||
useContextMenuTranslation()
|
||||
const values = [
|
||||
{ content: 'Convert seed to input' },
|
||||
{ content: 'Convert value to widget' }
|
||||
]
|
||||
const options = {
|
||||
extra: {
|
||||
inputs: [{ name: 'seed' }],
|
||||
widgets: [{ name: 'value' }]
|
||||
}
|
||||
}
|
||||
|
||||
new LiteGraph.ContextMenu(values, options)
|
||||
|
||||
expect(values).toMatchObject([
|
||||
{ content: 'translated:Convert seedtranslated: to input' },
|
||||
{ content: 'translated:Convert valuetranslated: to widget' }
|
||||
])
|
||||
})
|
||||
|
||||
it('uses widget labels when input conversion names do not match inputs', () => {
|
||||
useContextMenuTranslation()
|
||||
const values = [{ content: 'Convert seed to input' }]
|
||||
const options = {
|
||||
extra: {
|
||||
inputs: [{ name: 'other' }],
|
||||
widgets: [{ name: 'seed', label: 'Widget Seed' }]
|
||||
}
|
||||
}
|
||||
|
||||
new LiteGraph.ContextMenu(values, options)
|
||||
|
||||
expect(values[0].content).toBe(
|
||||
'translated:Convert Widget Seedtranslated: to input'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps plain unregistered menu items unchanged', () => {
|
||||
useContextMenuTranslation()
|
||||
const values = [{ content: 'Plain' }]
|
||||
|
||||
new LiteGraph.ContextMenu(values, {})
|
||||
|
||||
expect(values[0].content).toBe('Plain')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -503,7 +503,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}) => {
|
||||
trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
showSubscriptionDialog({ reason: 'subscribe_to_run' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -526,7 +526,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}) => {
|
||||
trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
showSubscriptionDialog({ reason: 'subscribe_to_run' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -548,7 +548,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}) => {
|
||||
trackRunButton(metadata)
|
||||
if (!isActiveSubscription.value) {
|
||||
showSubscriptionDialog()
|
||||
showSubscriptionDialog({ reason: 'subscribe_to_run' })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
124
src/composables/useEditKeybindingDialog.test.ts
Normal file
124
src/composables/useEditKeybindingDialog.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
import type { KeyComboImpl } from '@/platform/keybindings/keyCombo'
|
||||
|
||||
import { DIALOG_KEY, useEditKeybindingDialog } from './useEditKeybindingDialog'
|
||||
|
||||
const mockShowSmallLayoutDialog = vi.fn()
|
||||
const mockGetKeybinding = vi.fn()
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
showSmallLayoutDialog: mockShowSmallLayoutDialog
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/keybindings/keybindingStore', () => ({
|
||||
useKeybindingStore: () => ({
|
||||
getKeybinding: mockGetKeybinding
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/components/dialog/content/setting/keybinding/EditKeybindingContent.vue',
|
||||
() => ({
|
||||
default: { name: 'EditKeybindingContent' }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/components/dialog/content/setting/keybinding/EditKeybindingFooter.vue',
|
||||
() => ({
|
||||
default: { name: 'EditKeybindingFooter' }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/components/dialog/content/setting/keybinding/EditKeybindingHeader.vue',
|
||||
() => ({
|
||||
default: { name: 'EditKeybindingHeader' }
|
||||
})
|
||||
)
|
||||
|
||||
function makeCombo(label: string): KeyComboImpl {
|
||||
return fromAny({
|
||||
label,
|
||||
equals: vi.fn((other: { label: string }) => other.label === label)
|
||||
})
|
||||
}
|
||||
|
||||
describe('useEditKeybindingDialog', () => {
|
||||
it('opens the edit dialog with default edit state', () => {
|
||||
const currentCombo = makeCombo('Ctrl+A')
|
||||
|
||||
useEditKeybindingDialog().show({
|
||||
commandId: 'app.test',
|
||||
commandLabel: 'Test command',
|
||||
currentCombo
|
||||
})
|
||||
|
||||
expect(mockShowSmallLayoutDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: DIALOG_KEY,
|
||||
props: expect.objectContaining({
|
||||
commandLabel: 'Test command'
|
||||
})
|
||||
})
|
||||
)
|
||||
const dialog = mockShowSmallLayoutDialog.mock.calls[0][0]
|
||||
expect(dialog.props.dialogState).toMatchObject({
|
||||
commandId: 'app.test',
|
||||
newCombo: currentCombo,
|
||||
currentCombo,
|
||||
mode: 'edit',
|
||||
existingBinding: null
|
||||
})
|
||||
expect(dialog.footerProps.dialogState).toBe(dialog.props.dialogState)
|
||||
})
|
||||
|
||||
it('updates combo state and reports a conflicting binding', () => {
|
||||
const currentCombo = makeCombo('Ctrl+A')
|
||||
const newCombo = makeCombo('Ctrl+B')
|
||||
const binding = fromAny<KeybindingImpl, unknown>({
|
||||
commandId: 'other.command'
|
||||
})
|
||||
mockGetKeybinding.mockReturnValue(binding)
|
||||
|
||||
useEditKeybindingDialog().show({
|
||||
commandId: 'app.test',
|
||||
commandLabel: 'Test command',
|
||||
currentCombo,
|
||||
mode: 'add',
|
||||
existingBinding: binding
|
||||
})
|
||||
|
||||
const dialog = mockShowSmallLayoutDialog.mock.calls.at(-1)![0]
|
||||
dialog.props.onUpdateCombo(newCombo)
|
||||
|
||||
expect(dialog.props.dialogState.newCombo).toMatchObject({
|
||||
label: 'Ctrl+B'
|
||||
})
|
||||
expect(dialog.props.existingKeybindingOnCombo.value).toBe(binding)
|
||||
expect(mockGetKeybinding.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
label: 'Ctrl+B'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not report a conflict for an unchanged or empty combo', () => {
|
||||
const currentCombo = makeCombo('Ctrl+A')
|
||||
|
||||
useEditKeybindingDialog().show({
|
||||
commandId: 'app.test',
|
||||
commandLabel: 'Test command',
|
||||
currentCombo
|
||||
})
|
||||
|
||||
const dialog = mockShowSmallLayoutDialog.mock.calls.at(-1)![0]
|
||||
|
||||
expect(dialog.props.existingKeybindingOnCombo.value).toBeNull()
|
||||
dialog.props.dialogState.newCombo = null
|
||||
expect(dialog.props.existingKeybindingOnCombo.value).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,12 @@ import {
|
||||
useFeatureFlags
|
||||
} from '@/composables/useFeatureFlags'
|
||||
import * as distributionTypes from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
remoteConfig,
|
||||
remoteConfigState
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
// Mock the API module
|
||||
@@ -24,6 +30,13 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
describe('useFeatureFlags', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
localStorage.clear()
|
||||
remoteConfig.value = {}
|
||||
remoteConfigState.value = 'unloaded'
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
vi.mocked(distributionTypes).isNightly = false
|
||||
})
|
||||
|
||||
describe('flags object', () => {
|
||||
@@ -219,6 +232,149 @@ describe('useFeatureFlags', () => {
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('consolidatedBillingEnabled is false off-cloud even without an override', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.consolidatedBillingEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('teamWorkspacesEnabled is disabled outside cloud builds', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('teamWorkspacesEnabled uses the cached value before authenticated config loads', () => {
|
||||
vi.mocked(distributionTypes).isCloud = true
|
||||
cachedTeamWorkspacesEnabled.value = true
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(api.getServerFeature).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('teamWorkspacesEnabled falls back to the server after authenticated config loads', () => {
|
||||
vi.mocked(distributionTypes).isCloud = true
|
||||
remoteConfigState.value = 'authenticated'
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(api.getServerFeature).toHaveBeenCalledWith(
|
||||
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED,
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('nodeLibraryEssentialsEnabled checks config outside nightly and dev builds', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.NODE_LIBRARY_ESSENTIALS_ENABLED)
|
||||
return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.nodeLibraryEssentialsEnabled).toBe(true)
|
||||
expect(api.getServerFeature).toHaveBeenCalledWith(
|
||||
ServerFeatureFlag.NODE_LIBRARY_ESSENTIALS_ENABLED,
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('nodeLibraryEssentialsEnabled uses remote config before the server fallback', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
remoteConfig.value = {
|
||||
node_library_essentials_enabled: true
|
||||
}
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.nodeLibraryEssentialsEnabled).toBe(true)
|
||||
expect(api.getServerFeature).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('auth-gated flags on cloud', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(distributionTypes).isCloud = true
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns the cached session value during the auth window', () => {
|
||||
cachedTeamWorkspacesEnabled.value = false
|
||||
cachedConsolidatedBillingEnabled.value = true
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to false during the auth window when nothing is cached', () => {
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers authenticated remoteConfig over the server feature fallback', () => {
|
||||
remoteConfigState.value = 'authenticated'
|
||||
remoteConfig.value = {
|
||||
team_workspaces_enabled: true,
|
||||
consolidated_billing_enabled: true
|
||||
}
|
||||
vi.mocked(api.getServerFeature).mockReturnValue(false)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
|
||||
remoteConfigState.value = 'authenticated'
|
||||
remoteConfig.value = {}
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
|
||||
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
|
||||
return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('signupTurnstileMode', () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { computed, reactive, readonly } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
isAuthenticatedConfigLoaded,
|
||||
remoteConfig
|
||||
@@ -30,6 +32,7 @@ export enum ServerFeatureFlag {
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile'
|
||||
}
|
||||
|
||||
@@ -46,6 +49,26 @@ function resolveFlag<T>(
|
||||
return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a per-user, Cloud-only flag that selects backend behavior. Off the
|
||||
* Cloud build it is always false; during the auth window it falls back to the
|
||||
* cached session value so anonymous bootstrap config cannot route the user to
|
||||
* the wrong backend before authenticated config confirms the flag.
|
||||
*/
|
||||
function resolveAuthGatedFlag(
|
||||
flagKey: string,
|
||||
remoteConfigValue: boolean | undefined,
|
||||
cachedValue: Ref<boolean | undefined>
|
||||
): boolean {
|
||||
const override = getDevOverride<boolean>(flagKey)
|
||||
if (override !== undefined) return override
|
||||
|
||||
if (!isCloud) return false
|
||||
if (!isAuthenticatedConfigLoaded.value) return cachedValue.value ?? false
|
||||
|
||||
return remoteConfigValue ?? api.getServerFeature(flagKey, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for reactive access to server-side feature flags
|
||||
*/
|
||||
@@ -104,18 +127,10 @@ export function useFeatureFlags() {
|
||||
* and prevents race conditions during initialization.
|
||||
*/
|
||||
get teamWorkspacesEnabled() {
|
||||
const override = getDevOverride<boolean>(
|
||||
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED
|
||||
)
|
||||
if (override !== undefined) return override
|
||||
|
||||
if (!isCloud) return false
|
||||
if (!isAuthenticatedConfigLoaded.value)
|
||||
return cachedTeamWorkspacesEnabled.value ?? false
|
||||
|
||||
return (
|
||||
remoteConfig.value.team_workspaces_enabled ??
|
||||
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
|
||||
return resolveAuthGatedFlag(
|
||||
ServerFeatureFlag.TEAM_WORKSPACES_ENABLED,
|
||||
remoteConfig.value.team_workspaces_enabled,
|
||||
cachedTeamWorkspacesEnabled
|
||||
)
|
||||
},
|
||||
get userSecretsEnabled() {
|
||||
@@ -175,6 +190,18 @@ export function useFeatureFlags() {
|
||||
false
|
||||
)
|
||||
},
|
||||
/**
|
||||
* Whether personal workspaces use the consolidated (workspace-scoped)
|
||||
* billing flow. While false (default), personal workspaces stay on the
|
||||
* legacy per-user billing flow; team workspaces are unaffected.
|
||||
*/
|
||||
get consolidatedBillingEnabled() {
|
||||
return resolveAuthGatedFlag(
|
||||
ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED,
|
||||
remoteConfig.value.consolidated_billing_enabled,
|
||||
cachedConsolidatedBillingEnabled
|
||||
)
|
||||
},
|
||||
get signupTurnstileMode() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.SIGNUP_TURNSTILE,
|
||||
|
||||
591
src/composables/useHdrViewer.test.ts
Normal file
591
src/composables/useHdrViewer.test.ts
Normal file
@@ -0,0 +1,591 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, nextTick } from 'vue'
|
||||
import type { App } from 'vue'
|
||||
|
||||
const mockStats = {
|
||||
min: 0,
|
||||
max: 4,
|
||||
mean: 1,
|
||||
stdDev: 0.5,
|
||||
nanCount: 0,
|
||||
infCount: 0
|
||||
}
|
||||
const mockHistograms = {
|
||||
r: new Uint32Array([1]),
|
||||
g: new Uint32Array([2]),
|
||||
b: new Uint32Array([3]),
|
||||
a: new Uint32Array([4]),
|
||||
luminance: new Uint32Array([5])
|
||||
}
|
||||
|
||||
interface MockTexture {
|
||||
type: string
|
||||
image: {
|
||||
width: number
|
||||
height: number
|
||||
data: ArrayLike<number>
|
||||
}
|
||||
colorSpace?: string
|
||||
minFilter?: string
|
||||
magFilter?: string
|
||||
needsUpdate?: boolean
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
exrLoad: vi.fn(),
|
||||
exrSetDataType: vi.fn(),
|
||||
rgbeLoad: vi.fn(),
|
||||
render: vi.fn(),
|
||||
setPixelRatio: vi.fn(),
|
||||
setClearColor: vi.fn(),
|
||||
setSize: vi.fn(),
|
||||
updateProjectionMatrix: vi.fn(),
|
||||
positionSet: vi.fn(),
|
||||
scaleSet: vi.fn(),
|
||||
sceneAdd: vi.fn(),
|
||||
materialDispose: vi.fn(),
|
||||
geometryDispose: vi.fn(),
|
||||
viewportObserveResize: vi.fn(),
|
||||
viewportDisposeRenderer: vi.fn(),
|
||||
textureDispose: vi.fn(),
|
||||
raycasterSetFromCamera: vi.fn(),
|
||||
intersectObject: vi.fn(),
|
||||
fromHalfFloat: vi.fn((value: number) => value + 0.5),
|
||||
matrixSet: vi.fn(),
|
||||
detectGamutFromChromaticities: vi.fn(() => 'Display P3'),
|
||||
gamutToSrgbMatrix: vi.fn(() => [1, 0, 0, 0, 1, 0, 0, 0, 1]),
|
||||
computeImageStats: vi.fn(() => mockStats),
|
||||
computeChannelHistograms: vi.fn(() => mockHistograms),
|
||||
lastCanvas: undefined as HTMLCanvasElement | undefined
|
||||
}))
|
||||
|
||||
vi.mock('three', () => {
|
||||
class WebGLRenderer {
|
||||
domElement: HTMLCanvasElement
|
||||
outputColorSpace?: string
|
||||
|
||||
constructor() {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
right: 100,
|
||||
bottom: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
}) satisfies DOMRect
|
||||
this.domElement = canvas
|
||||
mocks.lastCanvas = canvas
|
||||
}
|
||||
|
||||
setPixelRatio(value: number) {
|
||||
mocks.setPixelRatio(value)
|
||||
}
|
||||
|
||||
setClearColor(color: number, alpha: number) {
|
||||
mocks.setClearColor(color, alpha)
|
||||
}
|
||||
|
||||
setSize(width: number, height: number, updateStyle: boolean) {
|
||||
mocks.setSize(width, height, updateStyle)
|
||||
}
|
||||
|
||||
render(scene: unknown, camera: unknown) {
|
||||
mocks.render(scene, camera)
|
||||
}
|
||||
}
|
||||
|
||||
class Scene {
|
||||
add(mesh: unknown) {
|
||||
mocks.sceneAdd(mesh)
|
||||
}
|
||||
}
|
||||
|
||||
class OrthographicCamera {
|
||||
left = -1
|
||||
right = 1
|
||||
top = 1
|
||||
bottom = -1
|
||||
zoom = 1
|
||||
position = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 1,
|
||||
set: (x: number, y: number, z: number) => {
|
||||
this.position.x = x
|
||||
this.position.y = y
|
||||
this.position.z = z
|
||||
mocks.positionSet(x, y, z)
|
||||
}
|
||||
}
|
||||
|
||||
updateProjectionMatrix() {
|
||||
mocks.updateProjectionMatrix()
|
||||
}
|
||||
}
|
||||
|
||||
class Matrix3 {
|
||||
set(...values: number[]) {
|
||||
mocks.matrixSet(values)
|
||||
}
|
||||
}
|
||||
|
||||
class ShaderMaterial {
|
||||
uniforms: Record<string, { value: unknown }>
|
||||
|
||||
constructor(options: { uniforms: Record<string, { value: unknown }> }) {
|
||||
this.uniforms = options.uniforms
|
||||
}
|
||||
|
||||
dispose() {
|
||||
mocks.materialDispose()
|
||||
}
|
||||
}
|
||||
|
||||
class Mesh {
|
||||
scale = { set: mocks.scaleSet }
|
||||
geometry = { dispose: mocks.geometryDispose }
|
||||
|
||||
constructor(
|
||||
readonly geometryInput: unknown,
|
||||
readonly materialInput: unknown
|
||||
) {}
|
||||
}
|
||||
|
||||
class PlaneGeometry {
|
||||
constructor(
|
||||
readonly width: number,
|
||||
readonly height: number
|
||||
) {}
|
||||
}
|
||||
|
||||
class Raycaster {
|
||||
setFromCamera(pointer: unknown, camera: unknown) {
|
||||
mocks.raycasterSetFromCamera(pointer, camera)
|
||||
}
|
||||
|
||||
intersectObject(mesh: unknown) {
|
||||
return mocks.intersectObject(mesh)
|
||||
}
|
||||
}
|
||||
|
||||
class Vector2 {
|
||||
constructor(
|
||||
public x = 0,
|
||||
public y = 0
|
||||
) {}
|
||||
}
|
||||
|
||||
return {
|
||||
WebGLRenderer,
|
||||
Scene,
|
||||
OrthographicCamera,
|
||||
Matrix3,
|
||||
ShaderMaterial,
|
||||
Mesh,
|
||||
PlaneGeometry,
|
||||
Raycaster,
|
||||
Vector2,
|
||||
DataUtils: { fromHalfFloat: mocks.fromHalfFloat },
|
||||
MathUtils: {
|
||||
clamp: (value: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, value))
|
||||
},
|
||||
FloatType: 'FloatType',
|
||||
HalfFloatType: 'HalfFloatType',
|
||||
LinearSRGBColorSpace: 'LinearSRGBColorSpace',
|
||||
LinearFilter: 'LinearFilter',
|
||||
GLSL3: 'GLSL3'
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/EXRLoader', () => ({
|
||||
EXRLoader: class {
|
||||
setDataType(type: string) {
|
||||
mocks.exrSetDataType(type)
|
||||
}
|
||||
|
||||
load(
|
||||
url: string,
|
||||
onLoad: (texture: MockTexture, textureData?: unknown) => void,
|
||||
onProgress: unknown,
|
||||
onError: (error: unknown) => void
|
||||
) {
|
||||
mocks.exrLoad(url, onLoad, onProgress, onError)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/RGBELoader', () => ({
|
||||
RGBELoader: class {
|
||||
load(
|
||||
url: string,
|
||||
onLoad: (texture: MockTexture, textureData?: unknown) => void,
|
||||
onProgress: unknown,
|
||||
onError: (error: unknown) => void
|
||||
) {
|
||||
mocks.rgbeLoad(url, onLoad, onProgress, onError)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/three/WebGLViewport', () => ({
|
||||
WebGLViewport: class {
|
||||
constructor(readonly renderer: unknown) {}
|
||||
|
||||
observeResize(container: HTMLElement, resize: () => void) {
|
||||
mocks.viewportObserveResize(container, resize)
|
||||
}
|
||||
|
||||
disposeRenderer() {
|
||||
mocks.viewportDisposeRenderer()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/hdr/colorGamut', () => ({
|
||||
detectGamutFromChromaticities: mocks.detectGamutFromChromaticities,
|
||||
gamutToSrgbMatrix: mocks.gamutToSrgbMatrix
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/hdr/hdrStats', () => ({
|
||||
computeImageStats: mocks.computeImageStats,
|
||||
computeChannelHistograms: mocks.computeChannelHistograms
|
||||
}))
|
||||
|
||||
import { CHANNEL_MODES, useHdrViewer } from './useHdrViewer'
|
||||
|
||||
type HdrViewer = ReturnType<typeof useHdrViewer>
|
||||
|
||||
let mountedApps: App[] = []
|
||||
|
||||
function createViewer(): HdrViewer {
|
||||
let viewer: HdrViewer | undefined
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
viewer = useHdrViewer()
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
)
|
||||
app.mount(document.createElement('div'))
|
||||
mountedApps.push(app)
|
||||
if (!viewer) throw new Error('Expected useHdrViewer to initialize')
|
||||
return viewer
|
||||
}
|
||||
|
||||
function makeTexture(
|
||||
data: ArrayLike<number> = [0, 0.25, 0.5, 1, 1, 2, 3, 4],
|
||||
width = 2,
|
||||
height = 1,
|
||||
type = 'FloatType'
|
||||
): MockTexture {
|
||||
return {
|
||||
type,
|
||||
image: { width, height, data },
|
||||
dispose: mocks.textureDispose
|
||||
}
|
||||
}
|
||||
|
||||
function makeContainer(): HTMLElement {
|
||||
const container = document.createElement('div')
|
||||
Object.defineProperty(container, 'clientWidth', { value: 200 })
|
||||
Object.defineProperty(container, 'clientHeight', { value: 100 })
|
||||
return container
|
||||
}
|
||||
|
||||
describe('useHdrViewer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callback(0)
|
||||
return 1
|
||||
})
|
||||
mocks.lastCanvas = undefined
|
||||
mocks.exrLoad.mockImplementation(
|
||||
(
|
||||
_url: string,
|
||||
onLoad: (texture: MockTexture, textureData?: unknown) => void
|
||||
) => onLoad(makeTexture())
|
||||
)
|
||||
mocks.rgbeLoad.mockImplementation(
|
||||
(
|
||||
_url: string,
|
||||
onLoad: (texture: MockTexture, textureData?: unknown) => void
|
||||
) => onLoad(makeTexture(), { header: { chromaticities: {} } })
|
||||
)
|
||||
mocks.intersectObject.mockReturnValue([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps) app.unmount()
|
||||
mountedApps = []
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('exposes all channel modes', () => {
|
||||
expect(CHANNEL_MODES).toEqual(['rgb', 'r', 'g', 'b', 'a', 'luminance'])
|
||||
})
|
||||
|
||||
it('mounts hdr textures through the RGBE loader and exposes image metadata', async () => {
|
||||
const viewer = createViewer()
|
||||
const container = makeContainer()
|
||||
|
||||
await viewer.mount(container, '/api/view?filename=scene.hdr')
|
||||
|
||||
expect(mocks.rgbeLoad).toHaveBeenCalledWith(
|
||||
'/api/view?filename=scene.hdr',
|
||||
expect.any(Function),
|
||||
undefined,
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(mocks.exrSetDataType).not.toHaveBeenCalled()
|
||||
expect(viewer.loading.value).toBe(false)
|
||||
expect(viewer.error.value).toBeNull()
|
||||
expect(viewer.gamut.value).toBe('Display P3')
|
||||
expect(viewer.dimensions.value).toBe('2 x 1')
|
||||
expect(viewer.stats.value).toEqual(mockStats)
|
||||
expect(viewer.histogram.value).toBe(mockHistograms.luminance)
|
||||
viewer.channel.value = 'g'
|
||||
await nextTick()
|
||||
expect(viewer.histogram.value).toBe(mockHistograms.g)
|
||||
expect(container.contains(mocks.lastCanvas!)).toBe(true)
|
||||
})
|
||||
|
||||
it('selects histograms for every channel mode', async () => {
|
||||
const viewer = createViewer()
|
||||
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=scene.hdr')
|
||||
|
||||
for (const [mode, histogram] of [
|
||||
['r', mockHistograms.r],
|
||||
['g', mockHistograms.g],
|
||||
['b', mockHistograms.b],
|
||||
['a', mockHistograms.a],
|
||||
['rgb', mockHistograms.luminance],
|
||||
['luminance', mockHistograms.luminance]
|
||||
] as const) {
|
||||
viewer.channel.value = mode
|
||||
await nextTick()
|
||||
expect(viewer.histogram.value).toBe(histogram)
|
||||
}
|
||||
})
|
||||
|
||||
it('loads exr textures with float data and reads hovered pixels', async () => {
|
||||
const data = [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
mocks.exrLoad.mockImplementation(
|
||||
(
|
||||
_url: string,
|
||||
onLoad: (texture: MockTexture, textureData?: unknown) => void
|
||||
) => onLoad(makeTexture(data, 2, 1, 'HalfFloatType'))
|
||||
)
|
||||
mocks.intersectObject.mockReturnValue([{ uv: { x: 0.75, y: 0.25 } }])
|
||||
const viewer = createViewer()
|
||||
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
|
||||
mocks.lastCanvas!.dispatchEvent(
|
||||
new PointerEvent('pointermove', { clientX: 75, clientY: 75 })
|
||||
)
|
||||
|
||||
expect(mocks.exrSetDataType).toHaveBeenCalledWith('FloatType')
|
||||
expect(mocks.fromHalfFloat).toHaveBeenCalled()
|
||||
expect(viewer.pixel.value).toEqual({
|
||||
x: 1,
|
||||
y: 0,
|
||||
r: 4.5,
|
||||
g: 5.5,
|
||||
b: 6.5,
|
||||
a: 7.5
|
||||
})
|
||||
})
|
||||
|
||||
it('reads three-channel pixels without alpha', async () => {
|
||||
mocks.exrLoad.mockImplementation(
|
||||
(
|
||||
_url: string,
|
||||
onLoad: (texture: MockTexture, textureData?: unknown) => void
|
||||
) => onLoad(makeTexture([0, 1, 2, 3, 4, 5], 2, 1))
|
||||
)
|
||||
mocks.intersectObject.mockReturnValue([{ uv: { x: 0.75, y: 0.25 } }])
|
||||
const viewer = createViewer()
|
||||
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
|
||||
mocks.lastCanvas!.dispatchEvent(
|
||||
new PointerEvent('pointermove', { clientX: 75, clientY: 75 })
|
||||
)
|
||||
|
||||
expect(viewer.pixel.value).toEqual({
|
||||
x: 1,
|
||||
y: 0,
|
||||
r: 3,
|
||||
g: 4,
|
||||
b: 5,
|
||||
a: null
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the hovered pixel when the pointer leaves or misses the mesh', async () => {
|
||||
mocks.intersectObject.mockReturnValueOnce([{ uv: { x: 0, y: 0 } }])
|
||||
const viewer = createViewer()
|
||||
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
|
||||
mocks.lastCanvas!.dispatchEvent(new PointerEvent('pointermove'))
|
||||
expect(viewer.pixel.value).not.toBeNull()
|
||||
|
||||
mocks.lastCanvas!.dispatchEvent(new PointerEvent('pointerleave'))
|
||||
expect(viewer.pixel.value).toBeNull()
|
||||
|
||||
mocks.lastCanvas!.dispatchEvent(new PointerEvent('pointermove'))
|
||||
expect(viewer.pixel.value).toBeNull()
|
||||
})
|
||||
|
||||
it('normalizes exposure and disposes renderer resources', async () => {
|
||||
const viewer = createViewer()
|
||||
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
|
||||
viewer.normalizeExposure()
|
||||
viewer.dispose()
|
||||
|
||||
expect(viewer.exposureStops.value).toBe(-2)
|
||||
expect(mocks.viewportDisposeRenderer).toHaveBeenCalled()
|
||||
expect(mocks.textureDispose).toHaveBeenCalled()
|
||||
expect(mocks.materialDispose).toHaveBeenCalled()
|
||||
expect(mocks.geometryDispose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles no-op viewer actions before mounting', () => {
|
||||
const viewer = createViewer()
|
||||
|
||||
viewer.fitView()
|
||||
viewer.normalizeExposure()
|
||||
viewer.dispose()
|
||||
|
||||
expect(viewer.exposureStops.value).toBe(0)
|
||||
expect(mocks.viewportDisposeRenderer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves sample-derived state empty when texture data is missing', async () => {
|
||||
mocks.exrLoad.mockImplementation(
|
||||
(
|
||||
_url: string,
|
||||
onLoad: (texture: MockTexture, textureData?: unknown) => void
|
||||
) =>
|
||||
onLoad({
|
||||
...makeTexture(),
|
||||
image: {
|
||||
width: 2,
|
||||
height: 1,
|
||||
data: undefined as unknown as ArrayLike<number>
|
||||
}
|
||||
})
|
||||
)
|
||||
const viewer = createViewer()
|
||||
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
|
||||
viewer.normalizeExposure()
|
||||
|
||||
expect(viewer.dimensions.value).toBe('2 x 1')
|
||||
expect(viewer.stats.value).toBeNull()
|
||||
expect(viewer.histogram.value).toBeNull()
|
||||
expect(viewer.exposureStops.value).toBe(0)
|
||||
})
|
||||
|
||||
it('disposes textures that finish loading after viewer disposal', async () => {
|
||||
let resolveLoad: (
|
||||
texture: MockTexture,
|
||||
textureData?: unknown
|
||||
) => void = () => {}
|
||||
mocks.exrLoad.mockImplementation(
|
||||
(
|
||||
_url: string,
|
||||
onLoad: (texture: MockTexture, textureData?: unknown) => void
|
||||
) => {
|
||||
resolveLoad = onLoad
|
||||
}
|
||||
)
|
||||
const viewer = createViewer()
|
||||
const mounting = viewer.mount(
|
||||
makeContainer(),
|
||||
'/api/view?filename=scene.exr'
|
||||
)
|
||||
|
||||
viewer.dispose()
|
||||
resolveLoad(makeTexture())
|
||||
await mounting
|
||||
|
||||
expect(mocks.textureDispose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports loader errors and clears loading state', async () => {
|
||||
mocks.exrLoad.mockImplementation(
|
||||
(
|
||||
_url: string,
|
||||
_onLoad: (texture: MockTexture, textureData?: unknown) => void,
|
||||
_onProgress: unknown,
|
||||
onError: (error: unknown) => void
|
||||
) => onError(new Error('load failed'))
|
||||
)
|
||||
const viewer = createViewer()
|
||||
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=broken.exr')
|
||||
|
||||
expect(viewer.error.value).toBe('load failed')
|
||||
expect(viewer.loading.value).toBe(false)
|
||||
expect(mocks.viewportDisposeRenderer).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports string loader errors', async () => {
|
||||
mocks.exrLoad.mockImplementation(
|
||||
(
|
||||
_url: string,
|
||||
_onLoad: (texture: MockTexture, textureData?: unknown) => void,
|
||||
_onProgress: unknown,
|
||||
onError: (error: unknown) => void
|
||||
) => onError('load failed')
|
||||
)
|
||||
const viewer = createViewer()
|
||||
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=broken.exr')
|
||||
|
||||
expect(viewer.error.value).toBe('load failed')
|
||||
})
|
||||
|
||||
it('zooms with the wheel and pans while dragging', async () => {
|
||||
const viewer = createViewer()
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
|
||||
|
||||
mocks.lastCanvas!.dispatchEvent(new WheelEvent('wheel', { deltaY: -1000 }))
|
||||
mocks.lastCanvas!.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
window.dispatchEvent(
|
||||
new PointerEvent('pointermove', { clientX: 20, clientY: 30 })
|
||||
)
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(mocks.updateProjectionMatrix).toHaveBeenCalled()
|
||||
expect(mocks.render).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores hover sampling while dragging', async () => {
|
||||
const viewer = createViewer()
|
||||
await viewer.mount(makeContainer(), '/api/view?filename=scene.exr')
|
||||
|
||||
mocks.lastCanvas!.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { clientX: 10, clientY: 10 })
|
||||
)
|
||||
mocks.raycasterSetFromCamera.mockClear()
|
||||
mocks.lastCanvas!.dispatchEvent(
|
||||
new PointerEvent('pointermove', { clientX: 20, clientY: 20 })
|
||||
)
|
||||
window.dispatchEvent(new PointerEvent('pointerup'))
|
||||
|
||||
expect(mocks.raycasterSetFromCamera).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
189
src/composables/useHelpCenter.test.ts
Normal file
189
src/composables/useHelpCenter.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent } from 'vue'
|
||||
|
||||
const mockSettingGet = vi.hoisted(() => vi.fn())
|
||||
const mockTrackUiButtonClicked = vi.hoisted(() => vi.fn())
|
||||
const mockReleaseStore = vi.hoisted(() => ({
|
||||
shouldShowRedDot: { value: false },
|
||||
initialize: vi.fn()
|
||||
}))
|
||||
const mockHelpCenterStore = vi.hoisted(() => ({
|
||||
isVisible: { value: false },
|
||||
toggle: vi.fn(),
|
||||
hide: vi.fn()
|
||||
}))
|
||||
const mockConflictDetection = vi.hoisted(() => ({
|
||||
shouldShowConflictModalAfterUpdate: vi.fn()
|
||||
}))
|
||||
const mockShowNodeConflictDialog = vi.hoisted(() => vi.fn())
|
||||
const mockConflictAcknowledgment = vi.hoisted(() => ({
|
||||
shouldShowRedDot: { value: false },
|
||||
markConflictsAsSeen: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('pinia', () => ({
|
||||
storeToRefs: (store: Record<string, unknown>) => store
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: mockSettingGet })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackUiButtonClicked: mockTrackUiButtonClicked
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/releaseStore', () => ({
|
||||
useReleaseStore: () => mockReleaseStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/helpCenterStore', () => ({
|
||||
useHelpCenterStore: () => mockHelpCenterStore
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/useConflictDetection',
|
||||
() => ({
|
||||
useConflictDetection: () => mockConflictDetection
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/useNodeConflictDialog',
|
||||
() => ({
|
||||
useNodeConflictDialog: () => ({ show: mockShowNodeConflictDialog })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
|
||||
() => ({
|
||||
useConflictAcknowledgment: () => mockConflictAcknowledgment
|
||||
})
|
||||
)
|
||||
|
||||
import { useHelpCenter } from './useHelpCenter'
|
||||
|
||||
function mountHelpCenter() {
|
||||
let result: ReturnType<typeof useHelpCenter> | undefined
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = useHelpCenter()
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
)
|
||||
app.mount(document.createElement('div'))
|
||||
if (!result) throw new Error('Expected help center composable to initialize')
|
||||
return { app, result }
|
||||
}
|
||||
|
||||
describe('useHelpCenter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSettingGet.mockReturnValue('left')
|
||||
mockReleaseStore.shouldShowRedDot.value = false
|
||||
mockHelpCenterStore.isVisible.value = false
|
||||
mockHelpCenterStore.toggle.mockImplementation(() => {
|
||||
mockHelpCenterStore.isVisible.value = !mockHelpCenterStore.isVisible.value
|
||||
})
|
||||
mockHelpCenterStore.hide.mockImplementation(() => {
|
||||
mockHelpCenterStore.isVisible.value = false
|
||||
})
|
||||
mockConflictAcknowledgment.shouldShowRedDot.value = false
|
||||
mockConflictDetection.shouldShowConflictModalAfterUpdate.mockResolvedValue(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('initializes releases on mount and exposes store-backed computed state', async () => {
|
||||
mockReleaseStore.shouldShowRedDot.value = true
|
||||
const { app, result } = mountHelpCenter()
|
||||
|
||||
expect(mockReleaseStore.initialize).toHaveBeenCalledOnce()
|
||||
expect(result.isHelpCenterVisible.value).toBe(false)
|
||||
expect(result.shouldShowRedDot.value).toBe(true)
|
||||
expect(result.sidebarLocation.value).toBe('left')
|
||||
|
||||
app.unmount()
|
||||
})
|
||||
|
||||
it('uses the conflict red dot when the release red dot is hidden', () => {
|
||||
mockConflictAcknowledgment.shouldShowRedDot.value = true
|
||||
const { app, result } = mountHelpCenter()
|
||||
|
||||
expect(result.shouldShowRedDot.value).toBe(true)
|
||||
|
||||
app.unmount()
|
||||
})
|
||||
|
||||
it('tracks and toggles help center visibility', () => {
|
||||
const { app, result } = mountHelpCenter()
|
||||
|
||||
result.toggleHelpCenter()
|
||||
|
||||
expect(mockTrackUiButtonClicked).toHaveBeenCalledWith({
|
||||
button_id: 'sidebar_help_center_toggled',
|
||||
element_group: 'sidebar'
|
||||
})
|
||||
expect(mockHelpCenterStore.toggle).toHaveBeenCalledOnce()
|
||||
expect(mockHelpCenterStore.isVisible.value).toBe(true)
|
||||
|
||||
result.closeHelpCenter()
|
||||
|
||||
expect(mockHelpCenterStore.hide).toHaveBeenCalledOnce()
|
||||
expect(mockHelpCenterStore.isVisible.value).toBe(false)
|
||||
app.unmount()
|
||||
})
|
||||
|
||||
it('opens the conflict modal after the whats-new dialog when needed', async () => {
|
||||
mockConflictDetection.shouldShowConflictModalAfterUpdate.mockResolvedValue(
|
||||
true
|
||||
)
|
||||
const { app, result } = mountHelpCenter()
|
||||
|
||||
await result.handleWhatsNewDismissed()
|
||||
|
||||
expect(mockShowNodeConflictDialog).toHaveBeenCalledWith({
|
||||
showAfterWhatsNew: true,
|
||||
dialogComponentProps: {
|
||||
onClose: expect.any(Function)
|
||||
}
|
||||
})
|
||||
const options = mockShowNodeConflictDialog.mock.calls[0][0]
|
||||
options.dialogComponentProps.onClose()
|
||||
expect(
|
||||
mockConflictAcknowledgment.markConflictsAsSeen
|
||||
).toHaveBeenCalledOnce()
|
||||
app.unmount()
|
||||
})
|
||||
|
||||
it('does not open the conflict modal when not needed', async () => {
|
||||
const { app, result } = mountHelpCenter()
|
||||
|
||||
await result.handleWhatsNewDismissed()
|
||||
|
||||
expect(mockShowNodeConflictDialog).not.toHaveBeenCalled()
|
||||
app.unmount()
|
||||
})
|
||||
|
||||
it('logs conflict-check failures without throwing', async () => {
|
||||
const error = new Error('failed')
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockConflictDetection.shouldShowConflictModalAfterUpdate.mockRejectedValue(
|
||||
error
|
||||
)
|
||||
const { app, result } = mountHelpCenter()
|
||||
|
||||
await expect(result.handleWhatsNewDismissed()).resolves.toBeUndefined()
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[HelpCenter] Error checking conflict modal:',
|
||||
error
|
||||
)
|
||||
app.unmount()
|
||||
})
|
||||
})
|
||||
@@ -137,7 +137,7 @@ function mountContainerLayout(
|
||||
|
||||
function makePointerEvent(
|
||||
type: 'pointerdown' | 'pointermove' | 'pointerup',
|
||||
target: HTMLElement,
|
||||
target: EventTarget,
|
||||
clientX: number,
|
||||
clientY: number
|
||||
) {
|
||||
@@ -302,6 +302,32 @@ describe('useImageCrop', () => {
|
||||
expect(vm.imageUrl).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when a subgraph output link cannot be resolved', async () => {
|
||||
const subgraphInput = createMockSubgraphNode([], {
|
||||
id: 40,
|
||||
resolveSubgraphOutputLink: vi.fn(() => undefined)
|
||||
})
|
||||
const sgCrop = createMockLGraphNode({
|
||||
id: 2,
|
||||
getInputNode: vi.fn(() => subgraphInput),
|
||||
getInputLink: vi.fn(() => ({ origin_slot: 0 })),
|
||||
isSubgraphNode: () => false
|
||||
})
|
||||
mockResolveNode.mockReturnValue(sgCrop)
|
||||
const vm = await mountHarness()
|
||||
expect(vm.imageUrl).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the source node has no image URLs', async () => {
|
||||
mockGetNodeImageUrls.mockImplementation((n) =>
|
||||
n === sourceNode ? [] : null
|
||||
)
|
||||
|
||||
const vm = await mountHarness()
|
||||
|
||||
expect(vm.imageUrl).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves image through a subgraph input node', async () => {
|
||||
const innerSource = createMockLGraphNode({
|
||||
id: 50,
|
||||
@@ -340,6 +366,18 @@ describe('useImageCrop', () => {
|
||||
expect(vm.imageUrl).toBe('https://example.com/b.png')
|
||||
})
|
||||
|
||||
it('keeps loading unchanged when output updates keep the same URL', async () => {
|
||||
const vm = await mountHarness()
|
||||
;(vm.handleImageLoad as () => void)()
|
||||
expect(vm.isLoading).toBe(false)
|
||||
|
||||
outputStore.nodeOutputs['touch'] = { updated: true }
|
||||
|
||||
await flushTicks()
|
||||
expect(vm.imageUrl).toBe('https://example.com/a.png')
|
||||
expect(vm.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('updates imageUrl when nodePreviewImages change', async () => {
|
||||
let url = 'https://example.com/a.png'
|
||||
mockGetNodeImageUrls.mockImplementation((n) =>
|
||||
@@ -390,6 +428,30 @@ describe('useImageCrop', () => {
|
||||
expect(parseFloat(style.height)).toBeCloseTo(80, 1)
|
||||
})
|
||||
|
||||
it('ignores resize observer callbacks before an image is rendered', async () => {
|
||||
mockGetNodeImageUrls.mockReturnValue(null)
|
||||
const vm = await mountHarness()
|
||||
|
||||
flushResizeObservers()
|
||||
|
||||
expect(vm.imageUrl).toBeNull()
|
||||
expect(vm.cropBoxStyle).toEqual({
|
||||
left: '38px',
|
||||
top: '38px',
|
||||
width: '160px',
|
||||
height: '120px'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses default crop dimensions when model dimensions are zero', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 400)
|
||||
vm.modelValue = { x: 0, y: 0, width: 0, height: 0 }
|
||||
|
||||
expect(vm.cropWidth).toBe(512)
|
||||
expect(vm.cropHeight).toBe(512)
|
||||
})
|
||||
|
||||
it('exposes eight resize handles when unlocked and four when locked', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 400)
|
||||
@@ -423,6 +485,48 @@ describe('useImageCrop', () => {
|
||||
expect(vm.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('uses fallback scale when dragging before image dimensions are known', async () => {
|
||||
const vm = await mountHarness()
|
||||
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
|
||||
const captureEl = document.createElement('div')
|
||||
captureEl.setPointerCapture = vi.fn()
|
||||
captureEl.releasePointerCapture = vi.fn()
|
||||
|
||||
const dragStart = vm.handleDragStart as (e: PointerEvent) => void
|
||||
const dragMove = vm.handleDragMove as (e: PointerEvent) => void
|
||||
const dragEnd = vm.handleDragEnd as (e: PointerEvent) => void
|
||||
|
||||
dragStart(makePointerEvent('pointerdown', captureEl, 10, 10))
|
||||
dragMove(makePointerEvent('pointermove', captureEl, 30, 30))
|
||||
dragEnd(makePointerEvent('pointerup', captureEl, 30, 30))
|
||||
|
||||
expect(vm.modelValue.x).toBe(0)
|
||||
expect(vm.modelValue.y).toBe(0)
|
||||
})
|
||||
|
||||
it('uses fallback scale when rendered container width is missing', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 400)
|
||||
mountContainerLayout(vm.$el, 0, 300, 0)
|
||||
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
|
||||
const captureEl = document.createElement('div')
|
||||
captureEl.setPointerCapture = vi.fn()
|
||||
captureEl.releasePointerCapture = vi.fn()
|
||||
|
||||
const resizeStart = vm.handleResizeStart as (
|
||||
e: PointerEvent,
|
||||
dir: string
|
||||
) => void
|
||||
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
|
||||
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
|
||||
|
||||
resizeStart(makePointerEvent('pointerdown', captureEl, 130, 80), 'right')
|
||||
resizeMove(makePointerEvent('pointermove', captureEl, 150, 80))
|
||||
resizeEnd(makePointerEvent('pointerup', captureEl, 150, 80))
|
||||
|
||||
expect(vm.modelValue.width).toBe(140)
|
||||
})
|
||||
|
||||
it('does not start dragging when there is no image', async () => {
|
||||
mockGetNodeImageUrls.mockReturnValue(null)
|
||||
const vm = await mountHarness()
|
||||
@@ -436,6 +540,41 @@ describe('useImageCrop', () => {
|
||||
expect(vm.cropX as number).toBe(xBefore)
|
||||
})
|
||||
|
||||
it('ignores drag move and end before dragging starts', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 300)
|
||||
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
|
||||
const releaseEl = document.createElement('div')
|
||||
releaseEl.releasePointerCapture = vi.fn()
|
||||
|
||||
;(vm.handleDragMove as (e: PointerEvent) => void)(
|
||||
makePointerEvent('pointermove', releaseEl, 260, 180)
|
||||
)
|
||||
;(vm.handleDragEnd as (e: PointerEvent) => void)(
|
||||
makePointerEvent('pointerup', releaseEl, 260, 180)
|
||||
)
|
||||
|
||||
expect(vm.modelValue).toEqual({ x: 10, y: 10, width: 120, height: 90 })
|
||||
expect(releaseEl.releasePointerCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drags without pointer capture when the event target is not an element', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 300)
|
||||
vm.modelValue = { x: 10, y: 10, width: 120, height: 90 }
|
||||
|
||||
const dragStart = vm.handleDragStart as (e: PointerEvent) => void
|
||||
const dragMove = vm.handleDragMove as (e: PointerEvent) => void
|
||||
const dragEnd = vm.handleDragEnd as (e: PointerEvent) => void
|
||||
|
||||
dragStart(makePointerEvent('pointerdown', document, 200, 150))
|
||||
dragMove(makePointerEvent('pointermove', document, 260, 180))
|
||||
dragEnd(makePointerEvent('pointerup', document, 260, 180))
|
||||
|
||||
expect(vm.modelValue.x).toBeGreaterThan(10)
|
||||
expect(vm.modelValue.y).toBeGreaterThan(10)
|
||||
})
|
||||
|
||||
it('drags the crop box in image space and ends on pointerup', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 300)
|
||||
@@ -506,6 +645,62 @@ describe('useImageCrop', () => {
|
||||
expect(vm.modelValue.height).toBeLessThan(200)
|
||||
})
|
||||
|
||||
it('resizes from the left edge and clamps to the image origin', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 500, 500)
|
||||
vm.modelValue = { x: 50, y: 50, width: 120, height: 100 }
|
||||
|
||||
const captureEl = document.createElement('div')
|
||||
captureEl.setPointerCapture = vi.fn()
|
||||
captureEl.releasePointerCapture = vi.fn()
|
||||
|
||||
const resizeStart = vm.handleResizeStart as (
|
||||
e: PointerEvent,
|
||||
dir: string
|
||||
) => void
|
||||
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
|
||||
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
|
||||
|
||||
resizeStart(makePointerEvent('pointerdown', captureEl, 100, 120), 'left')
|
||||
resizeMove(makePointerEvent('pointermove', captureEl, -100, 120))
|
||||
resizeEnd(makePointerEvent('pointerup', captureEl, -100, 120))
|
||||
|
||||
expect(vm.modelValue.x).toBe(0)
|
||||
expect(vm.modelValue.width).toBeGreaterThan(120)
|
||||
})
|
||||
|
||||
it('ignores resize move and end before resizing starts', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 400)
|
||||
vm.modelValue = { x: 40, y: 40, width: 120, height: 120 }
|
||||
const releaseEl = document.createElement('div')
|
||||
releaseEl.releasePointerCapture = vi.fn()
|
||||
|
||||
;(vm.handleResizeMove as (e: PointerEvent) => void)(
|
||||
makePointerEvent('pointermove', releaseEl, 360, 360)
|
||||
)
|
||||
;(vm.handleResizeEnd as (e: PointerEvent) => void)(
|
||||
makePointerEvent('pointerup', releaseEl, 360, 360)
|
||||
)
|
||||
|
||||
expect(vm.modelValue).toEqual({ x: 40, y: 40, width: 120, height: 120 })
|
||||
expect(releaseEl.releasePointerCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not start resizing when there is no image', async () => {
|
||||
mockGetNodeImageUrls.mockReturnValue(null)
|
||||
const vm = await mountHarness()
|
||||
const captureEl = document.createElement('div')
|
||||
captureEl.setPointerCapture = vi.fn()
|
||||
|
||||
;(vm.handleResizeStart as (e: PointerEvent, dir: string) => void)(
|
||||
makePointerEvent('pointerdown', captureEl, 20, 20),
|
||||
'right'
|
||||
)
|
||||
|
||||
expect(captureEl.setPointerCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies a preset aspect ratio and clamps height to the image', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 800, 500)
|
||||
@@ -524,6 +719,25 @@ describe('useImageCrop', () => {
|
||||
expect(vm.isLockEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores unknown aspect-ratio presets and unlocks explicit lock changes', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 400)
|
||||
vm.modelValue = { x: 0, y: 0, width: 160, height: 120 }
|
||||
|
||||
vm.selectedRatio = 'missing'
|
||||
expect(vm.selectedRatio).toBe('custom')
|
||||
expect(vm.isLockEnabled).toBe(false)
|
||||
|
||||
vm.isLockEnabled = true
|
||||
expect(vm.selectedRatio).toBe('4:3')
|
||||
|
||||
vm.isLockEnabled = true
|
||||
expect(vm.selectedRatio).toBe('4:3')
|
||||
|
||||
vm.isLockEnabled = false
|
||||
expect(vm.selectedRatio).toBe('custom')
|
||||
})
|
||||
|
||||
it('shows custom in the ratio label when lock does not match a preset', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 400)
|
||||
@@ -583,6 +797,58 @@ describe('useImageCrop', () => {
|
||||
expect(vm.modelValue.y + vm.modelValue.height).toBeLessThanOrEqual(400)
|
||||
})
|
||||
|
||||
it('clamps constrained north-west resize to the image top-left bounds', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 400)
|
||||
vm.modelValue = { x: 20, y: 20, width: 80, height: 80 }
|
||||
vm.isLockEnabled = true
|
||||
|
||||
const captureEl = document.createElement('div')
|
||||
captureEl.setPointerCapture = vi.fn()
|
||||
captureEl.releasePointerCapture = vi.fn()
|
||||
|
||||
const resizeStart = vm.handleResizeStart as (
|
||||
e: PointerEvent,
|
||||
dir: string
|
||||
) => void
|
||||
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
|
||||
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
|
||||
|
||||
resizeStart(makePointerEvent('pointerdown', captureEl, 40, 40), 'nw')
|
||||
resizeMove(makePointerEvent('pointermove', captureEl, -200, -200))
|
||||
resizeEnd(makePointerEvent('pointerup', captureEl, -200, -200))
|
||||
|
||||
expect(vm.modelValue.x).toBeGreaterThanOrEqual(0)
|
||||
expect(vm.modelValue.y).toBeGreaterThanOrEqual(0)
|
||||
expect(vm.modelValue.width).toBeGreaterThanOrEqual(16)
|
||||
expect(vm.modelValue.height).toBeGreaterThanOrEqual(16)
|
||||
})
|
||||
|
||||
it('clamps constrained corner resize to minimum dimensions', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 400)
|
||||
vm.modelValue = { x: 40, y: 40, width: 160, height: 80 }
|
||||
vm.isLockEnabled = true
|
||||
|
||||
const captureEl = document.createElement('div')
|
||||
captureEl.setPointerCapture = vi.fn()
|
||||
captureEl.releasePointerCapture = vi.fn()
|
||||
|
||||
const resizeStart = vm.handleResizeStart as (
|
||||
e: PointerEvent,
|
||||
dir: string
|
||||
) => void
|
||||
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
|
||||
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
|
||||
|
||||
resizeStart(makePointerEvent('pointerdown', captureEl, 200, 120), 'se')
|
||||
resizeMove(makePointerEvent('pointermove', captureEl, -800, -800))
|
||||
resizeEnd(makePointerEvent('pointerup', captureEl, -800, -800))
|
||||
|
||||
expect(vm.modelValue.width).toBe(32)
|
||||
expect(vm.modelValue.height).toBe(16)
|
||||
})
|
||||
|
||||
it('ends resize and clears direction on pointerup', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 400, 400)
|
||||
|
||||
134
src/composables/useIntersectionObserver.test.ts
Normal file
134
src/composables/useIntersectionObserver.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { createApp, h, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
type ObserverInit = ConstructorParameters<typeof IntersectionObserver>[1]
|
||||
type ObserverCallback = ConstructorParameters<typeof IntersectionObserver>[0]
|
||||
|
||||
const observers: MockIntersectionObserver[] = []
|
||||
|
||||
class MockIntersectionObserver {
|
||||
readonly callback: ObserverCallback
|
||||
readonly options?: ObserverInit
|
||||
readonly observe = vi.fn()
|
||||
readonly unobserve = vi.fn()
|
||||
readonly disconnect = vi.fn()
|
||||
|
||||
constructor(callback: ObserverCallback, options?: ObserverInit) {
|
||||
this.callback = callback
|
||||
this.options = options
|
||||
observers.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
function mountObserver(
|
||||
target: Ref<Element | null>,
|
||||
callback: IntersectionObserverCallback,
|
||||
options: Parameters<typeof useIntersectionObserver>[2] = {}
|
||||
) {
|
||||
let result: ReturnType<typeof useIntersectionObserver> | undefined
|
||||
const app = createApp({
|
||||
setup() {
|
||||
result = useIntersectionObserver(target, callback, options)
|
||||
return () => h('div')
|
||||
}
|
||||
})
|
||||
app.mount(document.createElement('div'))
|
||||
if (!result) throw new Error('useIntersectionObserver did not initialize')
|
||||
return {
|
||||
result,
|
||||
unmount: () => app.unmount()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
observers.length = 0
|
||||
Object.defineProperty(window, 'IntersectionObserver', {
|
||||
configurable: true,
|
||||
value: MockIntersectionObserver
|
||||
})
|
||||
})
|
||||
|
||||
describe('useIntersectionObserver', () => {
|
||||
it('observes the target immediately and updates intersection state', async () => {
|
||||
const target = ref<Element | null>(document.createElement('div'))
|
||||
const callback = vi.fn()
|
||||
|
||||
const { result, unmount } = mountObserver(target, callback, {
|
||||
threshold: 0.5
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(result.isSupported).toBe(true)
|
||||
expect(observers).toHaveLength(1)
|
||||
expect(observers[0].options).toMatchObject({ threshold: 0.5 })
|
||||
expect(observers[0].observe).toHaveBeenCalledWith(target.value)
|
||||
|
||||
observers[0].callback(
|
||||
[
|
||||
{ isIntersecting: false },
|
||||
{ isIntersecting: true }
|
||||
] as IntersectionObserverEntry[],
|
||||
observers[0] as unknown as IntersectionObserver
|
||||
)
|
||||
|
||||
expect(result.isIntersecting.value).toBe(true)
|
||||
expect(callback).toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
expect(observers[0].disconnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('supports manual observe, unobserve, and cleanup', () => {
|
||||
const target = ref<Element | null>(document.createElement('div'))
|
||||
const { result, unmount } = mountObserver(target, vi.fn(), {
|
||||
immediate: false
|
||||
})
|
||||
|
||||
expect(observers).toHaveLength(0)
|
||||
|
||||
result.observe()
|
||||
expect(observers).toHaveLength(1)
|
||||
expect(observers[0].observe).toHaveBeenCalledWith(target.value)
|
||||
|
||||
result.unobserve()
|
||||
expect(observers[0].unobserve).toHaveBeenCalledWith(target.value)
|
||||
|
||||
result.cleanup()
|
||||
expect(observers[0].disconnect).toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does nothing when unsupported or missing a target', () => {
|
||||
Reflect.deleteProperty(window, 'IntersectionObserver')
|
||||
const unsupported = mountObserver(
|
||||
ref(document.createElement('div')),
|
||||
vi.fn(),
|
||||
{
|
||||
immediate: false
|
||||
}
|
||||
)
|
||||
|
||||
unsupported.result.observe()
|
||||
|
||||
expect(unsupported.result.isSupported).toBe(false)
|
||||
expect(observers).toHaveLength(0)
|
||||
unsupported.unmount()
|
||||
|
||||
Object.defineProperty(window, 'IntersectionObserver', {
|
||||
configurable: true,
|
||||
value: MockIntersectionObserver
|
||||
})
|
||||
const missingTarget = mountObserver(ref<Element | null>(null), vi.fn(), {
|
||||
immediate: false
|
||||
})
|
||||
|
||||
missingTarget.result.observe()
|
||||
|
||||
expect(observers).toHaveLength(0)
|
||||
missingTarget.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive, ref, shallowRef } from 'vue'
|
||||
import type { Pinia } from 'pinia'
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { Size } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
@@ -21,6 +23,7 @@ import type { IWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import {
|
||||
createMockCanvasPointerEvent,
|
||||
createMockLGraphNode
|
||||
@@ -295,6 +298,19 @@ describe('useLoad3d', () => {
|
||||
expect(mockLoad3d.renderer!.domElement.hidden).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the renderer visible when collapsed flag is unset', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
delete mockNode.flags.collapsed
|
||||
mockLoad3d.renderer!.domElement.hidden = true
|
||||
mockNode.onDrawBackground?.({} as CanvasRenderingContext2D)
|
||||
|
||||
expect(mockLoad3d.renderer!.domElement.hidden).toBe(false)
|
||||
})
|
||||
|
||||
it('should initialize without loading model (model loading is handled by Load3DConfiguration)', async () => {
|
||||
mockNode.widgets!.push({
|
||||
name: 'model_file',
|
||||
@@ -331,6 +347,31 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses saved config fallbacks for empty render mode and missing light intensity', async () => {
|
||||
mockNode.properties!['Scene Config'] = {
|
||||
showGrid: true,
|
||||
backgroundColor: '#111111',
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: ''
|
||||
}
|
||||
mockNode.properties!['Light Config'] = {
|
||||
hdri: {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
}
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
expect(composable.sceneConfig.value.backgroundRenderMode).toBe('tiled')
|
||||
expect(composable.lightConfig.value.intensity).toBe(5)
|
||||
expect(mockLoad3d.setBackgroundRenderMode).toHaveBeenCalledWith('tiled')
|
||||
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(5)
|
||||
})
|
||||
|
||||
it('should set preview mode when no width/height widgets', async () => {
|
||||
mockNode.widgets = []
|
||||
|
||||
@@ -397,6 +438,127 @@ describe('useLoad3d', () => {
|
||||
expect.objectContaining({ getZoomScale: expect.any(Function) })
|
||||
)
|
||||
})
|
||||
|
||||
it('passes live dimension, zoom, and context-menu callbacks to Load3d', async () => {
|
||||
const originalCanvas = app.canvas
|
||||
const menuOptions = [{ content: 'Inspect' }]
|
||||
const getNodeMenuOptions = vi.fn(() => menuOptions)
|
||||
app.canvas = {
|
||||
ds: { scale: 2.25 },
|
||||
getNodeMenuOptions
|
||||
} as unknown as typeof app.canvas
|
||||
const contextMenuSpy = vi
|
||||
.spyOn(LiteGraph, 'ContextMenu')
|
||||
.mockImplementation(function () {})
|
||||
|
||||
try {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
const config = vi.mocked(createLoad3d).mock.calls[0][1]!
|
||||
expect(config.getDimensions?.()).toEqual({ width: 512, height: 512 })
|
||||
mockNode.widgets![0].value = 640
|
||||
mockNode.widgets![1].value = 360
|
||||
expect(config.getDimensions?.()).toEqual({ width: 640, height: 360 })
|
||||
expect(config.getZoomScale!()).toBe(2.25)
|
||||
|
||||
const event = new MouseEvent('contextmenu')
|
||||
config.onContextMenu!(event)
|
||||
|
||||
expect(getNodeMenuOptions).toHaveBeenCalledWith(mockNode)
|
||||
expect(contextMenuSpy).toHaveBeenCalledWith(menuOptions, {
|
||||
event,
|
||||
title: mockNode.type,
|
||||
extra: mockNode
|
||||
})
|
||||
} finally {
|
||||
app.canvas = originalCanvas
|
||||
contextMenuSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to zoom scale 1 when the app canvas is unavailable', async () => {
|
||||
const originalCanvas = app.canvas
|
||||
app.canvas = undefined as unknown as typeof app.canvas
|
||||
|
||||
try {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
const config = vi.mocked(createLoad3d).mock.calls[0][1]!
|
||||
expect(config.getZoomScale!()).toBe(1)
|
||||
} finally {
|
||||
app.canvas = originalCanvas
|
||||
}
|
||||
})
|
||||
|
||||
it('restores and enables a saved HDRI when it loads successfully', async () => {
|
||||
mockNode.properties!['Light Config'] = {
|
||||
intensity: 7,
|
||||
hdri: {
|
||||
enabled: true,
|
||||
hdriPath: '3d/env.hdr',
|
||||
showAsBackground: true,
|
||||
intensity: 2
|
||||
}
|
||||
}
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['3d', 'env.hdr'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/view?filename=env.hdr'
|
||||
)
|
||||
vi.mocked(api.apiURL).mockReturnValue(
|
||||
'http://localhost/view?filename=env.hdr'
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
expect(mockLoad3d.loadHDRI).toHaveBeenCalledWith(
|
||||
'http://localhost/view?filename=env.hdr'
|
||||
)
|
||||
expect(composable.lightConfig.value.hdri!.enabled).toBe(true)
|
||||
expect(mockLoad3d.setHDRIEnabled).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('clears saved HDRI state when restore fails', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockNode.properties!['Light Config'] = {
|
||||
intensity: 7,
|
||||
hdri: {
|
||||
enabled: true,
|
||||
hdriPath: '3d/missing.hdr',
|
||||
showAsBackground: true,
|
||||
intensity: 2
|
||||
}
|
||||
}
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'3d',
|
||||
'missing.hdr'
|
||||
])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/view?filename=missing.hdr'
|
||||
)
|
||||
vi.mocked(api.apiURL).mockReturnValue(
|
||||
'http://localhost/view?filename=missing.hdr'
|
||||
)
|
||||
vi.mocked(mockLoad3d.loadHDRI!).mockRejectedValueOnce(
|
||||
new Error('missing')
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Failed to restore HDRI:',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
|
||||
expect(composable.lightConfig.value.hdri!.enabled).toBe(false)
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('zoom watcher', () => {
|
||||
@@ -526,6 +688,31 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
|
||||
describe('configuration watchers', () => {
|
||||
it('updates refs before Load3d exists without calling scene methods', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
composable.sceneConfig.value.backgroundColor = '#abcdef'
|
||||
composable.sceneConfig.value.backgroundImage = 'pending.png'
|
||||
composable.sceneConfig.value.backgroundRenderMode = 'panorama'
|
||||
composable.modelConfig.value.upDirection = '+z'
|
||||
composable.modelConfig.value.materialMode = 'normal'
|
||||
composable.modelConfig.value.showSkeleton = true
|
||||
composable.cameraConfig.value.fov = 55
|
||||
composable.lightConfig.value.intensity = 9
|
||||
composable.lightConfig.value.hdri!.intensity = 3
|
||||
composable.lightConfig.value.hdri!.showAsBackground = true
|
||||
composable.lightConfig.value.hdri!.enabled = true
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setBackgroundColor).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setBackgroundImage).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setBackgroundRenderMode).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setUpDirection).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setMaterialMode).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setShowSkeleton).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setLightIntensity).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update scene config when values change', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
@@ -567,20 +754,23 @@ describe('useLoad3d', () => {
|
||||
|
||||
vi.mocked(mockLoad3d.setUpDirection!).mockClear()
|
||||
vi.mocked(mockLoad3d.setMaterialMode!).mockClear()
|
||||
vi.mocked(mockLoad3d.setShowSkeleton!).mockClear()
|
||||
|
||||
composable.modelConfig.value.upDirection = '+y'
|
||||
composable.modelConfig.value.materialMode = 'wireframe'
|
||||
composable.modelConfig.value.showSkeleton = true
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y')
|
||||
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
|
||||
expect(mockLoad3d.setShowSkeleton).toHaveBeenCalledWith(true)
|
||||
const savedModelConfig = mockNode.properties['Model Config'] as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(savedModelConfig.upDirection).toBe('+y')
|
||||
expect(savedModelConfig.materialMode).toBe('wireframe')
|
||||
expect(savedModelConfig.showSkeleton).toBe(false)
|
||||
expect(savedModelConfig.showSkeleton).toBe(true)
|
||||
})
|
||||
|
||||
it('should update camera config when values change', async () => {
|
||||
@@ -906,6 +1096,68 @@ describe('useLoad3d', () => {
|
||||
expect(composable.modelConfig.value.materialMode).toBe('wireframe')
|
||||
})
|
||||
|
||||
it('applies scalar Load3d events to config refs', async () => {
|
||||
const handlers: Record<string, (value: unknown) => void> = {}
|
||||
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
|
||||
(event: string, handler: unknown) => {
|
||||
handlers[event] = handler as (value: unknown) => void
|
||||
}
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
handlers.backgroundColorChange('#123456')
|
||||
handlers.backgroundRenderModeChange('panorama')
|
||||
handlers.lightIntensityChange(8)
|
||||
handlers.fovChange(60)
|
||||
handlers.cameraTypeChange('orthographic')
|
||||
handlers.showGridChange(false)
|
||||
handlers.upDirectionChange('-z')
|
||||
handlers.backgroundImageChange('scene.jpg')
|
||||
|
||||
expect(composable.sceneConfig.value).toMatchObject({
|
||||
backgroundColor: '#123456',
|
||||
backgroundRenderMode: 'panorama',
|
||||
showGrid: false,
|
||||
backgroundImage: 'scene.jpg'
|
||||
})
|
||||
expect(composable.lightConfig.value.intensity).toBe(8)
|
||||
expect(composable.cameraConfig.value).toMatchObject({
|
||||
fov: 60,
|
||||
cameraType: 'orthographic'
|
||||
})
|
||||
expect(composable.modelConfig.value.upDirection).toBe('-z')
|
||||
})
|
||||
|
||||
it('should handle background image loading events', async () => {
|
||||
let startHandler: (() => void) | undefined
|
||||
let endHandler: (() => void) | undefined
|
||||
|
||||
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
|
||||
(event: string, handler: unknown) => {
|
||||
if (event === 'backgroundImageLoadingStart') {
|
||||
startHandler = handler as () => void
|
||||
} else if (event === 'backgroundImageLoadingEnd') {
|
||||
endHandler = handler as () => void
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
startHandler?.()
|
||||
expect(composable.loading.value).toBe(true)
|
||||
expect(composable.loadingMessage.value).toBe(
|
||||
'load3d.loadingBackgroundImage'
|
||||
)
|
||||
|
||||
endHandler?.()
|
||||
expect(composable.loading.value).toBe(false)
|
||||
expect(composable.loadingMessage.value).toBe('')
|
||||
})
|
||||
|
||||
it('should handle loading events', async () => {
|
||||
let modelLoadingStartHandler: (() => void) | undefined
|
||||
let modelLoadingEndHandler: (() => void) | undefined
|
||||
@@ -934,6 +1186,70 @@ describe('useLoad3d', () => {
|
||||
expect(composable.loadingMessage.value).toBe('')
|
||||
})
|
||||
|
||||
it('handles stale modelLoadingEnd after cleanup with fallback model state', async () => {
|
||||
let modelLoadingEndHandler: (() => void) | undefined
|
||||
|
||||
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
|
||||
(event: string, handler: unknown) => {
|
||||
if (event === 'modelLoadingEnd') {
|
||||
modelLoadingEndHandler = handler as () => void
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
vi.mocked(mockLoad3d.isSplatModel!).mockReturnValue(true)
|
||||
vi.mocked(mockLoad3d.isPlyModel!).mockReturnValue(true)
|
||||
vi.mocked(mockLoad3d.getSourceFormat!).mockReturnValue('splat')
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
composable.cleanup()
|
||||
|
||||
modelLoadingEndHandler?.()
|
||||
|
||||
expect(composable.isSplatModel.value).toBe(false)
|
||||
expect(composable.isPlyModel.value).toBe(false)
|
||||
expect(composable.sourceFormat.value).toBeNull()
|
||||
expect(composable.canFitToViewer.value).toBe(true)
|
||||
expect(composable.canUseGizmo.value).toBe(true)
|
||||
expect(composable.canUseLighting.value).toBe(true)
|
||||
expect(composable.canExport.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses fallback model capabilities when Load3d returns none', async () => {
|
||||
let modelLoadingEndHandler: (() => void) | undefined
|
||||
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
|
||||
(event: string, handler: unknown) => {
|
||||
if (event === 'modelLoadingEnd') {
|
||||
modelLoadingEndHandler = handler as () => void
|
||||
}
|
||||
}
|
||||
)
|
||||
vi.mocked(mockLoad3d.isSplatModel!).mockReturnValue(true)
|
||||
vi.mocked(mockLoad3d.getSourceFormat!).mockReturnValue('splat')
|
||||
vi.mocked(mockLoad3d.getCurrentModelCapabilities!).mockReturnValue(
|
||||
fromAny(undefined)
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
modelLoadingEndHandler?.()
|
||||
|
||||
expect(composable.isSplatModel.value).toBe(true)
|
||||
expect(composable.sourceFormat.value).toBe('splat')
|
||||
expect(composable.canCenterCameraOnModel.value).toBe(true)
|
||||
expect(composable.canFitToViewer.value).toBe(true)
|
||||
expect(composable.canUseGizmo.value).toBe(true)
|
||||
expect(composable.canUseLighting.value).toBe(true)
|
||||
expect(composable.canExport.value).toBe(true)
|
||||
expect(composable.materialModes.value).toEqual([
|
||||
'original',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle recordingStatusChange event', async () => {
|
||||
let recordingStatusHandler: ((status: boolean) => void) | undefined
|
||||
|
||||
@@ -956,6 +1272,70 @@ describe('useLoad3d', () => {
|
||||
expect(composable.recordingDuration.value).toBe(10)
|
||||
expect(composable.hasRecording.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle recordingStatusChange true without recording duration update', async () => {
|
||||
let recordingStatusHandler: ((status: boolean) => void) | undefined
|
||||
|
||||
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
|
||||
(event: string, handler: unknown) => {
|
||||
if (event === 'recordingStatusChange') {
|
||||
recordingStatusHandler = handler as (status: boolean) => void
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
recordingStatusHandler?.(true)
|
||||
|
||||
expect(composable.isRecording.value).toBe(true)
|
||||
expect(mockLoad3d.getRecordingDuration).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use the default export loading message when none is provided', async () => {
|
||||
let exportLoadingStartHandler: ((message: string) => void) | undefined
|
||||
|
||||
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
|
||||
(event: string, handler: unknown) => {
|
||||
if (event === 'exportLoadingStart') {
|
||||
exportLoadingStartHandler = handler as (message: string) => void
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
exportLoadingStartHandler?.('')
|
||||
|
||||
expect(composable.loading.value).toBe(true)
|
||||
expect(composable.loadingMessage.value).toBe('load3d.exportingModel')
|
||||
})
|
||||
|
||||
it('handles export completion and animation list events', async () => {
|
||||
const handlers: Record<string, (value?: unknown) => void> = {}
|
||||
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
|
||||
(event: string, handler: unknown) => {
|
||||
handlers[event] = handler as (value?: unknown) => void
|
||||
}
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
handlers.exportLoadingStart('Exporting GLB')
|
||||
expect(composable.loading.value).toBe(true)
|
||||
expect(composable.loadingMessage.value).toBe('Exporting GLB')
|
||||
|
||||
handlers.exportLoadingEnd()
|
||||
expect(composable.loading.value).toBe(false)
|
||||
expect(composable.loadingMessage.value).toBe('')
|
||||
|
||||
const animations = [{ name: 'Idle', index: 0 }]
|
||||
handlers.animationListChange(animations)
|
||||
expect(composable.animations.value).toEqual(animations)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
@@ -977,6 +1357,19 @@ describe('useLoad3d', () => {
|
||||
|
||||
expect(() => composable.cleanup()).not.toThrow()
|
||||
})
|
||||
|
||||
it('stops cleanup early when the node ref is already cleared', async () => {
|
||||
const nodeRef = shallowRef<LGraphNode | null>(mockNode)
|
||||
const composable = useLoad3d(nodeRef)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
nodeRef.value = null
|
||||
composable.cleanup()
|
||||
|
||||
expect(mockLoad3d.removeEventListener).toHaveBeenCalled()
|
||||
expect(mockLoad3d.remove).not.toHaveBeenCalled()
|
||||
expect(nodeToLoad3dMap.has(mockNode)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleModelDrop', () => {
|
||||
@@ -1008,6 +1401,62 @@ describe('useLoad3d', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('updates model_file widget values after a successful model drop', async () => {
|
||||
const modelWidget = {
|
||||
name: 'model_file',
|
||||
value: '',
|
||||
options: { values: ['existing.glb'] }
|
||||
} as unknown as IWidget
|
||||
mockNode.widgets!.push(modelWidget)
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'uploaded',
|
||||
'model.glb'
|
||||
])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/api/view/uploaded/model.glb'
|
||||
)
|
||||
vi.mocked(api.apiURL).mockReturnValue(
|
||||
'http://localhost/api/view/uploaded/model.glb'
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
await composable.handleModelDrop(
|
||||
new File([''], 'model.glb', { type: 'model/gltf-binary' })
|
||||
)
|
||||
|
||||
expect(modelWidget.value).toBe('uploaded/model.glb')
|
||||
expect(modelWidget.options.values).toEqual([
|
||||
'existing.glb',
|
||||
'uploaded/model.glb'
|
||||
])
|
||||
})
|
||||
|
||||
it('uses output resource URLs when dropping a model into preview mode', async () => {
|
||||
mockNode.widgets = []
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'uploaded',
|
||||
'model.glb'
|
||||
])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/api/view/uploaded/model.glb'
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
await composable.handleModelDrop(
|
||||
new File([''], 'model.glb', { type: 'model/gltf-binary' })
|
||||
)
|
||||
|
||||
expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
|
||||
'uploaded',
|
||||
'model.glb',
|
||||
'output'
|
||||
)
|
||||
})
|
||||
|
||||
it('should use resource folder for upload subfolder', async () => {
|
||||
mockNode.properties['Resource Folder'] = 'subfolder'
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
|
||||
@@ -1047,6 +1496,69 @@ describe('useLoad3d', () => {
|
||||
'toastMessages.no3dScene'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns before upload when the node ref is cleared after initialization', async () => {
|
||||
const nodeRef = shallowRef<LGraphNode | null>(mockNode)
|
||||
const composable = useLoad3d(nodeRef)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
nodeRef.value = null
|
||||
|
||||
await composable.handleModelDrop(
|
||||
new File([''], 'model.glb', { type: 'model/gltf-binary' })
|
||||
)
|
||||
|
||||
expect(Load3dUtils.uploadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an upload failure alert when model upload returns no path', async () => {
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('')
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
await composable.handleModelDrop(
|
||||
new File([''], 'model.glb', { type: 'model/gltf-binary' })
|
||||
)
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.fileUploadFailed'
|
||||
)
|
||||
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
|
||||
expect(composable.loading.value).toBe(false)
|
||||
expect(composable.loadingMessage.value).toBe('')
|
||||
})
|
||||
|
||||
it('shows a load failure alert when model loading throws', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'uploaded',
|
||||
'model.glb'
|
||||
])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/api/view/uploaded/model.glb'
|
||||
)
|
||||
vi.mocked(mockLoad3d.loadModel!).mockRejectedValueOnce(
|
||||
new Error('bad model')
|
||||
)
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
await composable.handleModelDrop(
|
||||
new File([''], 'model.glb', { type: 'model/gltf-binary' })
|
||||
)
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Model drop failed:',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToLoadModel'
|
||||
)
|
||||
expect(composable.loading.value).toBe(false)
|
||||
expect(composable.loadingMessage.value).toBe('')
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hdri controls', () => {
|
||||
@@ -1092,6 +1604,40 @@ describe('useLoad3d', () => {
|
||||
expect(mockLoad3d.setHDRIIntensity).toHaveBeenCalledWith(2.5)
|
||||
})
|
||||
|
||||
it('restores non-HDRI light intensity when HDRI is disabled', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.lightConfig.value.intensity = 8
|
||||
await nextTick()
|
||||
composable.lightConfig.value = {
|
||||
...composable.lightConfig.value,
|
||||
hdri: { ...composable.lightConfig.value.hdri!, enabled: true }
|
||||
}
|
||||
await nextTick()
|
||||
composable.lightConfig.value.intensity = 1
|
||||
await nextTick()
|
||||
composable.lightConfig.value = {
|
||||
...composable.lightConfig.value,
|
||||
hdri: { ...composable.lightConfig.value.hdri!, enabled: false }
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(composable.lightConfig.value.intensity).toBe(8)
|
||||
expect(mockLoad3d.setHDRIEnabled).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
|
||||
it('should ignore HDRI updates before Load3d is initialized', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
await composable.handleHDRIFileUpdate(
|
||||
new File([''], 'env.hdr', { type: 'image/x-hdr' })
|
||||
)
|
||||
|
||||
expect(Load3dUtils.uploadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should upload file, load HDRI and update hdriConfig', async () => {
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('3d/env.hdr')
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['3d', 'env.hdr'])
|
||||
@@ -1117,6 +1663,100 @@ describe('useLoad3d', () => {
|
||||
expect(composable.lightConfig.value.hdri!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('skips HDRI loading when the Load3d instance changes after upload', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
vi.mocked(Load3dUtils.uploadFile).mockImplementation(async () => {
|
||||
composable.cleanup()
|
||||
return '3d/env.hdr'
|
||||
})
|
||||
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
await composable.handleHDRIFileUpdate(
|
||||
new File([''], 'env.hdr', { type: 'image/x-hdr' })
|
||||
)
|
||||
|
||||
expect(mockLoad3d.loadHDRI).not.toHaveBeenCalled()
|
||||
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
|
||||
})
|
||||
|
||||
it('skips HDRI state updates when the Load3d instance changes after load', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('3d/env.hdr')
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['3d', 'env.hdr'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/view?filename=env.hdr'
|
||||
)
|
||||
vi.mocked(api.apiURL).mockReturnValue(
|
||||
'http://localhost/view?filename=env.hdr'
|
||||
)
|
||||
vi.mocked(mockLoad3d.loadHDRI!).mockImplementation(async () => {
|
||||
composable.cleanup()
|
||||
})
|
||||
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
await composable.handleHDRIFileUpdate(
|
||||
new File([''], 'env.hdr', { type: 'image/x-hdr' })
|
||||
)
|
||||
|
||||
expect(mockLoad3d.loadHDRI).toHaveBeenCalled()
|
||||
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
|
||||
expect(composable.loading.value).toBe(false)
|
||||
expect(composable.loadingMessage.value).toBe('')
|
||||
})
|
||||
|
||||
it('should leave HDRI state unchanged when upload returns no path', async () => {
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('')
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
await composable.handleHDRIFileUpdate(
|
||||
new File([''], 'env.hdr', { type: 'image/x-hdr' })
|
||||
)
|
||||
|
||||
expect(mockLoad3d.loadHDRI).not.toHaveBeenCalled()
|
||||
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
|
||||
expect(composable.loading.value).toBe(false)
|
||||
expect(composable.loadingMessage.value).toBe('')
|
||||
})
|
||||
|
||||
it('should clear HDRI and show an alert when HDRI loading fails', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('3d/bad.hdr')
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['3d', 'bad.hdr'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/view?filename=bad.hdr'
|
||||
)
|
||||
vi.mocked(api.apiURL).mockReturnValue(
|
||||
'http://localhost/view?filename=bad.hdr'
|
||||
)
|
||||
vi.mocked(mockLoad3d.loadHDRI!).mockRejectedValueOnce(
|
||||
new Error('bad hdri')
|
||||
)
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
await composable.handleHDRIFileUpdate(
|
||||
new File([''], 'bad.hdr', { type: 'image/x-hdr' })
|
||||
)
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Failed to load HDRI:',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(mockLoad3d.clearHDRI).toHaveBeenCalled()
|
||||
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
|
||||
expect(composable.lightConfig.value.hdri!.enabled).toBe(false)
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToLoadHDRI'
|
||||
)
|
||||
expect(composable.loading.value).toBe(false)
|
||||
expect(composable.loadingMessage.value).toBe('')
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should clear HDRI when file is null', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
@@ -1180,6 +1820,23 @@ describe('useLoad3d', () => {
|
||||
|
||||
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('existing.jpg')
|
||||
})
|
||||
|
||||
it('skips background color updates while HDRI is enabled', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
composable.lightConfig.value = {
|
||||
...composable.lightConfig.value,
|
||||
hdri: { ...composable.lightConfig.value.hdri!, enabled: true }
|
||||
}
|
||||
await nextTick()
|
||||
vi.mocked(mockLoad3d.setBackgroundColor!).mockClear()
|
||||
|
||||
composable.sceneConfig.value.backgroundColor = '#ffffff'
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setBackgroundColor).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('gizmo controls', () => {
|
||||
@@ -1219,6 +1876,37 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('applies restored gizmo config after model loading ends', async () => {
|
||||
;(mockNode.properties!['Model Config'] as Record<string, unknown>).gizmo =
|
||||
{
|
||||
enabled: true,
|
||||
mode: 'rotate',
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
rotation: { x: 0.1, y: 0.2, z: 0.3 },
|
||||
scale: { x: 2, y: 2, z: 2 }
|
||||
}
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
vi.mocked(mockLoad3d.applyGizmoTransform!).mockClear()
|
||||
vi.mocked(mockLoad3d.setGizmoEnabled!).mockClear()
|
||||
vi.mocked(mockLoad3d.setGizmoMode!).mockClear()
|
||||
|
||||
const loadingEndCall = vi
|
||||
.mocked(mockLoad3d.addEventListener!)
|
||||
.mock.calls.find(([event]) => event === 'modelLoadingEnd')
|
||||
const loadingEndHandler = loadingEndCall![1] as () => void
|
||||
loadingEndHandler()
|
||||
|
||||
expect(mockLoad3d.applyGizmoTransform).toHaveBeenCalledWith(
|
||||
{ x: 1, y: 2, z: 3 },
|
||||
{ x: 0.1, y: 0.2, z: 0.3 },
|
||||
{ x: 2, y: 2, z: 2 }
|
||||
)
|
||||
expect(mockLoad3d.setGizmoEnabled).toHaveBeenCalledWith(true)
|
||||
expect(mockLoad3d.setGizmoMode).toHaveBeenCalledWith('rotate')
|
||||
})
|
||||
|
||||
it('should add default gizmo config when missing from saved config', async () => {
|
||||
mockNode.properties!['Model Config'] = {
|
||||
upDirection: 'original',
|
||||
@@ -1581,6 +2269,29 @@ describe('useLoad3d', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('captures thumbnail from an image widget when no model_file widget exists', async () => {
|
||||
const { isAssetPreviewSupported, persistThumbnail } =
|
||||
await import('@/platform/assets/utils/assetPreviewUtil')
|
||||
vi.mocked(isAssetPreviewSupported).mockReturnValue(true)
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'',
|
||||
'preview.png'
|
||||
] as unknown as ReturnType<typeof Load3dUtils.splitFilePath>)
|
||||
mockNode.widgets = [
|
||||
{ name: 'image', value: 'preview.png' } as unknown as IWidget
|
||||
]
|
||||
|
||||
const { handler } = await getModelReadyHandler()
|
||||
handler()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(mockLoad3d.captureThumbnail).toHaveBeenCalledWith(256, 256)
|
||||
expect(persistThumbnail).toHaveBeenCalledWith(
|
||||
'preview.png',
|
||||
expect.any(Blob)
|
||||
)
|
||||
})
|
||||
|
||||
it('skips persistence when the model widget has no value', async () => {
|
||||
const { isAssetPreviewSupported, persistThumbnail } =
|
||||
await import('@/platform/assets/utils/assetPreviewUtil')
|
||||
@@ -1839,6 +2550,29 @@ describe('useLoad3d', () => {
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('cameraChanged creates camera properties when the node has none', async () => {
|
||||
let cameraChangedHandler: ((state: unknown) => void) | undefined
|
||||
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
|
||||
(event: string, handler: unknown) => {
|
||||
if (event === 'cameraChanged') {
|
||||
cameraChangedHandler = handler as (state: unknown) => void
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
mockNode.properties = undefined as unknown as LGraphNode['properties']
|
||||
|
||||
cameraChangedHandler!({ position: { x: 1, y: 2, z: 3 } })
|
||||
|
||||
expect(mockNode.properties['Camera Config']).toEqual({
|
||||
cameraType: 'perspective',
|
||||
fov: 75,
|
||||
state: { position: { x: 1, y: 2, z: 3 } }
|
||||
})
|
||||
})
|
||||
|
||||
it('handleStopRecording marks dirty when a recording was produced', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
@@ -1909,6 +2643,27 @@ describe('useLoad3d', () => {
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('handleCenterCameraOnModel is a no-op before initialization', () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
composable.handleCenterCameraOnModel()
|
||||
|
||||
expect(mockLoad3d.centerCameraOnModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleSeek is a no-op before animation duration is known', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
setLoad3dOutputCache(mockNode, fakeCache)
|
||||
composable.handleSeek(50)
|
||||
|
||||
expect(mockLoad3d.setAnimationTime).not.toHaveBeenCalled()
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
|
||||
})
|
||||
|
||||
it('handleSeek marks dirty when the animation has a duration', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
@@ -128,6 +129,9 @@ describe('useLoad3dViewer', () => {
|
||||
setCameraState: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
hasAnimations: vi.fn().mockReturnValue(false),
|
||||
toggleAnimation: vi.fn(),
|
||||
setAnimationSpeed: vi.fn(),
|
||||
updateSelectedAnimation: vi.fn(),
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
getSourceFormat: vi.fn().mockReturnValue(null),
|
||||
@@ -147,7 +151,12 @@ describe('useLoad3dViewer', () => {
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
})
|
||||
}),
|
||||
setAnimationTime: vi.fn(),
|
||||
getAnimationDuration: vi.fn().mockReturnValue(12),
|
||||
animationManager: {
|
||||
animationClips: []
|
||||
} as Partial<Load3d['animationManager']> as Load3d['animationManager']
|
||||
}
|
||||
|
||||
mockSourceLoad3d = {
|
||||
@@ -169,6 +178,18 @@ describe('useLoad3dViewer', () => {
|
||||
currentUpDirection: 'original',
|
||||
materialMode: 'original'
|
||||
} as Load3d['modelManager'],
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
getSourceFormat: vi.fn().mockReturnValue(null),
|
||||
getCurrentModelCapabilities: vi.fn().mockReturnValue({
|
||||
fitToViewer: true,
|
||||
requiresMaterialRebuild: false,
|
||||
gizmoTransform: true,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'normal', 'wireframe'],
|
||||
fitTargetSize: 5
|
||||
}),
|
||||
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
|
||||
setBackgroundRenderMode: vi.fn(),
|
||||
forceRender: vi.fn()
|
||||
@@ -248,6 +269,91 @@ describe('useLoad3dViewer', () => {
|
||||
expect(viewer.hasBackgroundImage.value).toBe(true)
|
||||
})
|
||||
|
||||
it('passes target dimensions when width and height widgets are present', async () => {
|
||||
mockNode.widgets = [
|
||||
{ name: 'width', value: 640 },
|
||||
{ name: 'height', value: 360 }
|
||||
] as LGraphNode['widgets']
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
expect(createLoad3d).toHaveBeenCalledWith(
|
||||
containerRef,
|
||||
expect.objectContaining({
|
||||
width: 640,
|
||||
height: 360,
|
||||
isViewerMode: true,
|
||||
getDimensions: expect.any(Function)
|
||||
})
|
||||
)
|
||||
const config = vi.mocked(createLoad3d).mock.calls[0][1]!
|
||||
mockNode.widgets![0].value = 800
|
||||
mockNode.widgets![1].value = 450
|
||||
expect(config.getDimensions?.()).toEqual({ width: 800, height: 450 })
|
||||
})
|
||||
|
||||
it('falls back to source values when saved configs are empty', async () => {
|
||||
mockNode.properties!['Scene Config'] = {
|
||||
backgroundColor: '',
|
||||
showGrid: undefined,
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: ''
|
||||
}
|
||||
mockNode.properties!['Camera Config'] = {
|
||||
cameraType: undefined,
|
||||
fov: undefined
|
||||
}
|
||||
mockNode.properties!['Model Config'] = {
|
||||
upDirection: undefined,
|
||||
materialMode: undefined
|
||||
}
|
||||
mockSourceLoad3d.sceneManager!.backgroundRenderMode = 'panorama'
|
||||
mockSourceLoad3d.sceneManager!.gridHelper.visible = false
|
||||
mockSourceLoad3d.sceneManager!.currentBackgroundColor = '#111111'
|
||||
vi.mocked(mockSourceLoad3d.getCurrentCameraType!).mockReturnValue(
|
||||
'orthographic'
|
||||
)
|
||||
mockSourceLoad3d.cameraManager!.perspectiveCamera.fov = 35
|
||||
mockSourceLoad3d.modelManager!.currentUpDirection = '+y'
|
||||
mockSourceLoad3d.modelManager!.materialMode = 'normal'
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
|
||||
expect(viewer.backgroundColor.value).toBe('#111111')
|
||||
expect(viewer.showGrid.value).toBe(false)
|
||||
expect(viewer.backgroundRenderMode.value).toBe('panorama')
|
||||
expect(viewer.cameraType.value).toBe('orthographic')
|
||||
expect(viewer.fov.value).toBe(35)
|
||||
expect(viewer.upDirection.value).toBe('+y')
|
||||
expect(viewer.materialMode.value).toBe('normal')
|
||||
})
|
||||
|
||||
it('initializes animation state from existing clips', async () => {
|
||||
vi.mocked(mockLoad3d.hasAnimations!).mockReturnValue(true)
|
||||
mockLoad3d.animationManager = {
|
||||
animationClips: [{ name: 'Walk' }, { name: '' }]
|
||||
} as Partial<Load3d['animationManager']> as Load3d['animationManager']
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
|
||||
expect(viewer.animations.value).toEqual([
|
||||
{ name: 'Walk', index: 0 },
|
||||
{ name: 'Animation 2', index: 1 }
|
||||
])
|
||||
expect(viewer.animationDuration.value).toBe(12)
|
||||
})
|
||||
|
||||
it('should handle initialization errors', async () => {
|
||||
vi.mocked(createLoad3d).mockImplementationOnce(() => {
|
||||
throw new Error('Load3d creation failed')
|
||||
@@ -265,6 +371,41 @@ describe('useLoad3dViewer', () => {
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('ignores watcher changes before Load3d is initialized', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
|
||||
viewer.backgroundColor.value = '#ff0000'
|
||||
viewer.showGrid.value = false
|
||||
viewer.cameraType.value = 'orthographic'
|
||||
viewer.fov.value = 60
|
||||
viewer.lightIntensity.value = 2
|
||||
viewer.backgroundImage.value = 'bg.jpg'
|
||||
viewer.backgroundRenderMode.value = 'panorama'
|
||||
viewer.upDirection.value = '+y'
|
||||
viewer.materialMode.value = 'normal'
|
||||
viewer.playing.value = true
|
||||
viewer.selectedSpeed.value = 2
|
||||
viewer.selectedAnimation.value = 1
|
||||
viewer.gizmoEnabled.value = true
|
||||
viewer.gizmoMode.value = 'rotate'
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setBackgroundColor).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.toggleGrid).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.toggleCamera).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setFOV).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setLightIntensity).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setBackgroundImage).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setBackgroundRenderMode).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setUpDirection).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setMaterialMode).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.toggleAnimation).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setAnimationSpeed).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.updateSelectedAnimation).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setGizmoEnabled).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setGizmoMode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle watcher errors gracefully', async () => {
|
||||
vi.mocked(mockLoad3d.setBackgroundColor!).mockImplementationOnce(
|
||||
function () {
|
||||
@@ -284,6 +425,82 @@ describe('useLoad3dViewer', () => {
|
||||
'toastMessages.failedToUpdateBackgroundColor'
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces watcher errors for each viewer control', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
vi.mocked(mockLoad3d.toggleGrid!).mockImplementation(() => {
|
||||
throw new Error('grid failed')
|
||||
})
|
||||
vi.mocked(mockLoad3d.toggleCamera!).mockImplementation(() => {
|
||||
throw new Error('camera failed')
|
||||
})
|
||||
vi.mocked(mockLoad3d.setFOV!).mockImplementation(() => {
|
||||
throw new Error('fov failed')
|
||||
})
|
||||
vi.mocked(mockLoad3d.setLightIntensity!).mockImplementation(() => {
|
||||
throw new Error('light failed')
|
||||
})
|
||||
vi.mocked(mockLoad3d.setBackgroundImage!).mockRejectedValue(
|
||||
new Error('background failed')
|
||||
)
|
||||
vi.mocked(mockLoad3d.setBackgroundRenderMode!).mockImplementation(() => {
|
||||
throw new Error('render failed')
|
||||
})
|
||||
vi.mocked(mockLoad3d.setUpDirection!).mockImplementation(() => {
|
||||
throw new Error('up failed')
|
||||
})
|
||||
vi.mocked(mockLoad3d.setMaterialMode!).mockImplementation(() => {
|
||||
throw new Error('material failed')
|
||||
})
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
|
||||
viewer.showGrid.value = false
|
||||
await nextTick()
|
||||
viewer.showGrid.value = true
|
||||
viewer.cameraType.value = 'orthographic'
|
||||
viewer.fov.value = 60
|
||||
viewer.lightIntensity.value = 2
|
||||
viewer.backgroundImage.value = 'bg.jpg'
|
||||
viewer.backgroundRenderMode.value = 'panorama'
|
||||
viewer.upDirection.value = '+y'
|
||||
viewer.materialMode.value = 'normal'
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToToggleGrid'
|
||||
)
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToToggleCamera'
|
||||
)
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToUpdateFOV'
|
||||
)
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToUpdateLightIntensity'
|
||||
)
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToUpdateBackgroundImage'
|
||||
)
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToUpdateBackgroundRenderMode'
|
||||
)
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToUpdateUpDirection'
|
||||
)
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToUpdateMaterialMode'
|
||||
)
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportModel', () => {
|
||||
@@ -368,6 +585,42 @@ describe('useLoad3dViewer', () => {
|
||||
|
||||
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('handles animation controls and seek after progress is known', async () => {
|
||||
const eventHandlers: Record<string, (value: unknown) => void> = {}
|
||||
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
|
||||
(event: string, handler: unknown) => {
|
||||
eventHandlers[event] = handler as (value: unknown) => void
|
||||
}
|
||||
)
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
|
||||
eventHandlers.animationListChange([{ name: 'Spin', index: 0 }])
|
||||
eventHandlers.animationProgressChange({
|
||||
progress: 25,
|
||||
currentTime: 1,
|
||||
duration: 8
|
||||
})
|
||||
viewer.playing.value = true
|
||||
viewer.selectedSpeed.value = 0
|
||||
viewer.selectedAnimation.value = undefined as unknown as number
|
||||
await nextTick()
|
||||
viewer.selectedSpeed.value = 2
|
||||
viewer.selectedAnimation.value = 0
|
||||
await nextTick()
|
||||
viewer.handleSeek(50)
|
||||
|
||||
expect(viewer.animations.value).toEqual([{ name: 'Spin', index: 0 }])
|
||||
expect(viewer.animationProgress.value).toBe(25)
|
||||
expect(mockLoad3d.toggleAnimation).toHaveBeenCalledWith(true)
|
||||
expect(mockLoad3d.setAnimationSpeed).toHaveBeenCalledWith(2)
|
||||
expect(mockLoad3d.updateSelectedAnimation).toHaveBeenCalledWith(0)
|
||||
expect(mockLoad3d.setAnimationTime).toHaveBeenCalledWith(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreInitialState', () => {
|
||||
@@ -422,6 +675,14 @@ describe('useLoad3dViewer', () => {
|
||||
.futureField
|
||||
).toBe('preserve-me')
|
||||
})
|
||||
|
||||
it('does nothing in standalone mode', () => {
|
||||
const viewer = useLoad3dViewer()
|
||||
|
||||
viewer.restoreInitialState()
|
||||
|
||||
expect(viewer.needApplyChanges.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyChanges', () => {
|
||||
@@ -476,6 +737,24 @@ describe('useLoad3dViewer', () => {
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('applies without writing properties or dirtying when node state is absent', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
mockNode.properties = undefined as unknown as LGraphNode['properties']
|
||||
mockNode.graph = undefined as unknown as LGraphNode['graph']
|
||||
|
||||
const result = await viewer.applyChanges()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockLoad3dService.copyLoad3dState).toHaveBeenLastCalledWith(
|
||||
mockLoad3d,
|
||||
mockSourceLoad3d
|
||||
)
|
||||
})
|
||||
|
||||
it('should preserve unknown fields on Model Config when applying', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
@@ -578,6 +857,37 @@ describe('useLoad3dViewer', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('alerts when uploading without an active Load3d instance', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
|
||||
await viewer.handleBackgroundImageUpdate(
|
||||
new File([''], 'test.jpg', { type: 'image/jpeg' })
|
||||
)
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.no3dScene'
|
||||
)
|
||||
expect(Load3dUtils.uploadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves existing background state when upload returns no path', async () => {
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('')
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
viewer.backgroundImage.value = 'existing.jpg'
|
||||
viewer.hasBackgroundImage.value = true
|
||||
|
||||
await viewer.handleBackgroundImageUpdate(
|
||||
new File([''], 'test.jpg', { type: 'image/jpeg' })
|
||||
)
|
||||
|
||||
expect(viewer.backgroundImage.value).toBe('existing.jpg')
|
||||
expect(viewer.hasBackgroundImage.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should work in standalone mode without a node', async () => {
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce(
|
||||
'uploaded-image.jpg'
|
||||
@@ -664,6 +974,69 @@ describe('useLoad3dViewer', () => {
|
||||
)
|
||||
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates the model_file widget after a successful drop', async () => {
|
||||
const modelWidget = {
|
||||
name: 'model_file',
|
||||
value: '',
|
||||
options: { values: ['existing.glb'] }
|
||||
}
|
||||
mockNode.widgets = [modelWidget] as LGraphNode['widgets']
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('3d/new.glb')
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
|
||||
await viewer.handleModelDrop(new File([''], 'new.glb'))
|
||||
|
||||
expect(modelWidget.value).toBe('3d/new.glb')
|
||||
expect(modelWidget.options.values).toEqual(['existing.glb', '3d/new.glb'])
|
||||
})
|
||||
|
||||
it('does not duplicate model_file widget options', async () => {
|
||||
const modelWidget = {
|
||||
name: 'model_file',
|
||||
value: '',
|
||||
options: { values: ['3d/new.glb'] }
|
||||
}
|
||||
mockNode.widgets = [modelWidget] as LGraphNode['widgets']
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('3d/new.glb')
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
|
||||
await viewer.handleModelDrop(new File([''], 'new.glb'))
|
||||
|
||||
expect(modelWidget.options.values).toEqual(['3d/new.glb'])
|
||||
})
|
||||
|
||||
it('alerts when model loading throws', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce('3d/bad.glb')
|
||||
vi.mocked(mockLoad3d.loadModel!).mockRejectedValueOnce(
|
||||
new Error('bad model')
|
||||
)
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
|
||||
await viewer.handleModelDrop(new File([''], 'bad.glb'))
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToLoadModel'
|
||||
)
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanup', () => {
|
||||
@@ -694,6 +1067,17 @@ describe('useLoad3dViewer', () => {
|
||||
expect(Load3d).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle missing node in node-mode initialization', async () => {
|
||||
const viewer = useLoad3dViewer()
|
||||
|
||||
await viewer.initializeViewer(
|
||||
document.createElement('div'),
|
||||
mockSourceLoad3d as Load3d
|
||||
)
|
||||
|
||||
expect(createLoad3d).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle orthographic camera', async () => {
|
||||
vi.mocked(mockSourceLoad3d.getCurrentCameraType!).mockReturnValue(
|
||||
'orthographic'
|
||||
@@ -725,6 +1109,95 @@ describe('useLoad3dViewer', () => {
|
||||
})
|
||||
|
||||
describe('standalone mode persistence', () => {
|
||||
it('should handle missing standalone container ref', async () => {
|
||||
const viewer = useLoad3dViewer()
|
||||
|
||||
await viewer.initializeStandaloneViewer(null!, 'model.glb')
|
||||
|
||||
expect(createLoad3d).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('syncs pending hover state during standalone initialization', async () => {
|
||||
const viewer = useLoad3dViewer()
|
||||
viewer.handleMouseEnter()
|
||||
|
||||
await viewer.initializeStandaloneViewer(
|
||||
document.createElement('div'),
|
||||
'hover.glb'
|
||||
)
|
||||
|
||||
expect(mockLoad3d.updateStatusMouseOnViewer).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('shows an alert when first standalone load fails', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
vi.mocked(mockLoad3d.loadModel!).mockRejectedValueOnce(
|
||||
new Error('load failed')
|
||||
)
|
||||
const viewer = useLoad3dViewer()
|
||||
|
||||
await viewer.initializeStandaloneViewer(
|
||||
document.createElement('div'),
|
||||
'broken.glb'
|
||||
)
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToLoadModel'
|
||||
)
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('shows an alert when reusing a standalone viewer fails to load the next model', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
const viewer = useLoad3dViewer()
|
||||
await viewer.initializeStandaloneViewer(
|
||||
document.createElement('div'),
|
||||
'first.glb'
|
||||
)
|
||||
vi.mocked(mockLoad3d.loadModel!).mockRejectedValueOnce(
|
||||
new Error('reload failed')
|
||||
)
|
||||
|
||||
await viewer.initializeStandaloneViewer(
|
||||
document.createElement('div'),
|
||||
'second.glb'
|
||||
)
|
||||
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'Failed to load 3D model'
|
||||
)
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('restores cached standalone camera state', async () => {
|
||||
const cameraState = {
|
||||
position: { x: 2, y: 3, z: 4 },
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
zoom: 2,
|
||||
cameraType: 'perspective'
|
||||
}
|
||||
vi.mocked(mockLoad3d.getCameraState!).mockReturnValue(
|
||||
fromAny(cameraState)
|
||||
)
|
||||
const viewer = useLoad3dViewer()
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeStandaloneViewer(containerRef, 'camera.glb')
|
||||
viewer.cleanup()
|
||||
|
||||
const restoredViewer = useLoad3dViewer()
|
||||
await restoredViewer.initializeStandaloneViewer(
|
||||
containerRef,
|
||||
'camera.glb'
|
||||
)
|
||||
|
||||
expect(mockLoad3d.setCameraState).toHaveBeenCalledWith(cameraState)
|
||||
})
|
||||
|
||||
it('should save and restore configuration in standalone mode', async () => {
|
||||
const viewer = useLoad3dViewer()
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
@@ -381,4 +381,57 @@ The MEDIA_SRC_REGEX handles both single and double quotes in img, video and sour
|
||||
// Should have second node's content, not first
|
||||
expect(helpContent.value).toBe('# Second node content')
|
||||
})
|
||||
|
||||
it('returns empty state when no node is selected', async () => {
|
||||
const nodeRef = ref<ComfyNodeDefImpl | null>(null)
|
||||
|
||||
const { baseUrl, helpContent, isLoading, error } =
|
||||
useNodeHelpContent(nodeRef)
|
||||
await nextTick()
|
||||
|
||||
expect(baseUrl.value).toBe('')
|
||||
expect(helpContent.value).toBe('')
|
||||
expect(isLoading.value).toBe(false)
|
||||
expect(error.value).toBeNull()
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses stringified non-error rejections with the node description', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
mockFetch.mockRejectedValueOnce('offline')
|
||||
|
||||
const { error, helpContent } = useNodeHelpContent(nodeRef)
|
||||
await flushPromises()
|
||||
|
||||
expect(error.value).toBe('offline')
|
||||
expect(helpContent.value).toBe(mockCoreNode.description)
|
||||
})
|
||||
|
||||
it('ignores stale rejected requests after the node changes', async () => {
|
||||
const nodeRef = ref(mockCoreNode)
|
||||
let rejectFirst: (reason?: unknown) => void
|
||||
const firstRequest = new Promise((_resolve, reject) => {
|
||||
rejectFirst = reject
|
||||
})
|
||||
|
||||
mockFetch
|
||||
.mockImplementationOnce(() => firstRequest)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Current node content'
|
||||
})
|
||||
|
||||
const { error, helpContent } = useNodeHelpContent(nodeRef)
|
||||
await nextTick()
|
||||
|
||||
nodeRef.value = mockCustomNode
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
rejectFirst!(new Error('stale failure'))
|
||||
await flushPromises()
|
||||
|
||||
expect(error.value).toBeNull()
|
||||
expect(helpContent.value).toBe('# Current node content')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
pasteVideoNodes,
|
||||
usePaste
|
||||
} from './usePaste'
|
||||
import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers'
|
||||
|
||||
function createMockNode(): LGraphNode {
|
||||
return createMockLGraphNode({
|
||||
@@ -71,7 +72,7 @@ const mockCanvas = {
|
||||
} as Partial<LGraphCanvas> as LGraphCanvas
|
||||
|
||||
const mockCanvasStore = {
|
||||
canvas: mockCanvas,
|
||||
canvas: mockCanvas as LGraphCanvas | null,
|
||||
getCanvas: vi.fn(() => mockCanvas)
|
||||
}
|
||||
|
||||
@@ -139,6 +140,17 @@ describe('pasteImageNode', () => {
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('returns null when image node creation fails', async () => {
|
||||
vi.mocked(createNode).mockResolvedValue(null as never)
|
||||
|
||||
const file = createImageFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
await expect(
|
||||
pasteImageNode(mockCanvas, dataTransfer.items)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('should use existing image node when provided', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const file = createImageFile()
|
||||
@@ -216,6 +228,14 @@ describe('pasteImageNodes', () => {
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('omits files whose image node creation fails', async () => {
|
||||
vi.mocked(createNode).mockResolvedValue(null as never)
|
||||
|
||||
const result = await pasteImageNodes(mockCanvas, [createImageFile()])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasteAudioNode', () => {
|
||||
@@ -236,6 +256,17 @@ describe('pasteAudioNode', () => {
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('returns null when audio node creation fails', async () => {
|
||||
vi.mocked(createNode).mockResolvedValue(null as never)
|
||||
|
||||
const file = createAudioFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
await expect(
|
||||
pasteAudioNode(mockCanvas, dataTransfer.items)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('should use existing audio node when provided', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const file = createAudioFile()
|
||||
@@ -312,6 +343,14 @@ describe('pasteAudioNodes', () => {
|
||||
expect(createNode).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual([mockNode])
|
||||
})
|
||||
|
||||
it('omits files whose audio node creation fails', async () => {
|
||||
vi.mocked(createNode).mockResolvedValue(null as never)
|
||||
|
||||
const result = await pasteAudioNodes(mockCanvas, [createAudioFile()])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasteVideoNode', () => {
|
||||
@@ -332,6 +371,17 @@ describe('pasteVideoNode', () => {
|
||||
expect(mockNode.pasteFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('returns null when video node creation fails', async () => {
|
||||
vi.mocked(createNode).mockResolvedValue(null as never)
|
||||
|
||||
const file = createVideoFile()
|
||||
const dataTransfer = createDataTransfer([file])
|
||||
|
||||
await expect(
|
||||
pasteVideoNode(mockCanvas, dataTransfer.items)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('should use existing video node when provided', async () => {
|
||||
const mockNode = createMockNode()
|
||||
const file = createVideoFile()
|
||||
@@ -408,13 +458,23 @@ describe('pasteVideoNodes', () => {
|
||||
expect(createNode).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual([mockNode])
|
||||
})
|
||||
|
||||
it('omits files whose video node creation fails', async () => {
|
||||
vi.mocked(createNode).mockResolvedValue(null as never)
|
||||
|
||||
const result = await pasteVideoNodes(mockCanvas, [createVideoFile()])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePaste', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanvasStore.canvas = mockCanvas
|
||||
mockCanvas.current_node = null
|
||||
mockWorkspaceStore.shiftDown = false
|
||||
vi.mocked(shouldIgnoreCopyPaste).mockReturnValue(false)
|
||||
vi.mocked(mockCanvas.graph!.add).mockImplementation(
|
||||
(node: LGraphNode | LGraphGroup | null) => node as LGraphNode
|
||||
)
|
||||
@@ -544,6 +604,31 @@ describe('usePaste', () => {
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores paste when the target owns native copy paste', () => {
|
||||
vi.mocked(shouldIgnoreCopyPaste).mockReturnValue(true)
|
||||
|
||||
usePaste()
|
||||
|
||||
const dataTransfer = createDataTransfer([createImageFile()])
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores paste when the canvas is unavailable', () => {
|
||||
mockCanvasStore.canvas = null
|
||||
|
||||
usePaste()
|
||||
|
||||
const dataTransfer = createDataTransfer([createImageFile()])
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
expect(createNode).not.toHaveBeenCalled()
|
||||
expect(mockCanvas.pasteFromClipboard).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use existing image node when selected', () => {
|
||||
const mockNode = createMockLGraphNode({
|
||||
is_selected: true,
|
||||
@@ -596,6 +681,66 @@ describe('usePaste', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to litegraph paste when metadata cannot be decoded', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
usePaste()
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData(
|
||||
'text/html',
|
||||
`<div data-metadata="${btoa('{')}"></div>`
|
||||
)
|
||||
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCanvas._deserializeItems).not.toHaveBeenCalled()
|
||||
expect(mockCanvas.pasteFromClipboard).toHaveBeenCalled()
|
||||
})
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('leaves plain text paste alone in text inputs', () => {
|
||||
usePaste()
|
||||
|
||||
const input = document.createElement('input')
|
||||
input.type = 'text'
|
||||
document.body.append(input)
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/plain', 'plain text')
|
||||
|
||||
input.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockCanvas.pasteFromClipboard).not.toHaveBeenCalled()
|
||||
input.remove()
|
||||
})
|
||||
|
||||
it('leaves plain text paste alone in textareas', () => {
|
||||
usePaste()
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
document.body.append(textarea)
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/plain', 'plain text')
|
||||
|
||||
textarea.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockCanvas.pasteFromClipboard).not.toHaveBeenCalled()
|
||||
textarea.remove()
|
||||
})
|
||||
|
||||
it('should skip node metadata paste when a media node is selected', async () => {
|
||||
const mockNode = createMockLGraphNode({
|
||||
is_selected: true,
|
||||
|
||||
27
src/composables/useProgressBarBackground.test.ts
Normal file
27
src/composables/useProgressBarBackground.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useProgressBarBackground } from './useProgressBarBackground'
|
||||
|
||||
describe('useProgressBarBackground', () => {
|
||||
it('identifies finite progress values', () => {
|
||||
const { hasProgressPercent, hasAnyProgressPercent } =
|
||||
useProgressBarBackground()
|
||||
|
||||
expect(hasProgressPercent(undefined)).toBe(false)
|
||||
expect(hasProgressPercent(Number.NaN)).toBe(false)
|
||||
expect(hasProgressPercent(0)).toBe(true)
|
||||
expect(hasAnyProgressPercent(undefined, Number.POSITIVE_INFINITY)).toBe(
|
||||
false
|
||||
)
|
||||
expect(hasAnyProgressPercent(undefined, 42)).toBe(true)
|
||||
})
|
||||
|
||||
it('clamps progress styles to the valid percent range', () => {
|
||||
const { progressPercentStyle } = useProgressBarBackground()
|
||||
|
||||
expect(progressPercentStyle(undefined)).toBeUndefined()
|
||||
expect(progressPercentStyle(-10)).toEqual({ width: '0%' })
|
||||
expect(progressPercentStyle(125)).toEqual({ width: '100%' })
|
||||
expect(progressPercentStyle(37)).toEqual({ width: '37%' })
|
||||
})
|
||||
})
|
||||
61
src/composables/useRefreshableSelection.test.ts
Normal file
61
src/composables/useRefreshableSelection.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
|
||||
|
||||
const mockCanvasStore = reactive({
|
||||
selectedItems: [] as unknown[]
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => mockCanvasStore
|
||||
}))
|
||||
|
||||
function makeNode(widgets?: unknown[]): LGraphNode {
|
||||
const node = new LGraphNode('Test')
|
||||
node.widgets = widgets as LGraphNode['widgets']
|
||||
return node
|
||||
}
|
||||
|
||||
describe('useRefreshableSelection', () => {
|
||||
beforeEach(() => {
|
||||
mockCanvasStore.selectedItems = []
|
||||
})
|
||||
|
||||
it('does nothing when no selected widget is refreshable', async () => {
|
||||
const selection = useRefreshableSelection()
|
||||
|
||||
await selection.refreshSelected()
|
||||
|
||||
expect(selection.isRefreshable.value).toBe(false)
|
||||
})
|
||||
|
||||
it('refreshes selected widgets that expose a refresh function', async () => {
|
||||
const refresh = vi.fn()
|
||||
const ignoredRefresh = vi.fn()
|
||||
mockCanvasStore.selectedItems = [
|
||||
makeNode([{ refresh }, { refresh: 'not callable' }, null]),
|
||||
{ widgets: [{ refresh: ignoredRefresh }] }
|
||||
]
|
||||
|
||||
const selection = useRefreshableSelection()
|
||||
await nextTick()
|
||||
|
||||
expect(selection.isRefreshable.value).toBe(true)
|
||||
|
||||
await selection.refreshSelected()
|
||||
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
expect(ignoredRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats selected nodes without widgets as not refreshable', async () => {
|
||||
mockCanvasStore.selectedItems = [makeNode()]
|
||||
|
||||
const selection = useRefreshableSelection()
|
||||
await nextTick()
|
||||
|
||||
expect(selection.isRefreshable.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,13 @@ vi.mock('@vueuse/core', () => ({
|
||||
}))
|
||||
|
||||
describe('useServerLogs', () => {
|
||||
const listenerFor = <T>(eventType: string) =>
|
||||
vi
|
||||
.mocked(useEventListener)
|
||||
.mock.calls.find(([, type]) => type === eventType)?.[2] as
|
||||
| ((event: CustomEvent<T>) => void)
|
||||
| undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -119,4 +126,135 @@ describe('useServerLogs', () => {
|
||||
|
||||
expect(logs.value).toEqual(['Log message 1 dont remove me', ''])
|
||||
})
|
||||
|
||||
it('only captures logs while the matching task is active', async () => {
|
||||
const { logs, startListening } = useServerLogs({ ui_id: 'task-1' })
|
||||
|
||||
await startListening()
|
||||
|
||||
expect(vi.mocked(useEventListener)).toHaveBeenCalledWith(
|
||||
api,
|
||||
'cm-task-started',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(vi.mocked(useEventListener)).toHaveBeenCalledWith(
|
||||
api,
|
||||
'cm-task-completed',
|
||||
expect.any(Function)
|
||||
)
|
||||
|
||||
const onLogs = listenerFor<LogsWsMessage>('logs')
|
||||
const onTaskStarted = listenerFor<{ ui_id: string }>('cm-task-started')
|
||||
const onTaskDone = listenerFor<{ ui_id: string }>('cm-task-completed')
|
||||
|
||||
onLogs?.(
|
||||
new CustomEvent('logs', {
|
||||
detail: fromAny<LogsWsMessage, unknown>({
|
||||
type: 'logs',
|
||||
entries: [{ m: 'before start' }]
|
||||
})
|
||||
})
|
||||
)
|
||||
onTaskStarted?.(
|
||||
new CustomEvent('cm-task-started', { detail: { ui_id: 'other-task' } })
|
||||
)
|
||||
onLogs?.(
|
||||
new CustomEvent('logs', {
|
||||
detail: fromAny<LogsWsMessage, unknown>({
|
||||
type: 'logs',
|
||||
entries: [{ m: 'wrong task' }]
|
||||
})
|
||||
})
|
||||
)
|
||||
onTaskStarted?.(
|
||||
new CustomEvent('cm-task-started', { detail: { ui_id: 'task-1' } })
|
||||
)
|
||||
onLogs?.(
|
||||
new CustomEvent('logs', {
|
||||
detail: fromAny<LogsWsMessage, unknown>({
|
||||
type: 'logs',
|
||||
entries: [{ m: 'captured' }]
|
||||
})
|
||||
})
|
||||
)
|
||||
onTaskDone?.(
|
||||
new CustomEvent('cm-task-completed', { detail: { ui_id: 'other-task' } })
|
||||
)
|
||||
onLogs?.(
|
||||
new CustomEvent('logs', {
|
||||
detail: fromAny<LogsWsMessage, unknown>({
|
||||
type: 'logs',
|
||||
entries: [{ m: 'still active' }]
|
||||
})
|
||||
})
|
||||
)
|
||||
onTaskDone?.(
|
||||
new CustomEvent('cm-task-completed', { detail: { ui_id: 'task-1' } })
|
||||
)
|
||||
onLogs?.(
|
||||
new CustomEvent('logs', {
|
||||
detail: fromAny<LogsWsMessage, unknown>({
|
||||
type: 'logs',
|
||||
entries: [{ m: 'after done' }]
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(logs.value).toEqual(['captured', 'still active'])
|
||||
})
|
||||
|
||||
it('ignores invalid and empty log events', async () => {
|
||||
const { logs, startListening } = useServerLogs()
|
||||
|
||||
await startListening()
|
||||
|
||||
const onLogs = listenerFor<LogsWsMessage>('logs')
|
||||
|
||||
onLogs?.(
|
||||
new CustomEvent('not-logs', {
|
||||
detail: fromAny<LogsWsMessage, unknown>({
|
||||
type: 'logs',
|
||||
entries: [{ m: 'wrong event' }]
|
||||
})
|
||||
})
|
||||
)
|
||||
onLogs?.(
|
||||
new CustomEvent('logs', {
|
||||
detail: fromAny<LogsWsMessage, unknown>({
|
||||
type: 'logs',
|
||||
entries: []
|
||||
})
|
||||
})
|
||||
)
|
||||
onLogs?.(
|
||||
new CustomEvent('logs', {
|
||||
detail: fromAny<LogsWsMessage, unknown>({
|
||||
type: 'logs',
|
||||
entries: [{ m: ' ' }]
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(logs.value).toEqual([])
|
||||
})
|
||||
|
||||
it('stops every registered listener', async () => {
|
||||
const stopLogs = vi.fn()
|
||||
const stopTaskStarted = vi.fn()
|
||||
const stopTaskDone = vi.fn()
|
||||
vi.mocked(useEventListener)
|
||||
.mockReturnValueOnce(stopLogs)
|
||||
.mockReturnValueOnce(stopTaskStarted)
|
||||
.mockReturnValueOnce(stopTaskDone)
|
||||
|
||||
const { startListening, stopListening } = useServerLogs({ ui_id: 'task-1' })
|
||||
|
||||
await startListening()
|
||||
await stopListening()
|
||||
|
||||
expect(stopLogs).toHaveBeenCalledTimes(1)
|
||||
expect(stopTaskStarted).toHaveBeenCalledTimes(1)
|
||||
expect(stopTaskDone).toHaveBeenCalledTimes(1)
|
||||
expect(api.subscribeLogs).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -248,6 +248,157 @@ describe('useTemplateFiltering', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts plain template arrays and treats invalid ref values as empty', () => {
|
||||
const plain = useTemplateFiltering([
|
||||
{
|
||||
name: 'plain-template',
|
||||
description: 'Plain template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
expect(
|
||||
plain.filteredTemplates.value.map((template) => template.name)
|
||||
).toEqual(['plain-template'])
|
||||
|
||||
const invalid = useTemplateFiltering(
|
||||
ref('not-an-array') as unknown as Parameters<
|
||||
typeof useTemplateFiltering
|
||||
>[0]
|
||||
)
|
||||
|
||||
expect(invalid.filteredTemplates.value).toEqual([])
|
||||
expect(invalid.totalCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores malformed models and tags while applying active filters', () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'model-template',
|
||||
description: 'Model template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
models: ['Flux'],
|
||||
tags: ['Video']
|
||||
},
|
||||
{
|
||||
name: 'missing-models',
|
||||
description: 'Missing models',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
models: 'Flux' as unknown as string[],
|
||||
tags: 'Video' as unknown as string[]
|
||||
}
|
||||
])
|
||||
|
||||
const {
|
||||
availableModels,
|
||||
availableUseCases,
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
filteredTemplates
|
||||
} = useTemplateFiltering(templates)
|
||||
|
||||
expect(availableModels.value).toEqual(['Flux'])
|
||||
expect(availableUseCases.value).toEqual(['Video'])
|
||||
|
||||
selectedModels.value = ['Flux']
|
||||
selectedUseCases.value = ['Video']
|
||||
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'model-template'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns no templates for unknown runs-on filters', () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'template',
|
||||
description: 'Template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
const { selectedRunsOn, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
|
||||
selectedRunsOn.value = ['Unknown target']
|
||||
|
||||
expect(filteredTemplates.value).toEqual([])
|
||||
})
|
||||
|
||||
it('supports recommended and popular score sorting', async () => {
|
||||
defaultRankingStore.computeDefaultScore.mockImplementation(
|
||||
(_date?: string, rank?: number) => rank ?? 0
|
||||
)
|
||||
defaultRankingStore.computePopularScore.mockImplementation(
|
||||
(_date?: string, usage?: number) => usage ?? 0
|
||||
)
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'low',
|
||||
description: 'Low score',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
searchRank: 1,
|
||||
usage: 10
|
||||
},
|
||||
{
|
||||
name: 'high',
|
||||
description: 'High score',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
searchRank: 9,
|
||||
usage: 1
|
||||
}
|
||||
])
|
||||
|
||||
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
|
||||
|
||||
sortBy.value = 'recommended'
|
||||
await nextTick()
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'high',
|
||||
'low'
|
||||
])
|
||||
|
||||
sortBy.value = 'popular'
|
||||
await nextTick()
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'low',
|
||||
'high'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps equal and missing model sizes stable for size sorting', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'first',
|
||||
description: 'First',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
},
|
||||
{
|
||||
name: 'second',
|
||||
description: 'Second',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
|
||||
|
||||
sortBy.value = 'model-size-low-to-high'
|
||||
await nextTick()
|
||||
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'first',
|
||||
'second'
|
||||
])
|
||||
})
|
||||
|
||||
describe('loadFuseOptions', () => {
|
||||
it('updates fuseOptions when getFuseOptions returns valid options', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
|
||||
102
src/composables/useTreeExpansion.test.ts
Normal file
102
src/composables/useTreeExpansion.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { ref } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import type { TreeNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
function node(over: Partial<TreeNode>): TreeNode {
|
||||
return over as TreeNode
|
||||
}
|
||||
|
||||
// root ─┬─ a ── a1 (leaf)
|
||||
// └─ b (leaf)
|
||||
function sampleTree() {
|
||||
const a1 = node({ key: 'a1', leaf: true })
|
||||
const a = node({ key: 'a', leaf: false, children: [a1] })
|
||||
const b = node({ key: 'b', leaf: true })
|
||||
const root = node({ key: 'root', leaf: false, children: [a, b] })
|
||||
return { root, a, a1, b }
|
||||
}
|
||||
|
||||
describe('useTreeExpansion', () => {
|
||||
it('toggleNode adds then removes a node key', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { toggleNode } = useTreeExpansion(expandedKeys)
|
||||
const n = node({ key: 'x' })
|
||||
|
||||
toggleNode(n)
|
||||
expect(expandedKeys.value).toEqual({ x: true })
|
||||
|
||||
toggleNode(n)
|
||||
expect(expandedKeys.value).toEqual({})
|
||||
})
|
||||
|
||||
it('toggleNode ignores nodes without a string key', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { toggleNode } = useTreeExpansion(expandedKeys)
|
||||
|
||||
toggleNode(node({ key: undefined }))
|
||||
toggleNode(node({ key: 42 as unknown as string }))
|
||||
|
||||
expect(expandedKeys.value).toEqual({})
|
||||
})
|
||||
|
||||
it('expandNode expands the node and all non-leaf descendants only', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { expandNode } = useTreeExpansion(expandedKeys)
|
||||
const { root } = sampleTree()
|
||||
|
||||
expandNode(root)
|
||||
|
||||
// root and a are folders; a1 and b are leaves and must be skipped
|
||||
expect(expandedKeys.value).toEqual({ root: true, a: true })
|
||||
})
|
||||
|
||||
it('expandNode does nothing for a leaf node', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { expandNode } = useTreeExpansion(expandedKeys)
|
||||
|
||||
expandNode(node({ key: 'leaf', leaf: true }))
|
||||
|
||||
expect(expandedKeys.value).toEqual({})
|
||||
})
|
||||
|
||||
it('collapseNode removes the node and its non-leaf descendants', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({
|
||||
root: true,
|
||||
a: true,
|
||||
stray: true
|
||||
})
|
||||
const { collapseNode } = useTreeExpansion(expandedKeys)
|
||||
const { root } = sampleTree()
|
||||
|
||||
collapseNode(root)
|
||||
|
||||
expect(expandedKeys.value).toEqual({ stray: true })
|
||||
})
|
||||
|
||||
it('toggleNodeRecursive expands when collapsed and collapses when expanded', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { toggleNodeRecursive } = useTreeExpansion(expandedKeys)
|
||||
const { root } = sampleTree()
|
||||
|
||||
toggleNodeRecursive(root)
|
||||
expect(expandedKeys.value).toEqual({ root: true, a: true })
|
||||
|
||||
toggleNodeRecursive(root)
|
||||
expect(expandedKeys.value).toEqual({})
|
||||
})
|
||||
|
||||
it('toggleNodeOnEvent toggles recursively with ctrl and singly without', () => {
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
|
||||
const { root } = sampleTree()
|
||||
|
||||
toggleNodeOnEvent(new KeyboardEvent('keydown', { ctrlKey: true }), root)
|
||||
expect(expandedKeys.value).toEqual({ root: true, a: true })
|
||||
|
||||
// Plain toggle removes only the node's own key, leaving descendants
|
||||
toggleNodeOnEvent(new MouseEvent('click'), root)
|
||||
expect(expandedKeys.value).toEqual({ a: true })
|
||||
})
|
||||
})
|
||||
105
src/composables/useViewErrorsInGraph.test.ts
Normal file
105
src/composables/useViewErrorsInGraph.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
import { useViewErrorsInGraph } from './useViewErrorsInGraph'
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
storeSetting: vi.fn(),
|
||||
storeSettings: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: apiMock
|
||||
}))
|
||||
|
||||
const appMock = vi.hoisted(() => ({
|
||||
ui: {
|
||||
settings: {
|
||||
dispatchChange: vi.fn()
|
||||
}
|
||||
},
|
||||
rootGraph: {
|
||||
events: new EventTarget(),
|
||||
nodes: []
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: appMock
|
||||
}))
|
||||
|
||||
function createSelectedCanvas() {
|
||||
const graph = new LGraph()
|
||||
const canvasElement = document.createElement('canvas')
|
||||
canvasElement.width = 800
|
||||
canvasElement.height = 600
|
||||
canvasElement.getContext = vi
|
||||
.fn()
|
||||
.mockReturnValue(createMockCanvasRenderingContext2D())
|
||||
|
||||
const canvas = new LGraphCanvas(canvasElement, graph, {
|
||||
skip_events: true,
|
||||
skip_render: true
|
||||
})
|
||||
const node = new LGraphNode('Selected Node')
|
||||
graph.add(node)
|
||||
canvas.selectedItems.add(node)
|
||||
node.selected = true
|
||||
|
||||
return { canvas, node }
|
||||
}
|
||||
|
||||
describe('useViewErrorsInGraph', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
apiMock.getSettings.mockResolvedValue({})
|
||||
apiMock.storeSetting.mockResolvedValue(undefined)
|
||||
apiMock.storeSettings.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('opens graph errors and clears app-mode error UI state', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { canvas, node } = createSelectedCanvas()
|
||||
workflowStore.activeWorkflow = {
|
||||
activeMode: 'app'
|
||||
} as typeof workflowStore.activeWorkflow
|
||||
canvasStore.canvas = canvas
|
||||
canvasStore.selectedItems = [node]
|
||||
executionErrorStore.showErrorOverlay()
|
||||
|
||||
useViewErrorsInGraph().viewErrorsInGraph()
|
||||
|
||||
expect(node.selected).toBe(false)
|
||||
expect(canvasStore.linearMode).toBe(false)
|
||||
expect(canvasStore.selectedItems).toEqual([])
|
||||
expect(rightSidePanelStore.activeTab).toBe('errors')
|
||||
expect(rightSidePanelStore.isOpen).toBe(true)
|
||||
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('opens graph errors when the canvas is not initialized', () => {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
canvasStore.canvas = null
|
||||
executionErrorStore.showErrorOverlay()
|
||||
|
||||
expect(() => useViewErrorsInGraph().viewErrorsInGraph()).not.toThrow()
|
||||
|
||||
expect(rightSidePanelStore.activeTab).toBe('errors')
|
||||
expect(rightSidePanelStore.isOpen).toBe(true)
|
||||
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
22
src/composables/useViewErrorsInGraph.ts
Normal file
22
src/composables/useViewErrorsInGraph.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
export function useViewErrorsInGraph() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
|
||||
function viewErrorsInGraph() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
rightSidePanelStore.openPanel('errors')
|
||||
executionErrorStore.dismissErrorOverlay()
|
||||
}
|
||||
|
||||
return { viewErrorsInGraph }
|
||||
}
|
||||
@@ -1,24 +1,57 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { ref } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useWaveAudioPlayer } from './useWaveAudioPlayer'
|
||||
|
||||
type MediaControls = {
|
||||
playing: Ref<boolean>
|
||||
currentTime: Ref<number>
|
||||
duration: Ref<number>
|
||||
volume: Ref<number>
|
||||
muted: Ref<boolean>
|
||||
}
|
||||
|
||||
const mockMediaControls = vi.hoisted(() => ({
|
||||
values: [] as MediaControls[]
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return {
|
||||
...actual,
|
||||
useMediaControls: () => ({
|
||||
playing: ref(false),
|
||||
currentTime: ref(0),
|
||||
duration: ref(0)
|
||||
})
|
||||
useMediaControls: () =>
|
||||
mockMediaControls.values.shift() ?? {
|
||||
playing: ref(false),
|
||||
currentTime: ref(0),
|
||||
duration: ref(0),
|
||||
volume: ref(1),
|
||||
muted: ref(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mockFetchApi = vi.fn()
|
||||
const originalAudioContext = globalThis.AudioContext
|
||||
|
||||
function queueMediaControls(overrides: Partial<MediaControls> = {}) {
|
||||
const controls: MediaControls = {
|
||||
playing: ref(false),
|
||||
currentTime: ref(0),
|
||||
duration: ref(0),
|
||||
volume: ref(1),
|
||||
muted: ref(false),
|
||||
...overrides
|
||||
}
|
||||
mockMediaControls.values.push(controls)
|
||||
return controls
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockMediaControls.values = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.AudioContext = originalAudioContext
|
||||
mockFetchApi.mockReset()
|
||||
@@ -50,6 +83,21 @@ describe('useWaveAudioPlayer', () => {
|
||||
expect(playedBarIndex.value).toBe(-1)
|
||||
})
|
||||
|
||||
it('computes progress and played bar when duration is known', () => {
|
||||
queueMediaControls({
|
||||
currentTime: ref(30),
|
||||
duration: ref(120)
|
||||
})
|
||||
const src = ref('')
|
||||
const { playedBarIndex, progressRatio } = useWaveAudioPlayer({
|
||||
src,
|
||||
barCount: 40
|
||||
})
|
||||
|
||||
expect(playedBarIndex.value).toBe(9)
|
||||
expect(progressRatio.value).toBe(25)
|
||||
})
|
||||
|
||||
it('generates bars with heights between 10 and 70', () => {
|
||||
const src = ref('')
|
||||
const { bars } = useWaveAudioPlayer({ src })
|
||||
@@ -65,6 +113,56 @@ describe('useWaveAudioPlayer', () => {
|
||||
expect(isPlaying.value).toBe(false)
|
||||
})
|
||||
|
||||
it('updates playback and seek controls', () => {
|
||||
const controls = queueMediaControls({
|
||||
currentTime: ref(10),
|
||||
duration: ref(100)
|
||||
})
|
||||
const src = ref('')
|
||||
const player = useWaveAudioPlayer({ src })
|
||||
|
||||
player.togglePlayPause()
|
||||
expect(player.isPlaying.value).toBe(true)
|
||||
|
||||
player.seekToStart()
|
||||
expect(controls.currentTime.value).toBe(0)
|
||||
|
||||
player.seekToRatio(0.25)
|
||||
expect(controls.currentTime.value).toBe(25)
|
||||
|
||||
player.seekToRatio(-1)
|
||||
expect(controls.currentTime.value).toBe(0)
|
||||
|
||||
player.seekToRatio(2)
|
||||
expect(controls.currentTime.value).toBe(100)
|
||||
|
||||
player.seekToEnd()
|
||||
expect(controls.currentTime.value).toBe(100)
|
||||
expect(player.isPlaying.value).toBe(false)
|
||||
})
|
||||
|
||||
it('updates mute state and volume icon', () => {
|
||||
const controls = queueMediaControls({
|
||||
volume: ref(1),
|
||||
muted: ref(false)
|
||||
})
|
||||
const src = ref('')
|
||||
const player = useWaveAudioPlayer({ src })
|
||||
|
||||
expect(player.volumeIcon.value).toBe('icon-[lucide--volume-2]')
|
||||
|
||||
controls.volume.value = 0.25
|
||||
expect(player.volumeIcon.value).toBe('icon-[lucide--volume-1]')
|
||||
|
||||
controls.volume.value = 0
|
||||
expect(player.volumeIcon.value).toBe('icon-[lucide--volume-x]')
|
||||
|
||||
controls.volume.value = 1
|
||||
player.toggleMute()
|
||||
expect(controls.muted.value).toBe(true)
|
||||
expect(player.volumeIcon.value).toBe('icon-[lucide--volume-x]')
|
||||
})
|
||||
|
||||
it('shows 0:00 for formatted times initially', () => {
|
||||
const src = ref('')
|
||||
const { formattedCurrentTime, formattedDuration } = useWaveAudioPlayer({
|
||||
@@ -108,6 +206,91 @@ describe('useWaveAudioPlayer', () => {
|
||||
expect(bars.value).toHaveLength(10)
|
||||
})
|
||||
|
||||
it('uses placeholder bars when decoded audio has no channel data', async () => {
|
||||
const mockAudioBuffer = {
|
||||
getChannelData: vi.fn(() => new Float32Array())
|
||||
}
|
||||
const mockDecodeAudioData = vi.fn(() => Promise.resolve(mockAudioBuffer))
|
||||
const mockClose = vi.fn().mockResolvedValue(undefined)
|
||||
globalThis.AudioContext = fromAny<typeof AudioContext, unknown>(
|
||||
class {
|
||||
decodeAudioData = mockDecodeAudioData
|
||||
close = mockClose
|
||||
}
|
||||
)
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8))
|
||||
})
|
||||
const src = ref('/api/view?filename=empty.wav&type=output')
|
||||
const { bars, loading } = useWaveAudioPlayer({ src, barCount: 6 })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
expect(bars.value).toHaveLength(6)
|
||||
for (const bar of bars.value) {
|
||||
expect(bar.height).toBeGreaterThanOrEqual(10)
|
||||
expect(bar.height).toBeLessThanOrEqual(70)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses placeholder bars when fetching audio fails', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500
|
||||
})
|
||||
const src = ref('https://example.com/audio.wav')
|
||||
const { bars, loading } = useWaveAudioPlayer({ src, barCount: 5 })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('https://example.com/audio.wav')
|
||||
expect(bars.value).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('seeks from waveform clicks and starts playback', () => {
|
||||
const controls = queueMediaControls({
|
||||
duration: ref(100)
|
||||
})
|
||||
const src = ref('')
|
||||
const player = useWaveAudioPlayer({ src })
|
||||
|
||||
player.handleWaveformClick(fromAny<MouseEvent, unknown>({ clientX: 50 }))
|
||||
expect(controls.currentTime.value).toBe(0)
|
||||
|
||||
player.waveformRef.value = fromAny<HTMLElement, unknown>({
|
||||
getBoundingClientRect: () => ({ left: 10, width: 100 })
|
||||
})
|
||||
|
||||
player.handleWaveformClick(fromAny<MouseEvent, unknown>({ clientX: 60 }))
|
||||
expect(controls.currentTime.value).toBe(50)
|
||||
expect(player.isPlaying.value).toBe(true)
|
||||
|
||||
player.handleWaveformClick(fromAny<MouseEvent, unknown>({ clientX: -100 }))
|
||||
expect(controls.currentTime.value).toBe(0)
|
||||
|
||||
player.handleWaveformClick(fromAny<MouseEvent, unknown>({ clientX: 999 }))
|
||||
expect(controls.currentTime.value).toBe(100)
|
||||
})
|
||||
|
||||
it('ignores waveform clicks when duration is zero', () => {
|
||||
const controls = queueMediaControls()
|
||||
const src = ref('')
|
||||
const player = useWaveAudioPlayer({ src })
|
||||
player.waveformRef.value = fromAny<HTMLElement, unknown>({
|
||||
getBoundingClientRect: () => ({ left: 0, width: 100 })
|
||||
})
|
||||
|
||||
player.handleWaveformClick(fromAny<MouseEvent, unknown>({ clientX: 50 }))
|
||||
|
||||
expect(controls.currentTime.value).toBe(0)
|
||||
expect(player.isPlaying.value).toBe(false)
|
||||
})
|
||||
|
||||
it('does not call decodeAudioSource when src is empty', () => {
|
||||
const src = ref('')
|
||||
useWaveAudioPlayer({ src })
|
||||
|
||||
@@ -16,12 +16,14 @@ import {
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import {
|
||||
appendQuarantine,
|
||||
flushProxyWidgetMigration,
|
||||
normalizeLegacyProxyWidgetEntry,
|
||||
readHostQuarantine
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { UNASSIGNED_NODE_ID, toNodeId } from '@/types/nodeId'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
@@ -179,6 +181,33 @@ describe('flushProxyWidgetMigration', () => {
|
||||
expect(getPromotedInputValue(outerHost, 'text')).toBe('22222222222')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: resolves a nested promoted input by host input name', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const innerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const source = new LGraphNode('CLIPTextEncode')
|
||||
const sourceSlot = source.addInput('text', 'STRING')
|
||||
sourceSlot.widget = { name: 'text' }
|
||||
source.addWidget('text', 'text', 'nested value', () => {})
|
||||
innerSubgraph.add(source)
|
||||
|
||||
const nestedHost = createTestSubgraphNode(innerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
nestedHost.properties.proxyWidgets = [[String(source.id), 'text']]
|
||||
flushProxyWidgetMigration({ hostNode: nestedHost })
|
||||
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
outerSubgraph.add(nestedHost)
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph, {
|
||||
parentGraph: rootGraph
|
||||
})
|
||||
outerHost.properties.proxyWidgets = [[String(nestedHost.id), 'text']]
|
||||
|
||||
flushProxyWidgetMigration({ hostNode: outerHost })
|
||||
|
||||
expect(getPromotedInputValue(outerHost, 'text')).toBe('nested value')
|
||||
})
|
||||
|
||||
it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: 'INT' }]
|
||||
@@ -240,6 +269,41 @@ describe('flushProxyWidgetMigration', () => {
|
||||
).toBe('renamed_from_sidepanel')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: falls back to the source widget type when the slot type is missing', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
const slot = n.addInput('seed', 'INT')
|
||||
slot.type = undefined as never
|
||||
slot.widget = { name: 'seed' }
|
||||
n.addWidget('number', 'seed', 0, () => {})
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(
|
||||
host.subgraph.inputs.find((input) => input.name === 'seed')?.type
|
||||
).toBe('number')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: falls back to wildcard type when slot and widget type are missing', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
const slot = n.addInput('seed', 'INT')
|
||||
slot.type = undefined as never
|
||||
slot.widget = { name: 'seed' }
|
||||
const widget = n.addWidget('number', 'seed', 0, () => {})
|
||||
widget.type = undefined as never
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(
|
||||
host.subgraph.inputs.find((input) => input.name === 'seed')?.type
|
||||
).toBe('*')
|
||||
})
|
||||
|
||||
it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => {
|
||||
const host = buildHost()
|
||||
const inner = addInnerNode(host, 'Inner', (n) => {
|
||||
@@ -328,6 +392,88 @@ describe('flushProxyWidgetMigration', () => {
|
||||
expect(getPromotedInputValue(host, 'value')).toBe(11)
|
||||
})
|
||||
|
||||
it('uses the primitive title as the promoted input name when it was renamed', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.title = 'Batch Size'
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(
|
||||
host.inputs.find((input) => input.name === 'Batch Size')
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
it('skips a stale primitive bypass marker when the host input is absent', () => {
|
||||
const host = buildHost()
|
||||
const { primitive, targets } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.properties = {
|
||||
proxyBypassedToSubgraphInput: 'deleted_input'
|
||||
}
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
const slot = targets[0].inputs[0]
|
||||
const link = host.subgraph.links.get(slot.link!)
|
||||
expect(link?.origin_id).not.toBe(primitive.id)
|
||||
expect(host.inputs.find((input) => input.name === 'value')).toBeDefined()
|
||||
})
|
||||
|
||||
it('quarantines a stale primitive bypass marker that points to a plain input', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.properties = {
|
||||
proxyBypassedToSubgraphInput: 'plain'
|
||||
}
|
||||
host.addInput('plain', 'INT')
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [12]
|
||||
})
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'missingSubgraphInput'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines a stale primitive bypass marker that matches ambiguous host inputs', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.properties = {
|
||||
proxyBypassedToSubgraphInput: 'plain'
|
||||
}
|
||||
host.addInput('plain', 'INT')
|
||||
host.addInput('plain', 'INT')
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [12]
|
||||
})
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'ambiguousSubgraphInput'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines an unlinked primitive node with no fan-out', () => {
|
||||
const host = buildHost()
|
||||
const primitive = new LGraphNode('Primitive')
|
||||
@@ -346,6 +492,64 @@ describe('flushProxyWidgetMigration', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines primitive cohorts that disagree on source widget name', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(primitive.id), 'value'],
|
||||
[String(primitive.id), 'other']
|
||||
]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'other'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines duplicate primitive entries with no fan-out targets', () => {
|
||||
const host = buildHost()
|
||||
const primitive = new LGraphNode('PrimitiveNode')
|
||||
primitive.type = 'PrimitiveNode'
|
||||
primitive.addOutput('value', 'INT')
|
||||
host.subgraph.add(primitive)
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(primitive.id), 'value'],
|
||||
[String(primitive.id), 'value']
|
||||
]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the target default when the primitive source widget has no value', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
primitive.widgets = []
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(getPromotedInputValue(host, 'value')).toBe(0)
|
||||
})
|
||||
|
||||
it('quarantines all cohort entries when a target slot type is incompatible', () => {
|
||||
const host = buildHost()
|
||||
const { primitive, targets } = addPrimitiveWithTargets(host, {
|
||||
@@ -366,6 +570,73 @@ describe('flushProxyWidgetMigration', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines primitive repair when the target slot disappeared', () => {
|
||||
const host = buildHost()
|
||||
const { primitive, targets } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
targets[0].inputs = []
|
||||
|
||||
const inputCountBefore = host.subgraph.inputs.length
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(inputCountBefore)
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines primitive repair when the target node id is stale', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
const linkId = primitive.outputs[0].links?.[0]
|
||||
if (!linkId) throw new Error('Missing primitive link')
|
||||
const link = host.subgraph.links.get(linkId)
|
||||
if (!link) throw new Error('Missing primitive link record')
|
||||
link.target_id = toNodeId(999_999)
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('quarantines duplicate primitive entries when the fan-out target is unassigned', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, {
|
||||
targetCount: 1
|
||||
})
|
||||
const linkId = primitive.outputs[0].links?.[0]
|
||||
if (!linkId) throw new Error('Missing primitive link')
|
||||
const link = host.subgraph.links.get(linkId)
|
||||
if (!link) throw new Error('Missing primitive link record')
|
||||
link.target_id = UNASSIGNED_NODE_ID
|
||||
|
||||
host.properties.proxyWidgets = [
|
||||
[String(primitive.id), 'value'],
|
||||
[String(primitive.id), 'value']
|
||||
]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.objectContaining({
|
||||
originalEntry: [String(primitive.id), 'value'],
|
||||
reason: 'primitiveBypassFailed'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps surviving primitive targets when one fan-out link is dangling', () => {
|
||||
const host = buildHost()
|
||||
const { primitive } = addPrimitiveWithTargets(host, { targetCount: 1 })
|
||||
@@ -572,6 +843,22 @@ describe('flushProxyWidgetMigration', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('does not preserve non-widget host values on quarantine rows', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
|
||||
flushProxyWidgetMigration({
|
||||
hostNode: host,
|
||||
hostWidgetValues: [null]
|
||||
})
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual([
|
||||
expect.not.objectContaining({
|
||||
hostValue: expect.anything()
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('round-trips appended entries via the public read helper', () => {
|
||||
const host = buildHost()
|
||||
host.properties.proxyWidgets = [['9999', 'seed']]
|
||||
@@ -602,6 +889,14 @@ describe('flushProxyWidgetMigration', () => {
|
||||
|
||||
expect(readHostQuarantine(host)).toEqual(firstQuarantine)
|
||||
})
|
||||
|
||||
it('ignores empty quarantine append requests', () => {
|
||||
const host = buildHost()
|
||||
|
||||
appendQuarantine(host, [])
|
||||
|
||||
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('idempotency', () => {
|
||||
@@ -824,6 +1119,22 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
|
||||
expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id))
|
||||
})
|
||||
|
||||
it('strips nested legacy prefixes from widget name', () => {
|
||||
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
|
||||
|
||||
const result = normalizeLegacyProxyWidgetEntry(
|
||||
hostNode,
|
||||
String(innerNode.id),
|
||||
'111: 222: seed'
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
sourceNodeId: String(innerNode.id),
|
||||
sourceWidgetName: 'seed',
|
||||
disambiguatingSourceNodeId: '222'
|
||||
})
|
||||
})
|
||||
|
||||
it('strips legacy prefix and surfaces it as disambiguator even when the bare name does not resolve', () => {
|
||||
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
|
||||
|
||||
|
||||
179
src/core/graph/subgraph/promotedInputWidget.test.ts
Normal file
179
src/core/graph/subgraph/promotedInputWidget.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
import {
|
||||
inputForWidget,
|
||||
promotedInputSource,
|
||||
promotedInputWidget,
|
||||
promotedInputWidgets,
|
||||
widgetPromotedSource
|
||||
} from './promotedInputWidget'
|
||||
import { resolveSubgraphInputTarget } from './resolveSubgraphInputTarget'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
widgets: new Map<string, Record<string, unknown>>(),
|
||||
setValue: vi.fn(),
|
||||
resolveSubgraphInputTarget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => ({
|
||||
useWidgetValueStore: () => ({
|
||||
getWidget: (id: string) => mocks.widgets.get(id),
|
||||
setValue: mocks.setValue
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./resolveSubgraphInputTarget', () => ({
|
||||
resolveSubgraphInputTarget: mocks.resolveSubgraphInputTarget
|
||||
}))
|
||||
|
||||
function input(overrides: Partial<INodeInputSlot> = {}): INodeInputSlot {
|
||||
return {
|
||||
name: 'prompt',
|
||||
type: 'STRING',
|
||||
label: 'Prompt',
|
||||
...overrides
|
||||
} as INodeInputSlot
|
||||
}
|
||||
|
||||
function node(overrides: Record<string, unknown> = {}): LGraphNode {
|
||||
return {
|
||||
inputs: [],
|
||||
isSubgraphNode: () => true,
|
||||
getSlotFromWidget: vi.fn(),
|
||||
...overrides
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
describe('promotedInputWidget helpers', () => {
|
||||
beforeEach(() => {
|
||||
mocks.widgets.clear()
|
||||
mocks.setValue.mockClear()
|
||||
mocks.resolveSubgraphInputTarget.mockReset()
|
||||
})
|
||||
|
||||
it('resolves promoted input sources only for widget-backed inputs', () => {
|
||||
const graphNode = node()
|
||||
mocks.resolveSubgraphInputTarget.mockReturnValue({
|
||||
nodeId: '12',
|
||||
widgetName: 'prompt'
|
||||
})
|
||||
|
||||
expect(promotedInputSource(graphNode, input())).toBeUndefined()
|
||||
expect(
|
||||
promotedInputSource(
|
||||
graphNode,
|
||||
input({ widgetId: 'graph:12:prompt' as WidgetId })
|
||||
)
|
||||
).toEqual({
|
||||
nodeId: '12',
|
||||
widgetName: 'prompt'
|
||||
})
|
||||
expect(resolveSubgraphInputTarget).toHaveBeenCalledWith(graphNode, 'prompt')
|
||||
})
|
||||
|
||||
it('resolves promoted widget sources only on subgraph nodes with matching inputs', () => {
|
||||
const widget = { name: 'prompt' } as IBaseWidget
|
||||
const backingInput = input({ widgetId: 'graph:12:prompt' as WidgetId })
|
||||
mocks.resolveSubgraphInputTarget.mockReturnValue({
|
||||
nodeId: '12',
|
||||
widgetName: 'prompt'
|
||||
})
|
||||
|
||||
expect(
|
||||
widgetPromotedSource(node({ isSubgraphNode: () => false }), widget)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
widgetPromotedSource(node({ getSlotFromWidget: () => undefined }), widget)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
widgetPromotedSource(
|
||||
node({ getSlotFromWidget: () => backingInput }),
|
||||
widget
|
||||
)
|
||||
).toEqual({
|
||||
nodeId: '12',
|
||||
widgetName: 'prompt'
|
||||
})
|
||||
})
|
||||
|
||||
it('projects store-backed widget fields with input fallbacks', () => {
|
||||
const widgetId = 'graph:12:prompt' as WidgetId
|
||||
const widget = promotedInputWidget(input({ widgetId }))
|
||||
|
||||
expect(widget?.name).toBe('prompt')
|
||||
expect(widget?.label).toBe('Prompt')
|
||||
expect(widget?.y).toBe(0)
|
||||
expect(widget?.type).toBe('text')
|
||||
expect(widget?.options).toEqual({})
|
||||
expect(widget?.value).toBeUndefined()
|
||||
|
||||
widget!.label = 'Ignored'
|
||||
widget!.y = 12
|
||||
widget!.value = 'next'
|
||||
widget!.callback?.('callback')
|
||||
|
||||
expect(mocks.setValue).toHaveBeenCalledWith(widgetId, 'next')
|
||||
expect(mocks.setValue).toHaveBeenCalledWith(widgetId, 'callback')
|
||||
})
|
||||
|
||||
it('projects live widget store fields and mutates store state', () => {
|
||||
const widgetId = 'graph:12:prompt' as WidgetId
|
||||
const state = {
|
||||
name: 'store-name',
|
||||
label: 'Store Label',
|
||||
y: 42,
|
||||
type: 'combo',
|
||||
options: { values: ['a'] },
|
||||
value: 'a'
|
||||
}
|
||||
mocks.widgets.set(widgetId, state)
|
||||
|
||||
const widget = promotedInputWidget(input({ widgetId, label: undefined }))
|
||||
|
||||
expect(widget?.name).toBe('store-name')
|
||||
expect(widget?.label).toBe('Store Label')
|
||||
expect(widget?.y).toBe(42)
|
||||
expect(widget?.type).toBe('combo')
|
||||
expect(widget?.options).toEqual({ values: ['a'] })
|
||||
expect(widget?.value).toBe('a')
|
||||
|
||||
widget!.label = 'New Label'
|
||||
widget!.y = 52
|
||||
|
||||
expect(state.label).toBe('New Label')
|
||||
expect(state.y).toBe(52)
|
||||
})
|
||||
|
||||
it('returns null for non-promoted inputs and filters projected widget lists', () => {
|
||||
const widgetId = 'graph:12:prompt' as WidgetId
|
||||
const graphNode = node({
|
||||
inputs: [input(), input({ widgetId })]
|
||||
})
|
||||
|
||||
expect(promotedInputWidget(input())).toBeNull()
|
||||
expect(promotedInputWidgets(graphNode)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns undefined for null stored values', () => {
|
||||
const widgetId = 'graph:12:prompt' as WidgetId
|
||||
mocks.widgets.set(widgetId, { value: null })
|
||||
|
||||
expect(promotedInputWidget(input({ widgetId }))?.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('delegates input lookup to the graph node', () => {
|
||||
const widget = { name: 'prompt' } as IBaseWidget
|
||||
const backingInput = input({ widgetId: 'graph:12:prompt' as WidgetId })
|
||||
const graphNode = node({
|
||||
getSlotFromWidget: vi.fn(() => backingInput)
|
||||
})
|
||||
|
||||
expect(inputForWidget(graphNode, widget)).toBe(backingInput)
|
||||
expect(graphNode.getSlotFromWidget).toHaveBeenCalledWith(widget)
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,10 @@ import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
function promotedInputNames(host: {
|
||||
inputs: Array<{ widgetId?: unknown; name: string }>
|
||||
@@ -51,19 +55,37 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: updatePreviewsMock })
|
||||
}))
|
||||
|
||||
const addBreadcrumbMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@sentry/vue', () => ({
|
||||
addBreadcrumb: addBreadcrumbMock
|
||||
}))
|
||||
|
||||
const mockNavigation = vi.hoisted(() => ({
|
||||
stack: [] as Subgraph[]
|
||||
}))
|
||||
vi.mock('@/stores/subgraphNavigationStore', () => ({
|
||||
useSubgraphNavigationStore: () => ({
|
||||
navigationStack: mockNavigation.stack
|
||||
})
|
||||
}))
|
||||
|
||||
import {
|
||||
CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
addWidgetPromotionOptions,
|
||||
autoExposeKnownPreviewNodes,
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
hasUnpromotedWidgets,
|
||||
isLinkedPromotion,
|
||||
isPreviewPseudoWidget,
|
||||
isWidgetPromotedOnSubgraphNode,
|
||||
promoteWidget,
|
||||
promoteValueWidgetViaSubgraphInput,
|
||||
promoteRecommendedWidgets,
|
||||
pruneDisconnected,
|
||||
reorderSubgraphInputsByName,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
reorderSubgraphInputsByWidgetOrder,
|
||||
tryToggleWidgetPromotion
|
||||
} from './promotionUtils'
|
||||
|
||||
function widget(
|
||||
@@ -74,11 +96,6 @@ function widget(
|
||||
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a host SubgraphNode whose subgraph contains two source nodes that
|
||||
* share a widget name (`text`), then promotes both — forcing the second
|
||||
* promotion to be disambiguated to `text_1`.
|
||||
*/
|
||||
function buildDuplicateNamePromotion() {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
@@ -102,6 +119,11 @@ function buildDuplicateNamePromotion() {
|
||||
return { subgraph, host, nodeA, widgetA, nodeB, widgetB }
|
||||
}
|
||||
|
||||
function setupNavigation(host: SubgraphNode) {
|
||||
host.subgraph.rootGraph.add(host)
|
||||
mockNavigation.stack = [host.subgraph]
|
||||
}
|
||||
|
||||
describe('isPreviewPseudoWidget', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -303,6 +325,284 @@ describe('getPromotableWidgets', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('widget promotion actions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
addBreadcrumbMock.mockReset()
|
||||
mockNavigation.stack = []
|
||||
})
|
||||
|
||||
function setupPromotableWidget() {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
setupNavigation(host)
|
||||
const node = new LGraphNode('Prompt')
|
||||
subgraph.add(node)
|
||||
const input = node.addInput('text', 'STRING')
|
||||
input.label = 'Prompt text'
|
||||
const callback = vi.fn()
|
||||
const textWidget = node.addWidget('text', 'text', 'value', callback)
|
||||
textWidget.label = 'Prompt'
|
||||
input.widget = { name: textWidget.name }
|
||||
return { host, node, textWidget, callback }
|
||||
}
|
||||
|
||||
it('adds a promote menu option and runs the widget callback after promotion', () => {
|
||||
const { host, node, textWidget, callback } = setupPromotableWidget()
|
||||
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
|
||||
|
||||
addWidgetPromotionOptions(options, textWidget, node)
|
||||
const menuCallback = options[0]?.callback as
|
||||
| ((...args: unknown[]) => unknown)
|
||||
| undefined
|
||||
void menuCallback?.(null, undefined, undefined)
|
||||
|
||||
expect(options[0]?.content).toContain('Prompt')
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(true)
|
||||
expect(callback).toHaveBeenCalledWith('value')
|
||||
})
|
||||
|
||||
it('adds an unpromote menu option when the widget is already promoted', () => {
|
||||
const { host, node, textWidget, callback } = setupPromotableWidget()
|
||||
expect(promoteValueWidgetViaSubgraphInput(host, node, textWidget).ok).toBe(
|
||||
true
|
||||
)
|
||||
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
|
||||
|
||||
addWidgetPromotionOptions(options, textWidget, node)
|
||||
const menuCallback = options[0]?.callback as
|
||||
| ((...args: unknown[]) => unknown)
|
||||
| undefined
|
||||
void menuCallback?.(null, undefined, undefined)
|
||||
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
|
||||
false
|
||||
)
|
||||
expect(callback).toHaveBeenCalledWith('value')
|
||||
})
|
||||
|
||||
it('reports outside-subgraph promotion attempts through the toast store', () => {
|
||||
const node = new LGraphNode('Prompt')
|
||||
const textWidget = node.addWidget('text', 'text', 'value', () => {})
|
||||
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
|
||||
|
||||
addWidgetPromotionOptions(options, textWidget, node)
|
||||
|
||||
expect(useToastStore().messagesToAdd).toHaveLength(1)
|
||||
expect(options).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('toggles promotion for the widget under the canvas pointer', () => {
|
||||
const { host, node, textWidget } = setupPromotableWidget()
|
||||
const canvas = fromPartial<ReturnType<typeof useCanvasStore>['canvas']>({
|
||||
graph_mouse: [10, 20],
|
||||
visible_nodes: [node],
|
||||
setDirty: vi.fn(),
|
||||
graph: {
|
||||
getNodeOnPos: vi.fn(() => node)
|
||||
}
|
||||
})
|
||||
vi.spyOn(node, 'getWidgetOnPos').mockReturnValue(textWidget)
|
||||
useCanvasStore().canvas = canvas
|
||||
|
||||
tryToggleWidgetPromotion()
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(true)
|
||||
|
||||
tryToggleWidgetPromotion()
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves state unchanged when toggle has no node or widget target', () => {
|
||||
const { host, node, textWidget } = setupPromotableWidget()
|
||||
useCanvasStore().canvas = fromPartial<
|
||||
ReturnType<typeof useCanvasStore>['canvas']
|
||||
>({
|
||||
graph_mouse: [0, 0],
|
||||
visible_nodes: [],
|
||||
setDirty: vi.fn(),
|
||||
graph: {
|
||||
getNodeOnPos: vi.fn(() => null)
|
||||
}
|
||||
})
|
||||
|
||||
tryToggleWidgetPromotion()
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
useCanvasStore().canvas = fromPartial<
|
||||
ReturnType<typeof useCanvasStore>['canvas']
|
||||
>({
|
||||
graph_mouse: [0, 0],
|
||||
visible_nodes: [node],
|
||||
setDirty: vi.fn(),
|
||||
graph: {
|
||||
getNodeOnPos: vi.fn(() => node)
|
||||
}
|
||||
})
|
||||
vi.spyOn(node, 'getWidgetOnPos').mockReturnValue(undefined)
|
||||
|
||||
tryToggleWidgetPromotion()
|
||||
expect(isLinkedPromotion(host, String(node.id), textWidget.name)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('records a breadcrumb when value promotion has no source slot', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const node = new LGraphNode('LooseWidgetNode')
|
||||
subgraph.add(node)
|
||||
const looseWidget = node.addWidget('text', 'loose', 'value', () => {})
|
||||
|
||||
promoteWidget(node, looseWidget, [host])
|
||||
|
||||
expect(addBreadcrumbMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
level: 'warning',
|
||||
message: expect.stringContaining('missingSourceSlot')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores promotion calls for node-shaped values that are not graph nodes', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const partialNode = {
|
||||
id: toNodeId(123),
|
||||
title: 'Partial',
|
||||
type: 'Partial'
|
||||
}
|
||||
|
||||
promoteWidget(partialNode, widget({ name: 'seed', type: 'number' }), [host])
|
||||
|
||||
expect(host.subgraph.inputs).toEqual([])
|
||||
expect(addBreadcrumbMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the widget name in menu text when label is absent', () => {
|
||||
const { node, textWidget } = setupPromotableWidget()
|
||||
textWidget.label = undefined
|
||||
const options: Parameters<typeof addWidgetPromotionOptions>[0] = []
|
||||
|
||||
addWidgetPromotionOptions(options, textWidget, node)
|
||||
|
||||
expect(options[0]?.content).toContain('text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('preview promotion actions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
addBreadcrumbMock.mockReset()
|
||||
mockNavigation.stack = []
|
||||
})
|
||||
|
||||
it('identifies preview exposure as promotion only for preview pseudo widgets', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const previewNode = new LGraphNode('PreviewImage')
|
||||
previewNode.type = 'PreviewImage'
|
||||
subgraph.add(previewNode)
|
||||
const previewWidget = widget({
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
serialize: false,
|
||||
type: 'preview'
|
||||
})
|
||||
usePreviewExposureStore().addExposure(host.rootGraph.id, String(host.id), {
|
||||
sourceNodeId: previewNode.id,
|
||||
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
})
|
||||
|
||||
expect(
|
||||
isWidgetPromotedOnSubgraphNode(
|
||||
host,
|
||||
{
|
||||
sourceNodeId: previewNode.id,
|
||||
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
|
||||
},
|
||||
previewWidget
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
isWidgetPromotedOnSubgraphNode(
|
||||
host,
|
||||
{
|
||||
sourceNodeId: previewNode.id,
|
||||
sourceWidgetName: 'other'
|
||||
},
|
||||
previewWidget
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('deduplicates preview exposures when the same preview is promoted twice', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const previewNode = new LGraphNode('PreviewImage')
|
||||
previewNode.type = 'PreviewImage'
|
||||
subgraph.add(previewNode)
|
||||
const previewWidget = widget({
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
serialize: false,
|
||||
type: 'preview'
|
||||
})
|
||||
|
||||
promoteWidget(previewNode, previewWidget, [host])
|
||||
promoteWidget(previewNode, previewWidget, [host])
|
||||
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id))
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('demotes preview exposures when no linked value promotion exists', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const previewNode = new LGraphNode('PreviewImage')
|
||||
previewNode.type = 'PreviewImage'
|
||||
subgraph.add(previewNode)
|
||||
const previewWidget = widget({
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
serialize: false,
|
||||
type: 'preview'
|
||||
})
|
||||
promoteWidget(previewNode, previewWidget, [host])
|
||||
|
||||
demoteWidget(previewNode, previewWidget, [host])
|
||||
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id))
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves unexposed preview widgets unchanged when demoted', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const previewNode = new LGraphNode('PreviewImage')
|
||||
previewNode.type = 'PreviewImage'
|
||||
subgraph.add(previewNode)
|
||||
const previewWidget = widget({
|
||||
name: CANVAS_IMAGE_PREVIEW_WIDGET,
|
||||
serialize: false,
|
||||
type: 'preview'
|
||||
})
|
||||
|
||||
demoteWidget(previewNode, previewWidget, [host])
|
||||
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(host.rootGraph.id, String(host.id))
|
||||
).toEqual([])
|
||||
expect(addBreadcrumbMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining(CANVAS_IMAGE_PREVIEW_WIDGET)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('promoteRecommendedWidgets', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -346,6 +646,49 @@ describe('promoteRecommendedWidgets', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps value promotion idempotent when the widget is already linked', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('Prompt')
|
||||
const input = interiorNode.addInput('text', 'STRING')
|
||||
const textWidget = interiorNode.addWidget('text', 'text', '', () => {})
|
||||
input.widget = { name: textWidget.name }
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, textWidget)
|
||||
.ok
|
||||
).toBe(true)
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, textWidget)
|
||||
.ok
|
||||
).toBe(true)
|
||||
|
||||
expect(subgraph.inputs.map((slot) => slot.name)).toEqual(['text'])
|
||||
})
|
||||
|
||||
it('seeds outer promoted widget state from a nested promoted input', () => {
|
||||
const { host: innerHost } = buildDuplicateNamePromotion()
|
||||
writePromotedInputValue(innerHost, 'text', 'inner value')
|
||||
const outerSubgraph = createTestSubgraph()
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
outerSubgraph.add(innerHost)
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(
|
||||
outerHost,
|
||||
innerHost,
|
||||
promotedWidgetRef(innerHost, 'text')
|
||||
).ok
|
||||
).toBe(true)
|
||||
|
||||
const hostInput = outerHost.inputs.find((input) => input.name === 'text')
|
||||
if (!hostInput?.widgetId) throw new Error('Missing promoted host widget id')
|
||||
expect(useWidgetValueStore().getWidget(hostInput.widgetId)?.value).toBe(
|
||||
'inner value'
|
||||
)
|
||||
})
|
||||
|
||||
it('promotes virtual previews through preview exposures', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
@@ -414,6 +757,24 @@ describe('promoteRecommendedWidgets', () => {
|
||||
})
|
||||
expect(updatePreviewsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records a breadcrumb when a recommended value widget has no source slot', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('CLIPTextEncode')
|
||||
interiorNode.type = 'CLIPTextEncode'
|
||||
interiorNode.addWidget('text', 'text', '', () => {})
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
promoteRecommendedWidgets(subgraphNode)
|
||||
|
||||
expect(addBreadcrumbMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
level: 'warning',
|
||||
message: expect.stringContaining('missingSourceSlot')
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoExposeKnownPreviewNodes', () => {
|
||||
@@ -482,6 +843,52 @@ describe('autoExposeKnownPreviewNodes', () => {
|
||||
.map((e) => e.sourceNodeId)
|
||||
).not.toContain(String(glslNode.id))
|
||||
})
|
||||
|
||||
it('defers preview discovery for nodes without eager preview widgets', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('DeferredPreview')
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((callback) => {
|
||||
rafCallbacks.push(callback)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
try {
|
||||
autoExposeKnownPreviewNodes(subgraphNode)
|
||||
rafCallbacks[0]?.(0)
|
||||
const updateCallback = updatePreviewsMock.mock.calls[0]?.[1]
|
||||
const previewWidget = interiorNode.addWidget(
|
||||
'preview' as Parameters<typeof interiorNode.addWidget>[0],
|
||||
'preview',
|
||||
'',
|
||||
() => {}
|
||||
)
|
||||
previewWidget.serialize = false
|
||||
previewWidget.type = 'preview'
|
||||
updateCallback?.()
|
||||
|
||||
expect(updatePreviewsMock).toHaveBeenCalledWith(
|
||||
interiorNode,
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(
|
||||
usePreviewExposureStore().getExposures(
|
||||
subgraphNode.rootGraph.id,
|
||||
String(subgraphNode.id)
|
||||
)
|
||||
).toContainEqual({
|
||||
name: 'preview',
|
||||
sourceNodeId: String(interiorNode.id),
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
} finally {
|
||||
requestAnimationFrameSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasUnpromotedWidgets', () => {
|
||||
@@ -673,6 +1080,25 @@ describe('reorderSubgraphInputsByName', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves unordered names after explicitly ordered inputs', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'first', type: 'number' },
|
||||
{ name: 'second', type: 'number' },
|
||||
{ name: 'third', type: 'number' }
|
||||
]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
|
||||
reorderSubgraphInputsByName(host, ['second'])
|
||||
|
||||
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
|
||||
'second',
|
||||
'first',
|
||||
'third'
|
||||
])
|
||||
})
|
||||
|
||||
it('updates subgraph input link slot indices after reordering', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
@@ -768,6 +1194,33 @@ describe('reorderSubgraphInputsByWidgetOrder', () => {
|
||||
'first value'
|
||||
])
|
||||
})
|
||||
|
||||
it('appends promoted inputs that are absent from the widget order', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const firstNode = new LGraphNode('First')
|
||||
const secondNode = new LGraphNode('Second')
|
||||
subgraph.add(firstNode)
|
||||
subgraph.add(secondNode)
|
||||
|
||||
const firstInput = firstNode.addInput('first', 'STRING')
|
||||
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
|
||||
firstInput.widget = { name: firstWidget.name }
|
||||
const secondInput = secondNode.addInput('second', 'STRING')
|
||||
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
|
||||
secondInput.widget = { name: secondWidget.name }
|
||||
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
|
||||
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
|
||||
|
||||
reorderSubgraphInputsByWidgetOrder(host, [
|
||||
promotedWidgetRef(host, 'second')
|
||||
])
|
||||
|
||||
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
|
||||
'second',
|
||||
'first'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
@@ -798,6 +1251,23 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
return { host, interiorNode, interiorWidget }
|
||||
}
|
||||
|
||||
it('runs as a no-op for an unpromoted non-preview widget', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('TestNode')
|
||||
host.subgraph.add(interiorNode)
|
||||
const widget = interiorNode.addWidget('text', 'value', 'initial', () => {})
|
||||
|
||||
demoteWidget(interiorNode, widget, [host])
|
||||
|
||||
expect(host.subgraph.inputs).toEqual([])
|
||||
expect(addBreadcrumbMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Demoted widget "value"')
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('drops projection but keeps slot and external link when host slot is externally connected', () => {
|
||||
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
|
||||
const hostInput = host.inputs[0]
|
||||
@@ -943,4 +1413,54 @@ describe('disambiguated nested promotion identity', () => {
|
||||
|
||||
expect(outerHost.subgraph.inputs).toHaveLength(beforeCount)
|
||||
})
|
||||
|
||||
it('promotes a widget whose source widget state is missing', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('Source')
|
||||
subgraph.add(interiorNode)
|
||||
const interiorInput = interiorNode.addInput('text', 'STRING')
|
||||
const interiorWidget = interiorNode.addWidget('text', 'text', '', () => {})
|
||||
interiorInput.widget = { name: interiorWidget.name }
|
||||
interiorInput.widgetId = 'missing-widget-state' as WidgetId
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(host, interiorNode, interiorWidget).ok
|
||||
).toBe(true)
|
||||
expect(host.subgraph.inputs.map((input) => input.name)).toEqual(['text'])
|
||||
})
|
||||
|
||||
it('keeps plain inputs after ordered promoted widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'plain', type: 'STRING' }]
|
||||
})
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
|
||||
reorderSubgraphInputsByWidgetOrder(host, [
|
||||
{ widgetId: 'missing-widget-state' as WidgetId }
|
||||
])
|
||||
|
||||
expect(host.inputs.map((input) => input.name)).toEqual(['plain'])
|
||||
})
|
||||
|
||||
it('falls back to append order when promoted input links are stale', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const interiorNode = new LGraphNode('Source')
|
||||
subgraph.add(interiorNode)
|
||||
const interiorInput = interiorNode.addInput('text', 'STRING')
|
||||
const interiorWidget = interiorNode.addWidget('text', 'text', '', () => {})
|
||||
interiorInput.widget = { name: interiorWidget.name }
|
||||
|
||||
expect(
|
||||
promoteValueWidgetViaSubgraphInput(host, interiorNode, interiorWidget).ok
|
||||
).toBe(true)
|
||||
const promotedInput = host.subgraph.inputs[0]
|
||||
const linkId = promotedInput.linkIds[0]
|
||||
host.subgraph.links.delete(linkId)
|
||||
|
||||
reorderSubgraphInputsByWidgetOrder(host, [promotedWidgetRef(host, 'text')])
|
||||
|
||||
expect(host.inputs.map((input) => input.name)).toEqual(['text'])
|
||||
})
|
||||
})
|
||||
|
||||
52
src/core/graph/widgets/dynamicTypes.test.ts
Normal file
52
src/core/graph/widgets/dynamicTypes.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveInputType } from './dynamicTypes'
|
||||
|
||||
describe('resolveInputType', () => {
|
||||
it('splits concrete comma-delimited input types', () => {
|
||||
expect(resolveInputType({ type: 'MODEL,CLIP' } as never)).toEqual([
|
||||
'MODEL',
|
||||
'CLIP'
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves match-type templates from allowed types', () => {
|
||||
expect(
|
||||
resolveInputType({
|
||||
type: 'COMFY_MATCHTYPE_V3',
|
||||
template: {
|
||||
allowed_types: 'IMAGE,MASK',
|
||||
template_id: 'image'
|
||||
}
|
||||
} as never)
|
||||
).toEqual(['IMAGE', 'MASK'])
|
||||
})
|
||||
|
||||
it('returns an empty type list for invalid match-type templates', () => {
|
||||
expect(resolveInputType({ type: 'COMFY_MATCHTYPE_V3' } as never)).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves autogrow templates from required and optional inputs', () => {
|
||||
expect(
|
||||
resolveInputType({
|
||||
type: 'COMFY_AUTOGROW_V3',
|
||||
template: {
|
||||
input: {
|
||||
required: {
|
||||
image: ['IMAGE', {}]
|
||||
},
|
||||
optional: {
|
||||
mask: ['MASK,IMAGE', {}]
|
||||
}
|
||||
}
|
||||
}
|
||||
} as never)
|
||||
).toEqual(['IMAGE', 'MASK', 'IMAGE'])
|
||||
})
|
||||
|
||||
it('returns an empty type list for invalid autogrow templates', () => {
|
||||
expect(resolveInputType({ type: 'COMFY_AUTOGROW_V3' } as never)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,19 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { HasInitialMinSize } from '@/services/litegraphService'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { applyDynamicInputs, dynamicWidgets } from './dynamicWidgets'
|
||||
|
||||
setActivePinia(createTestingPinia())
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
type DynamicInputs = ('INT' | 'STRING' | 'IMAGE' | DynamicInputs)[][]
|
||||
type TestAutogrowNode = LGraphNode & {
|
||||
comfyDynamic: { autogrow: Record<string, unknown> }
|
||||
@@ -15,6 +21,13 @@ type TestAutogrowNode = LGraphNode & {
|
||||
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
fromAny<{ configuringGraphLevel: number }, unknown>(
|
||||
app
|
||||
).configuringGraphLevel = 0
|
||||
})
|
||||
|
||||
function nextTick() {
|
||||
return new Promise<void>((r) => requestAnimationFrame(() => r()))
|
||||
}
|
||||
@@ -56,6 +69,23 @@ function addAutogrow(node: LGraphNode, template: unknown) {
|
||||
})
|
||||
)
|
||||
}
|
||||
function addMatchType(
|
||||
node: LGraphNode,
|
||||
name: string,
|
||||
allowedTypes = '*',
|
||||
templateId = 'a'
|
||||
) {
|
||||
addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(
|
||||
[
|
||||
'COMFY_MATCHTYPE_V3',
|
||||
{ template: { allowed_types: allowedTypes, template_id: templateId } }
|
||||
],
|
||||
{ name, isOptional: false }
|
||||
)
|
||||
)
|
||||
}
|
||||
function connectInput(node: LGraphNode, inputIndex: number, graph: LGraph) {
|
||||
const node2 = testNode()
|
||||
node2.addOutput('out', '*')
|
||||
@@ -116,7 +146,312 @@ describe('Dynamic Combos', () => {
|
||||
node.widgets[0].value = '1'
|
||||
expect.soft(node.widgets[1].tooltip).toBe('1')
|
||||
})
|
||||
|
||||
test('throws for malformed dynamic combo specs before creating a widget', () => {
|
||||
const node = testNode()
|
||||
const comboApp = { widgets: { COMBO: vi.fn() } } as unknown as Parameters<
|
||||
typeof dynamicWidgets.COMFY_DYNAMICCOMBO_V3
|
||||
>[3]
|
||||
|
||||
expect(() =>
|
||||
dynamicWidgets.COMFY_DYNAMICCOMBO_V3(
|
||||
node,
|
||||
'bad',
|
||||
['COMFY_DYNAMICCOMBO_V3', {}] as InputSpec,
|
||||
comboApp
|
||||
)
|
||||
).toThrow('invalid DynamicCombo spec')
|
||||
expect(comboApp.widgets.COMBO).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('clears grouped widgets when selection becomes empty', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['INT', 'STRING']])
|
||||
node.widgets[0].value = '1'
|
||||
const onRemove = vi.fn()
|
||||
node.widgets[1].onRemove = onRemove
|
||||
|
||||
node.widgets[0].value = undefined
|
||||
|
||||
expect(onRemove).toHaveBeenCalled()
|
||||
expect(node.widgets).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('deletes widget state when removing grouped dynamic widgets', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addDynamicCombo(node, [['INT'], ['STRING']])
|
||||
const childWidget = node.widgets[1]
|
||||
const childWidgetId = childWidget.widgetId
|
||||
if (!childWidgetId) throw new Error('Missing child widget id')
|
||||
const deleteWidget = vi.mocked(useWidgetValueStore().deleteWidget)
|
||||
|
||||
node.widgets[0].value = undefined
|
||||
|
||||
expect(deleteWidget).toHaveBeenCalledWith(childWidgetId)
|
||||
})
|
||||
|
||||
test('preserves an existing dynamic input link when refreshing a selection', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const onConnectionsChange = vi.fn()
|
||||
node.onConnectionsChange = onConnectionsChange
|
||||
graph.add(node)
|
||||
addDynamicCombo(node, [['IMAGE'], ['STRING']])
|
||||
node.widgets[0].value = '0'
|
||||
|
||||
connectInput(node, 1, graph)
|
||||
const linkId = node.inputs[1].link
|
||||
expect(linkId).not.toBeNull()
|
||||
onConnectionsChange.mockClear()
|
||||
|
||||
node.widgets[0].value = '0'
|
||||
|
||||
expect(node.inputs[1].link).toBe(linkId)
|
||||
expect(graph.links[linkId!].target_slot).toBe(1)
|
||||
expect(onConnectionsChange).toHaveBeenCalledWith(
|
||||
LiteGraph.INPUT,
|
||||
1,
|
||||
true,
|
||||
graph.links[linkId!],
|
||||
node.inputs[1]
|
||||
)
|
||||
})
|
||||
|
||||
test('throws if the backing widgets array disappears during update', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['STRING']])
|
||||
const controller = node.widgets[0]
|
||||
node.widgets = undefined as unknown as typeof node.widgets
|
||||
|
||||
expect(() => {
|
||||
controller.value = '1'
|
||||
}).toThrow('Not Reachable')
|
||||
})
|
||||
|
||||
test('throws when the dynamic controller widget is missing during update', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['STRING']])
|
||||
const controller = node.widgets[0]
|
||||
node.widgets = node.widgets.slice(1)
|
||||
|
||||
expect(() => {
|
||||
controller.value = '1'
|
||||
}).toThrow("Dynamic widget doesn't exist on node")
|
||||
})
|
||||
|
||||
test('throws when input-only dynamic sockets have no insertion point', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['IMAGE']])
|
||||
const controller = node.widgets[0]
|
||||
node.inputs = []
|
||||
|
||||
expect(() => {
|
||||
controller.value = '1'
|
||||
}).toThrow('Failed to find input socket for 0')
|
||||
})
|
||||
|
||||
test('updates dynamic inputs without requiring a graph', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['IMAGE']])
|
||||
|
||||
node.widgets[0].value = '1'
|
||||
|
||||
expect(node.inputs[1].type).toBe('IMAGE')
|
||||
})
|
||||
|
||||
test('reads dynamic combo values from widget state when available', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addDynamicCombo(node, [['INT'], ['STRING']])
|
||||
const controller = node.widgets[0]
|
||||
const controllerId = controller.widgetId
|
||||
if (!controllerId) throw new Error('Missing controller widget id')
|
||||
|
||||
controller.value = '1'
|
||||
useWidgetValueStore().setValue(controllerId, '0')
|
||||
|
||||
expect(controller.value).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dynamic input dispatch', () => {
|
||||
test('returns false for unknown dynamic input types', () => {
|
||||
const node = testNode()
|
||||
|
||||
expect(
|
||||
applyDynamicInputs(node, {
|
||||
name: 'plain',
|
||||
type: 'STRING',
|
||||
isOptional: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('returns true after applying a known dynamic input type', () => {
|
||||
const node = testNode()
|
||||
|
||||
expect(
|
||||
applyDynamicInputs(
|
||||
node,
|
||||
transformInputSpecV1ToV2(
|
||||
[
|
||||
'COMFY_AUTOGROW_V3',
|
||||
{ template: { input: { required: { image: ['IMAGE', {}] } } } }
|
||||
],
|
||||
{ name: 'grow', isOptional: false }
|
||||
)
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('throws when an autogrow input spec is malformed', () => {
|
||||
const node = testNode()
|
||||
const inputSpec = {
|
||||
name: 'bad',
|
||||
type: 'COMFY_AUTOGROW_V3'
|
||||
} as InputSpecV2
|
||||
|
||||
expect(() => addNodeInput(node, inputSpec)).toThrow('invalid Autogrow spec')
|
||||
})
|
||||
|
||||
test('ignores malformed match type specs', () => {
|
||||
const node = testNode()
|
||||
|
||||
expect(
|
||||
applyDynamicInputs(node, {
|
||||
name: 'bad',
|
||||
type: 'COMFY_MATCHTYPE_V3',
|
||||
isOptional: false
|
||||
})
|
||||
).toBe(true)
|
||||
expect(node.inputs).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MatchType inputs', () => {
|
||||
function createMatchTypeNode(graph: LGraph, outputMatchTypes = ['a']) {
|
||||
const node = testNode()
|
||||
node.constructor.nodeData = {
|
||||
name: 'testnode',
|
||||
output_matchtypes: outputMatchTypes
|
||||
} as typeof node.constructor.nodeData
|
||||
node.addOutput('out', '*')
|
||||
graph.add(node)
|
||||
addMatchType(node, 'on_true')
|
||||
addMatchType(node, 'on_false')
|
||||
return node
|
||||
}
|
||||
|
||||
function createSourceNode(graph: LGraph, type: string) {
|
||||
const node = testNode()
|
||||
node.addOutput('out', type)
|
||||
graph.add(node)
|
||||
return node
|
||||
}
|
||||
|
||||
test('ignores match type notifications outside registered inputs', () => {
|
||||
const graph = new LGraph()
|
||||
const node = createMatchTypeNode(graph)
|
||||
node.addInput('plain', 'STRING')
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.OUTPUT, 0, true, null, node.inputs[0])
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 2, true, null, node.inputs[2])
|
||||
|
||||
expect(node.outputs[0].type).toBe('*')
|
||||
})
|
||||
|
||||
test('uses wildcard types for stale match type links', () => {
|
||||
const graph = new LGraph()
|
||||
const node = createMatchTypeNode(graph)
|
||||
node.inputs[0].link = toLinkId(999)
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 1, false, null, node.inputs[1])
|
||||
|
||||
expect(node.outputs[0].type).toBe('*')
|
||||
})
|
||||
|
||||
test('leaves unmatched output groups unchanged', () => {
|
||||
const graph = new LGraph()
|
||||
const node = createMatchTypeNode(graph, ['other'])
|
||||
const source = createSourceNode(graph, 'IMAGE')
|
||||
|
||||
source.connect(0, node, 0)
|
||||
|
||||
expect(node.outputs[0].type).toBe('*')
|
||||
})
|
||||
|
||||
test('throws when match group input constraints cannot overlap', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation(() => 1)
|
||||
node.constructor.nodeData = {
|
||||
name: 'testnode',
|
||||
output_matchtypes: ['a']
|
||||
} as typeof node.constructor.nodeData
|
||||
node.addOutput('out', '*')
|
||||
graph.add(node)
|
||||
addMatchType(node, 'image', 'IMAGE')
|
||||
addMatchType(node, 'latent', 'LATENT')
|
||||
const source = createSourceNode(graph, 'IMAGE')
|
||||
|
||||
try {
|
||||
expect(() => source.connect(0, node, 0)).toThrow('invalid connection')
|
||||
} finally {
|
||||
requestAnimationFrameSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('disconnects downstream links when a match type output narrows', () => {
|
||||
const graph = new LGraph()
|
||||
const node = createMatchTypeNode(graph)
|
||||
const downstream = testNode()
|
||||
downstream.addInput('latent', 'LATENT')
|
||||
downstream.onConnectionsChange = vi.fn()
|
||||
graph.add(downstream)
|
||||
node.connect(0, downstream, 0)
|
||||
const source = createSourceNode(graph, 'IMAGE')
|
||||
|
||||
source.connect(0, node, 0)
|
||||
|
||||
expect(downstream.inputs[0].link).toBeNull()
|
||||
expect(downstream.onConnectionsChange).toHaveBeenCalledWith(
|
||||
LiteGraph.INPUT,
|
||||
0,
|
||||
false,
|
||||
expect.anything(),
|
||||
downstream.inputs[0]
|
||||
)
|
||||
})
|
||||
|
||||
test('ignores deferred match type refresh after the input is removed', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((callback) => {
|
||||
rafCallbacks.push(callback)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
graph.add(node)
|
||||
|
||||
try {
|
||||
addMatchType(node, 'removed')
|
||||
node.inputs.pop()
|
||||
rafCallbacks[0]?.(0)
|
||||
|
||||
expect(node.inputs).toHaveLength(0)
|
||||
} finally {
|
||||
requestAnimationFrameSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Autogrow', () => {
|
||||
const inputsSpec = { required: { image: ['IMAGE', {}] } }
|
||||
test('Can name by prefix', () => {
|
||||
@@ -162,6 +497,259 @@ describe('Autogrow', () => {
|
||||
connectInput(node, 2, graph)
|
||||
expect(node.inputs.length).toBe(3)
|
||||
})
|
||||
|
||||
test('ignores autogrow notifications that cannot affect a known input group', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
const inputCount = node.inputs.length
|
||||
const unknownInput = node.addInput('outside.0', 'IMAGE')
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.OUTPUT, 0, true, null, node.inputs[0])
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
99,
|
||||
true,
|
||||
null,
|
||||
fromAny<
|
||||
Parameters<NonNullable<typeof node.onConnectionsChange>>[4],
|
||||
unknown
|
||||
>(undefined)
|
||||
)
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 2, true, null, unknownInput)
|
||||
|
||||
expect(node.inputs).toHaveLength(inputCount + 1)
|
||||
})
|
||||
|
||||
test('does not grow autogrow inputs when connection metadata is missing', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 1, true, null, node.inputs[1])
|
||||
|
||||
expect(node.inputs).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('keeps minimum autogrow rows when disconnecting early ordinals', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 2, input: inputsSpec, prefix: 'test' })
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 0, false, null, node.inputs[0])
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs).toHaveLength(3)
|
||||
})
|
||||
|
||||
test('restores a configure-time autogrow widget shim', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
node.inputs[1].widget = { name: node.inputs[1].name }
|
||||
fromAny<{ configuringGraphLevel: number }, unknown>(
|
||||
app
|
||||
).configuringGraphLevel = 1
|
||||
|
||||
connectInput(node, 1, graph)
|
||||
|
||||
expect(node.widgets.some((widget) => widget.name === '0.test1')).toBe(true)
|
||||
})
|
||||
|
||||
test('draws configure-time autogrow shim text from the input name', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
node.inputs[1].widget = { name: node.inputs[1].name }
|
||||
fromAny<{ configuringGraphLevel: number }, unknown>(
|
||||
app
|
||||
).configuringGraphLevel = 1
|
||||
|
||||
connectInput(node, 1, graph)
|
||||
const shim = node.widgets.find((widget) => widget.name === '0.test1')
|
||||
if (!shim?.draw) throw new Error('Missing shim widget')
|
||||
node.inputs[1].label = undefined
|
||||
const ctx = fromAny<CanvasRenderingContext2D, unknown>({
|
||||
save: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
restore: vi.fn()
|
||||
})
|
||||
|
||||
shim.draw(ctx, node, 100, 10, 20)
|
||||
|
||||
expect(ctx.fillText).toHaveBeenCalledWith('0.test1', 20, 25)
|
||||
})
|
||||
|
||||
test('keeps an existing configure-time autogrow widget shim', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
node.inputs[1].widget = { name: node.inputs[1].name }
|
||||
node.widgets.push({
|
||||
name: node.inputs[1].name,
|
||||
type: 'shim',
|
||||
y: 0,
|
||||
options: {},
|
||||
serialize: false,
|
||||
draw: vi.fn()
|
||||
})
|
||||
fromAny<{ configuringGraphLevel: number }, unknown>(
|
||||
app
|
||||
).configuringGraphLevel = 1
|
||||
|
||||
connectInput(node, 1, graph)
|
||||
|
||||
expect(
|
||||
node.widgets.filter((widget) => widget.name === '0.test1')
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('defers disconnect handling during an input swap', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((callback) => {
|
||||
rafCallbacks.push(callback)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
|
||||
try {
|
||||
connectInput(node, 0, graph)
|
||||
node.disconnectInput(0)
|
||||
|
||||
expect(node.inputs).toHaveLength(2)
|
||||
expect(rafCallbacks).toHaveLength(2)
|
||||
} finally {
|
||||
requestAnimationFrameSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('stops cleanup for uneven multi-input autogrow groups', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
graph.add(node)
|
||||
addAutogrow(node, {
|
||||
min: 1,
|
||||
input: { required: { image: ['IMAGE', {}], mask: ['MASK', {}] } }
|
||||
})
|
||||
node.inputs.pop()
|
||||
|
||||
try {
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
0,
|
||||
false,
|
||||
null,
|
||||
node.inputs[0]
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to group multi-input autogrow inputs'
|
||||
)
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps trailing autogrow row when disconnecting the last slot', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
|
||||
node.onConnectionsChange?.(LiteGraph.INPUT, 1, false, null, node.inputs[1])
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.test0',
|
||||
'0.test1'
|
||||
])
|
||||
})
|
||||
|
||||
test('ignores named autogrow input names outside the configured list', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, names: ['a', 'b'] })
|
||||
const unknownInput = node.addInput('0.c', 'IMAGE')
|
||||
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
node.inputs.length - 1,
|
||||
false,
|
||||
null,
|
||||
unknownInput
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.a',
|
||||
'0.b',
|
||||
'0.c'
|
||||
])
|
||||
})
|
||||
|
||||
test('ignores autogrow input names without numeric ordinals', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
const unknownInput = node.addInput('0.testx', 'IMAGE')
|
||||
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
node.inputs.length - 1,
|
||||
false,
|
||||
null,
|
||||
unknownInput
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.test0',
|
||||
'0.test1',
|
||||
'0.testx'
|
||||
])
|
||||
})
|
||||
|
||||
test('marks optional autogrow inputs as optional after required inputs', () => {
|
||||
const node = testNode()
|
||||
|
||||
addAutogrow(node, {
|
||||
min: 1,
|
||||
input: {
|
||||
required: { image: ['IMAGE', {}] },
|
||||
optional: { mask: ['MASK', {}] }
|
||||
}
|
||||
})
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.image0',
|
||||
'0.mask0',
|
||||
'0.image1',
|
||||
'0.mask1'
|
||||
])
|
||||
expect(node.inputs.map((input) => input.type)).toEqual([
|
||||
'IMAGE',
|
||||
'MASK',
|
||||
'IMAGE',
|
||||
'MASK'
|
||||
])
|
||||
})
|
||||
test('Removing connections decreases to min + 1', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
@@ -258,6 +846,42 @@ describe('Autogrow', () => {
|
||||
expect(vid0Link).not.toBeNull()
|
||||
expect(graph.links[vid0Link!].target_slot).toBe(vid0Index)
|
||||
})
|
||||
|
||||
test('removes shim widgets when multi-input autogrow rows shrink', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
graph.add(node)
|
||||
addAutogrow(node, {
|
||||
min: 1,
|
||||
input: { required: { image: ['IMAGE', {}], mask: ['MASK', {}] } }
|
||||
})
|
||||
connectInput(node, 2, graph)
|
||||
await nextTick()
|
||||
expect(node.inputs).toHaveLength(6)
|
||||
|
||||
const removedWidgetNames = ['0.image2', '0.mask2']
|
||||
const onRemove = vi.fn()
|
||||
for (const widget of node.widgets.filter((widget) =>
|
||||
removedWidgetNames.includes(widget.name)
|
||||
)) {
|
||||
widget.onRemove = onRemove
|
||||
}
|
||||
|
||||
node.disconnectInput(2)
|
||||
await nextTick()
|
||||
|
||||
expect(node.inputs.map((input) => input.name)).toEqual([
|
||||
'0.image0',
|
||||
'0.mask0',
|
||||
'0.image1',
|
||||
'0.mask1'
|
||||
])
|
||||
expect(onRemove).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
node.widgets.some((widget) => removedWidgetNames.includes(widget.name))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('Can deserialize a complex node', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user