mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-06 14:11:55 +00:00
Compare commits
18 Commits
fix/error-
...
austin/dnd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
223831c514 | ||
|
|
bbbb55c410 | ||
|
|
9e16390c33 | ||
|
|
c88275b2a4 | ||
|
|
23e48b2140 | ||
|
|
af43619ae1 | ||
|
|
d2e88011aa | ||
|
|
180a0001e8 | ||
|
|
8f011225bf | ||
|
|
3c50487c18 | ||
|
|
818e549e8e | ||
|
|
fd1a8e9432 | ||
|
|
8f61ecd82e | ||
|
|
8fe0385a57 | ||
|
|
d078af3a79 | ||
|
|
57d708767a | ||
|
|
b3f5f82216 | ||
|
|
9df4e02189 |
@@ -1,4 +1,4 @@
|
||||
import type { Mouse } from '@playwright/test'
|
||||
import type { Locator, Mouse } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
@@ -72,6 +72,22 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async resizeByDragging(
|
||||
element: Locator,
|
||||
{ x, y }: { x?: number; y?: number }
|
||||
) {
|
||||
const elementBox = await element.boundingBox()
|
||||
if (!elementBox) throw new Error('element should have layout')
|
||||
|
||||
const cx = elementBox.x + elementBox.width / 2
|
||||
const cy = elementBox.y + elementBox.height / 2
|
||||
|
||||
await this.dragAndDrop(
|
||||
{ x: cx, y: cy },
|
||||
{ x: cx + (x ?? 0), y: cy + (y ?? 0) }
|
||||
)
|
||||
}
|
||||
|
||||
//#region Pass-through
|
||||
async click(...args: Parameters<Mouse['click']>) {
|
||||
return await this.mouse.click(...args)
|
||||
|
||||
@@ -17,6 +17,9 @@ export class ComfyNodeSearchBoxV2 {
|
||||
readonly filterChips: Locator
|
||||
readonly noResults: Locator
|
||||
readonly nodeIdBadge: Locator
|
||||
readonly sidebarToggle: Locator
|
||||
readonly sidebarBackdrop: Locator
|
||||
readonly filterChipsScroll: Locator
|
||||
|
||||
constructor(private comfyPage: ComfyPage) {
|
||||
const page = comfyPage.page
|
||||
@@ -28,6 +31,11 @@ export class ComfyNodeSearchBoxV2 {
|
||||
this.filterChips = this.dialog.getByTestId(searchBoxV2.filterChip)
|
||||
this.noResults = this.dialog.getByTestId(searchBoxV2.noResults)
|
||||
this.nodeIdBadge = this.dialog.getByTestId(searchBoxV2.nodeIdBadge)
|
||||
this.sidebarToggle = this.dialog.getByTestId(searchBoxV2.sidebarToggle)
|
||||
this.sidebarBackdrop = this.dialog.getByTestId(searchBoxV2.sidebarBackdrop)
|
||||
this.filterChipsScroll = this.dialog.getByTestId(
|
||||
searchBoxV2.filterChipsScroll
|
||||
)
|
||||
}
|
||||
|
||||
/** Sidebar category tree button (e.g. `sampling`, `sampling/custom_sampling`). */
|
||||
|
||||
@@ -7,17 +7,19 @@ import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { nextFrame } from '@e2e/fixtures/utils/timing'
|
||||
|
||||
type DragAndDropOptions = {
|
||||
fileName?: string
|
||||
url?: string
|
||||
dropPosition?: Position
|
||||
waitForUpload?: boolean
|
||||
preserveNativePropagation?: boolean
|
||||
}
|
||||
|
||||
export class DragDropHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async dragAndDropExternalResource(
|
||||
options: {
|
||||
fileName?: string
|
||||
url?: string
|
||||
dropPosition?: Position
|
||||
waitForUpload?: boolean
|
||||
preserveNativePropagation?: boolean
|
||||
} = {}
|
||||
options: DragAndDropOptions = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
dropPosition = { x: 100, y: 100 },
|
||||
@@ -143,17 +145,14 @@ export class DragDropHelper {
|
||||
|
||||
async dragAndDropFile(
|
||||
fileName: string,
|
||||
options: { dropPosition?: Position; waitForUpload?: boolean } = {}
|
||||
options: DragAndDropOptions = {}
|
||||
): Promise<void> {
|
||||
return this.dragAndDropExternalResource({ fileName, ...options })
|
||||
}
|
||||
|
||||
async dragAndDropURL(
|
||||
url: string,
|
||||
options: {
|
||||
dropPosition?: Position
|
||||
preserveNativePropagation?: boolean
|
||||
} = {}
|
||||
options: DragAndDropOptions = {}
|
||||
): Promise<void> {
|
||||
return this.dragAndDropExternalResource({ url, ...options })
|
||||
}
|
||||
|
||||
@@ -210,7 +210,8 @@ export const TestIds = {
|
||||
},
|
||||
queue: {
|
||||
overlayToggle: 'queue-overlay-toggle',
|
||||
clearHistoryAction: 'clear-history-action'
|
||||
clearHistoryAction: 'clear-history-action',
|
||||
jobAssetsList: 'job-assets-list'
|
||||
},
|
||||
errors: {
|
||||
imageLoadError: 'error-loading-image',
|
||||
@@ -261,6 +262,9 @@ export const TestIds = {
|
||||
chipDelete: 'chip-delete',
|
||||
noResults: 'no-results',
|
||||
nodeIdBadge: 'node-id-badge',
|
||||
sidebarToggle: 'toggle-category-sidebar',
|
||||
sidebarBackdrop: 'sidebar-backdrop',
|
||||
filterChipsScroll: 'filter-chips-scroll',
|
||||
category: (id: string) => `category-${id}`,
|
||||
rootCategory: (id: string) => `search-category-${id}`,
|
||||
typeFilter: (key: 'input' | 'output') => `search-filter-${key}`
|
||||
|
||||
@@ -125,4 +125,151 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Category sidebar', () => {
|
||||
test('Sidebar toggle hides and shows the category sidebar', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await searchBoxV2.open()
|
||||
|
||||
const samplingCategory = searchBoxV2.categoryButton('sampling')
|
||||
await expect(samplingCategory).toBeVisible()
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
)
|
||||
|
||||
await searchBoxV2.sidebarToggle.click()
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
)
|
||||
await expect(samplingCategory).toBeHidden()
|
||||
|
||||
await searchBoxV2.sidebarToggle.click()
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
)
|
||||
await expect(samplingCategory).toBeVisible()
|
||||
})
|
||||
|
||||
test('Filter bar scrolls horizontally while the sidebar toggle stays pinned', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
// Narrow viewport so the chips overflow the filter bar
|
||||
await comfyPage.page.setViewportSize({ width: 360, height: 800 })
|
||||
await searchBoxV2.open()
|
||||
|
||||
const scrollEl = searchBoxV2.filterChipsScroll
|
||||
const dims = await scrollEl.evaluate((el) => ({
|
||||
scrollWidth: el.scrollWidth,
|
||||
clientWidth: el.clientWidth
|
||||
}))
|
||||
expect(dims.scrollWidth).toBeGreaterThan(dims.clientWidth)
|
||||
|
||||
await scrollEl.evaluate((el) => {
|
||||
el.scrollLeft = el.scrollWidth
|
||||
})
|
||||
|
||||
// The toggle lives outside the scroll container, so even when the
|
||||
// chips scroll hundreds of px it must remain visible in the viewport.
|
||||
await expect(searchBoxV2.sidebarToggle).toBeInViewport()
|
||||
})
|
||||
|
||||
test('@mobile Sidebar is collapsed by default on mobile', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await searchBoxV2.open()
|
||||
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
)
|
||||
await expect(searchBoxV2.categoryButton('sampling')).toBeHidden()
|
||||
})
|
||||
|
||||
test('@mobile Clicking outside the sidebar closes it', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.sidebarToggle.click()
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
)
|
||||
await expect(searchBoxV2.categoryButton('sampling')).toBeVisible()
|
||||
await expect(searchBoxV2.sidebarBackdrop).toBeVisible()
|
||||
|
||||
// The backdrop spans the full content area, but the sidebar (z-20)
|
||||
// covers its left ~208px (w-52). Click past that to land on the
|
||||
// backdrop rather than the sidebar.
|
||||
await searchBoxV2.sidebarBackdrop.click({ position: { x: 240, y: 40 } })
|
||||
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
)
|
||||
await expect(searchBoxV2.categoryButton('sampling')).toBeHidden()
|
||||
await expect(searchBoxV2.sidebarBackdrop).toBeHidden()
|
||||
})
|
||||
|
||||
test('@mobile Focusing the search input closes the sidebar', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
await searchBoxV2.open()
|
||||
|
||||
await searchBoxV2.sidebarToggle.click()
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
)
|
||||
|
||||
await searchBoxV2.input.focus()
|
||||
|
||||
await expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
)
|
||||
})
|
||||
|
||||
test('Sidebar state across mobile/desktop resizes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { searchBoxV2 } = comfyPage
|
||||
const switchToDesktop = () =>
|
||||
comfyPage.page.setViewportSize({ width: 1280, height: 800 })
|
||||
const switchToMobile = () =>
|
||||
comfyPage.page.setViewportSize({ width: 360, height: 800 })
|
||||
const expectExpanded = (value: 'true' | 'false') =>
|
||||
expect(searchBoxV2.sidebarToggle).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
value
|
||||
)
|
||||
|
||||
await switchToDesktop()
|
||||
await searchBoxV2.open()
|
||||
await expectExpanded('true')
|
||||
|
||||
await switchToMobile()
|
||||
await expectExpanded('false')
|
||||
|
||||
await searchBoxV2.sidebarToggle.click()
|
||||
await switchToDesktop()
|
||||
await expectExpanded('true')
|
||||
|
||||
await searchBoxV2.sidebarToggle.click()
|
||||
await switchToMobile()
|
||||
await expectExpanded('false')
|
||||
|
||||
await switchToDesktop()
|
||||
await expectExpanded('false')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
121
browser_tests/tests/queue/queueSettings.spec.ts
Normal file
121
browser_tests/tests/queue/queueSettings.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
import type { Locator, Page, Request } from '@playwright/test'
|
||||
import type { JobsListResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJobs } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const TOTAL_MOCK_JOBS = 20
|
||||
const overflowJobsListRoutePattern = '**/api/jobs?*'
|
||||
|
||||
function isHistoryJobsRequest(url: string): boolean {
|
||||
if (!url.includes('/api/jobs')) return false
|
||||
const params = new URL(url).searchParams
|
||||
const statuses = (params.get('status') ?? '').split(',')
|
||||
return statuses.includes('completed')
|
||||
}
|
||||
|
||||
async function captureNextHistoryRequest(
|
||||
comfyPage: ComfyPage,
|
||||
exec: ExecutionHelper
|
||||
): Promise<Request> {
|
||||
const requestPromise = comfyPage.page.waitForRequest(
|
||||
(req) => isHistoryJobsRequest(req.url()),
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
exec.status(0)
|
||||
return requestPromise
|
||||
}
|
||||
|
||||
function getJobListResults(page: Page): Locator {
|
||||
return page.getByTestId(TestIds.queue.jobAssetsList).locator('[data-job-id]')
|
||||
}
|
||||
|
||||
test.describe('Queue settings', { tag: '@canvas' }, () => {
|
||||
test.describe('Comfy.Queue.MaxHistoryItems', () => {
|
||||
test.describe('limit query parameter', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(
|
||||
createMockJobs(TOTAL_MOCK_JOBS)
|
||||
)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('limit query parameter on /api/jobs reflects the setting', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const TARGET_LIMIT = 6
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Queue.MaxHistoryItems',
|
||||
TARGET_LIMIT
|
||||
)
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
const request = await captureNextHistoryRequest(comfyPage, exec)
|
||||
const url = new URL(request.url())
|
||||
expect(url.searchParams.get('limit')).toBe(String(TARGET_LIMIT))
|
||||
})
|
||||
})
|
||||
|
||||
test('queue panel caps history items to the configured number', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
// Add a mock route that returns all jobs regardless of the request's `limit` param
|
||||
const overflowJobs = createMockJobs(TOTAL_MOCK_JOBS)
|
||||
await comfyPage.page.route(
|
||||
overflowJobsListRoutePattern,
|
||||
async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (!url.searchParams.get('status')?.includes('completed')) {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
const response = {
|
||||
jobs: overflowJobs,
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: overflowJobs.length,
|
||||
total: overflowJobs.length,
|
||||
has_more: false
|
||||
}
|
||||
} satisfies {
|
||||
jobs: unknown[]
|
||||
pagination: JobsListResponse['pagination']
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const VISIBLE_LIMIT = 6
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Queue.MaxHistoryItems',
|
||||
VISIBLE_LIMIT
|
||||
)
|
||||
const exec = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
await captureNextHistoryRequest(comfyPage, exec)
|
||||
|
||||
await comfyPage.page.getByTestId(TestIds.queue.overlayToggle).click()
|
||||
const jobs = getJobListResults(comfyPage.page)
|
||||
await expect(jobs.first()).toBeVisible()
|
||||
await expect(jobs).toHaveCount(VISIBLE_LIMIT)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -364,6 +364,34 @@ test.describe('Workflows sidebar', () => {
|
||||
.toEqual(['*Unsaved Workflow', '*workflow1 (Copy)'])
|
||||
})
|
||||
|
||||
test('Can upload workflow to library by drag and drop', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
|
||||
expect(await workflowsTab.getTopLevelSavedWorkflowNames()).not.toContain(
|
||||
'default'
|
||||
)
|
||||
|
||||
const sidebarBox = (await comfyPage.page
|
||||
.locator('.workflows-sidebar-tab')
|
||||
.boundingBox())!
|
||||
const dropPosition = {
|
||||
x: sidebarBox.x + sidebarBox.width / 2,
|
||||
y: sidebarBox.y + sidebarBox.height / 2
|
||||
}
|
||||
await comfyPage.dragDrop.dragAndDropFile('default.json', {
|
||||
dropPosition,
|
||||
preserveNativePropagation: true
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => workflowsTab.getTopLevelSavedWorkflowNames())
|
||||
.toContain('default')
|
||||
})
|
||||
|
||||
test('Can drop workflow from workflows sidebar', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({
|
||||
'workflow1.json': 'default.json'
|
||||
|
||||
50
browser_tests/tests/vueNodes/widgets/legacy.spec.ts
Normal file
50
browser_tests/tests/vueNodes/widgets/legacy.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test('@vue-nodes In App Mode, widget width updates with panel size', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
await test.step('setup', async () => {
|
||||
await comfyPage.nodeOps.addNode('DevToolsNodeWithLegacyWidget', undefined, {
|
||||
x: 0,
|
||||
y: 0
|
||||
})
|
||||
await comfyPage.appMode.enterAppModeWithInputs([['10', 'legacy_widget']])
|
||||
})
|
||||
|
||||
const getWidth = () =>
|
||||
comfyPage.page.evaluate(
|
||||
() => graph!.getNodeById(10)!.widgets![0].width ?? 0
|
||||
)
|
||||
|
||||
await test.step('Mouse clicks resolve to button regions', async () => {
|
||||
const legacyWidget = comfyPage.appMode.linearWidgets.locator('canvas')
|
||||
const { width, height } = (await legacyWidget.boundingBox())!
|
||||
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(10)
|
||||
const legacyWidgetRef = await nodeRef.getWidget(0)
|
||||
expect(await legacyWidgetRef.getValue()).toBe(0)
|
||||
await legacyWidget.click({ position: { x: 20, y: height / 2 } })
|
||||
await expect.poll(() => legacyWidgetRef.getValue()).toBe(-1)
|
||||
await legacyWidget.click({ position: { x: width - 20, y: height / 2 } })
|
||||
await expect.poll(() => legacyWidgetRef.getValue()).toBe(0)
|
||||
})
|
||||
|
||||
await test.step('Resize to update width', async () => {
|
||||
const initialWidth = await getWidth()
|
||||
expect(initialWidth).toBeGreaterThan(0)
|
||||
|
||||
const gutter = comfyPage.page.getByRole('separator')
|
||||
|
||||
await expect(gutter).toBeVisible()
|
||||
await comfyMouse.resizeByDragging(gutter, { x: -200 })
|
||||
await expect.poll(getWidth).toBeGreaterThan(initialWidth)
|
||||
const intermediateWidth = await getWidth()
|
||||
|
||||
await comfyMouse.resizeByDragging(gutter, { x: 100 })
|
||||
await expect.poll(getWidth).toBeLessThan(intermediateWidth)
|
||||
})
|
||||
})
|
||||
@@ -55,7 +55,9 @@ const config: KnipConfig = {
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
|
||||
// Agent review check config, not part of the build
|
||||
'.agents/checks/eslint.strict.config.js'
|
||||
'.agents/checks/eslint.strict.config.js',
|
||||
// Devtools extensions, included dynamically
|
||||
'tools/devtools/web/**'
|
||||
],
|
||||
vite: {
|
||||
config: ['vite?(.*).config.mts']
|
||||
|
||||
21
src/App.vue
21
src/App.vue
@@ -15,7 +15,7 @@ import { isDesktop } from '@/platform/distribution/types'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { isStaleChunkError, parsePreloadError } from '@/utils/preloadErrorUtil'
|
||||
import { parsePreloadError } from '@/utils/preloadErrorUtil'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
@@ -92,14 +92,17 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
}
|
||||
if (isStaleChunkError(info)) {
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.preloadErrorTitle'),
|
||||
detail: t('g.preloadError'),
|
||||
life: 10000
|
||||
})
|
||||
}
|
||||
// Disabled: Third-party custom node extensions frequently trigger this toast
|
||||
// (e.g., bare "vue" imports, wrong relative paths to scripts/app.js, missing
|
||||
// core dependencies). These are plugin bugs, not ComfyUI core failures, but
|
||||
// the generic error message alarms users and offers no actionable guidance.
|
||||
// The console.error above still logs the details for developers to debug.
|
||||
// useToastStore().add({
|
||||
// severity: 'error',
|
||||
// summary: t('g.preloadErrorTitle'),
|
||||
// detail: t('g.preloadError'),
|
||||
// life: 10000
|
||||
// })
|
||||
})
|
||||
|
||||
// Capture resource load failures (CSS, scripts) in non-localhost distributions
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import JobGroupsList from '@/components/queue/job/JobGroupsList.vue'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const QueueJobItemStub = defineComponent({
|
||||
name: 'QueueJobItemStub',
|
||||
props: {
|
||||
jobId: { type: String, required: true },
|
||||
workflowId: { type: String, default: undefined },
|
||||
state: { type: String, required: true },
|
||||
title: { type: String, required: true },
|
||||
rightText: { type: String, default: '' },
|
||||
iconName: { type: String, default: undefined },
|
||||
iconImageUrl: { type: String, default: undefined },
|
||||
showClear: { type: Boolean, default: undefined },
|
||||
showMenu: { type: Boolean, default: undefined },
|
||||
progressTotalPercent: { type: Number, default: undefined },
|
||||
progressCurrentPercent: { type: Number, default: undefined },
|
||||
runningNodeName: { type: String, default: undefined },
|
||||
activeDetailsId: { type: String, default: null }
|
||||
},
|
||||
template: `
|
||||
<div class="queue-job-item-stub" :data-job-id="jobId" :data-active-details-id="activeDetailsId">
|
||||
<div :data-testid="'enter-' + jobId" @click="$emit('details-enter', jobId)" />
|
||||
<div :data-testid="'leave-' + jobId" @click="$emit('details-leave', jobId)" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => {
|
||||
const { taskRef, ...rest } = overrides
|
||||
return {
|
||||
id: 'job-id',
|
||||
title: 'Example job',
|
||||
meta: 'Meta text',
|
||||
state: 'running',
|
||||
iconName: 'icon',
|
||||
iconImageUrl: 'https://example.com/icon.png',
|
||||
showClear: true,
|
||||
taskRef: (taskRef ?? {
|
||||
workflow: { id: 'workflow-id' }
|
||||
}) as TaskItemImpl,
|
||||
progressTotalPercent: 60,
|
||||
progressCurrentPercent: 30,
|
||||
runningNodeName: 'Node A',
|
||||
...rest
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveDetailsId(container: Element, jobId: string): string | null {
|
||||
return (
|
||||
container
|
||||
.querySelector(`[data-job-id="${jobId}"]`)
|
||||
?.getAttribute('data-active-details-id') ?? null
|
||||
)
|
||||
}
|
||||
|
||||
const renderComponent = (groups: JobGroup[]) =>
|
||||
render(JobGroupsList, {
|
||||
props: { displayedJobGroups: groups },
|
||||
global: {
|
||||
stubs: {
|
||||
QueueJobItem: QueueJobItemStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('JobGroupsList hover behavior', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('delays showing and hiding details while hovering over job rows', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = createJobItem({ id: 'job-d' })
|
||||
const { container } = renderComponent([
|
||||
{ key: 'today', label: 'Today', items: [job] }
|
||||
])
|
||||
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.click(screen.getByTestId('enter-job-d'))
|
||||
vi.advanceTimersByTime(199)
|
||||
await nextTick()
|
||||
expect(getActiveDetailsId(container, 'job-d')).toBeNull()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await nextTick()
|
||||
expect(getActiveDetailsId(container, 'job-d')).toBe(job.id)
|
||||
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.click(screen.getByTestId('leave-job-d'))
|
||||
vi.advanceTimersByTime(149)
|
||||
await nextTick()
|
||||
expect(getActiveDetailsId(container, 'job-d')).toBe(job.id)
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await nextTick()
|
||||
expect(getActiveDetailsId(container, 'job-d')).toBeNull()
|
||||
})
|
||||
|
||||
it('clears the previous popover when hovering a new row briefly and leaving', async () => {
|
||||
vi.useFakeTimers()
|
||||
const firstJob = createJobItem({ id: 'job-1', title: 'First job' })
|
||||
const secondJob = createJobItem({ id: 'job-2', title: 'Second job' })
|
||||
const { container } = renderComponent([
|
||||
{ key: 'today', label: 'Today', items: [firstJob, secondJob] }
|
||||
])
|
||||
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.click(screen.getByTestId('enter-job-1'))
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
expect(getActiveDetailsId(container, 'job-1')).toBe(firstJob.id)
|
||||
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.click(screen.getByTestId('leave-job-1'))
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.click(screen.getByTestId('enter-job-2'))
|
||||
vi.advanceTimersByTime(100)
|
||||
await nextTick()
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.click(screen.getByTestId('leave-job-2'))
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
expect(getActiveDetailsId(container, 'job-1')).toBeNull()
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
expect(getActiveDetailsId(container, 'job-2')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-3 pb-4">
|
||||
<div
|
||||
v-for="group in displayedJobGroups"
|
||||
:key="group.key"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<div class="text-[12px] leading-none text-text-secondary">
|
||||
{{ group.label }}
|
||||
</div>
|
||||
<QueueJobItem
|
||||
v-for="ji in group.items"
|
||||
:key="ji.id"
|
||||
:job-id="ji.id"
|
||||
:workflow-id="ji.taskRef?.workflowId"
|
||||
:state="ji.state"
|
||||
:title="ji.title"
|
||||
:right-text="ji.meta"
|
||||
:icon-name="ji.iconName"
|
||||
:icon-image-url="ji.iconImageUrl"
|
||||
:show-clear="ji.showClear"
|
||||
:show-menu="true"
|
||||
:progress-total-percent="ji.progressTotalPercent"
|
||||
:progress-current-percent="ji.progressCurrentPercent"
|
||||
:running-node-name="ji.runningNodeName"
|
||||
:active-details-id="activeDetailsId"
|
||||
@cancel="emitCancelItem(ji)"
|
||||
@delete="emitDeleteItem(ji)"
|
||||
@menu="(ev) => $emit('menu', ji, ev)"
|
||||
@view="$emit('viewItem', ji)"
|
||||
@details-enter="onDetailsEnter"
|
||||
@details-leave="onDetailsLeave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import QueueJobItem from '@/components/queue/job/QueueJobItem.vue'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
|
||||
|
||||
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancelItem', item: JobListItem): void
|
||||
(e: 'deleteItem', item: JobListItem): void
|
||||
(e: 'menu', item: JobListItem, ev: MouseEvent): void
|
||||
(e: 'viewItem', item: JobListItem): void
|
||||
}>()
|
||||
|
||||
const {
|
||||
activeDetails: activeDetailsId,
|
||||
clearHoverTimers,
|
||||
scheduleDetailsHide,
|
||||
scheduleDetailsShow
|
||||
} = useJobDetailsHover<string>({
|
||||
getActiveId: (jobId) => jobId,
|
||||
getDisplayedJobGroups: () => displayedJobGroups
|
||||
})
|
||||
|
||||
function emitCancelItem(item: JobListItem) {
|
||||
emit('cancelItem', item)
|
||||
}
|
||||
|
||||
function emitDeleteItem(item: JobListItem) {
|
||||
emit('deleteItem', item)
|
||||
}
|
||||
|
||||
function onDetailsEnter(jobId: string) {
|
||||
if (activeDetailsId.value === jobId) {
|
||||
clearHoverTimers()
|
||||
return
|
||||
}
|
||||
|
||||
scheduleDetailsShow(jobId)
|
||||
}
|
||||
|
||||
function onDetailsLeave(jobId: string) {
|
||||
scheduleDetailsHide(jobId)
|
||||
}
|
||||
</script>
|
||||
@@ -1,65 +0,0 @@
|
||||
<template>
|
||||
<div class="w-[300px] min-w-[260px] rounded-lg shadow-md">
|
||||
<div class="p-3">
|
||||
<div class="relative aspect-square w-full overflow-hidden rounded-lg">
|
||||
<img
|
||||
ref="imgRef"
|
||||
:src="imageUrl"
|
||||
:alt="name"
|
||||
class="size-full cursor-pointer object-contain"
|
||||
@click="$emit('image-click')"
|
||||
@load="onImgLoad"
|
||||
/>
|
||||
<div
|
||||
v-if="timeLabel"
|
||||
class="absolute bottom-2 left-2 rounded-sm px-2 py-0.5 text-xs text-text-primary"
|
||||
:style="{
|
||||
background: 'rgba(217, 217, 217, 0.40)',
|
||||
backdropFilter: 'blur(2px)'
|
||||
}"
|
||||
>
|
||||
{{ timeLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-center">
|
||||
<div
|
||||
class="truncate text-sm/normal font-semibold text-text-primary"
|
||||
:title="name"
|
||||
>
|
||||
{{ name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="width && height"
|
||||
class="mt-1 text-xs/normal text-text-secondary"
|
||||
>
|
||||
{{ width }}x{{ height }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
defineProps<{
|
||||
imageUrl: string
|
||||
name: string
|
||||
timeLabel?: string
|
||||
}>()
|
||||
|
||||
defineEmits(['image-click'])
|
||||
|
||||
const imgRef = ref<HTMLImageElement | null>(null)
|
||||
const width = ref<number | null>(null)
|
||||
const height = ref<number | null>(null)
|
||||
|
||||
const onImgLoad = () => {
|
||||
const el = imgRef.value
|
||||
if (!el) return
|
||||
width.value = el.naturalWidth || null
|
||||
height.value = el.naturalHeight || null
|
||||
}
|
||||
</script>
|
||||
@@ -1,133 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import QueueJobItem from './QueueJobItem.vue'
|
||||
|
||||
const meta: Meta<typeof QueueJobItem> = {
|
||||
title: 'Queue/QueueJobItem',
|
||||
component: QueueJobItem,
|
||||
parameters: {
|
||||
layout: 'padded'
|
||||
},
|
||||
argTypes: {
|
||||
onCancel: { action: 'cancel' },
|
||||
onDelete: { action: 'delete' },
|
||||
onMenu: { action: 'menu' },
|
||||
onView: { action: 'view' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const thumb = (hex: string) =>
|
||||
`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='256' height='256'><rect width='256' height='256' fill='%23${hex}'/></svg>`
|
||||
|
||||
export const PendingRecentlyAdded: Story = {
|
||||
args: {
|
||||
jobId: 'job-pending-added-1',
|
||||
state: 'pending',
|
||||
title: 'Job added to queue',
|
||||
rightText: '12:30 PM',
|
||||
iconName: 'icon-[lucide--check]'
|
||||
}
|
||||
}
|
||||
|
||||
export const Pending: Story = {
|
||||
args: {
|
||||
jobId: 'job-pending-1',
|
||||
state: 'pending',
|
||||
title: 'Pending job',
|
||||
rightText: '12:31 PM'
|
||||
}
|
||||
}
|
||||
|
||||
export const Initialization: Story = {
|
||||
args: {
|
||||
jobId: 'job-init-1',
|
||||
state: 'initialization',
|
||||
title: 'Initializing...'
|
||||
}
|
||||
}
|
||||
|
||||
export const RunningTotalOnly: Story = {
|
||||
args: {
|
||||
jobId: 'job-running-1',
|
||||
state: 'running',
|
||||
title: 'Generating image',
|
||||
progressTotalPercent: 42
|
||||
}
|
||||
}
|
||||
|
||||
export const RunningWithCurrent: Story = {
|
||||
args: {
|
||||
jobId: 'job-running-2',
|
||||
state: 'running',
|
||||
title: 'Generating image',
|
||||
progressTotalPercent: 66,
|
||||
progressCurrentPercent: 10
|
||||
}
|
||||
}
|
||||
|
||||
export const CompletedWithPreview: Story = {
|
||||
args: {
|
||||
jobId: 'job-completed-1',
|
||||
state: 'completed',
|
||||
title: 'Prompt #1234',
|
||||
rightText: '12.79s',
|
||||
iconImageUrl: thumb('4dabf7')
|
||||
}
|
||||
}
|
||||
|
||||
export const CompletedNoPreview: Story = {
|
||||
args: {
|
||||
jobId: 'job-completed-2',
|
||||
state: 'completed',
|
||||
title: 'Prompt #5678',
|
||||
rightText: '8.12s'
|
||||
}
|
||||
}
|
||||
|
||||
export const Failed: Story = {
|
||||
args: {
|
||||
jobId: 'job-failed-1',
|
||||
state: 'failed',
|
||||
title: 'Failed job',
|
||||
rightText: 'Failed'
|
||||
}
|
||||
}
|
||||
|
||||
export const Gallery: Story = {
|
||||
render: (args) => ({
|
||||
components: { QueueJobItem },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: `
|
||||
<div class="flex flex-col gap-2 w-[420px]">
|
||||
<QueueJobItem job-id="job-pending-added-1" state="pending" title="Job added to queue" right-text="12:30 PM" icon-name="icon-[lucide--check]" v-bind="args" />
|
||||
<QueueJobItem job-id="job-pending-1" state="pending" title="Pending job" right-text="12:31 PM" v-bind="args" />
|
||||
<QueueJobItem job-id="job-init-1" state="initialization" title="Initializing..." v-bind="args" />
|
||||
<QueueJobItem job-id="job-running-1" state="running" title="Generating image" :progress-total-percent="42" v-bind="args" />
|
||||
<QueueJobItem
|
||||
job-id="job-running-2"
|
||||
state="running"
|
||||
title="Generating image"
|
||||
:progress-total-percent="66"
|
||||
:progress-current-percent="10"
|
||||
running-node-name="KSampler"
|
||||
v-bind="args"
|
||||
/>
|
||||
<QueueJobItem
|
||||
job-id="job-completed-1"
|
||||
state="completed"
|
||||
title="Prompt #1234"
|
||||
right-text="12.79s"
|
||||
icon-image-url="${thumb('4dabf7')}"
|
||||
v-bind="args"
|
||||
/>
|
||||
<QueueJobItem job-id="job-completed-2" state="completed" title="Prompt #5678" right-text="8.12s" v-bind="args" />
|
||||
<QueueJobItem job-id="job-failed-1" state="failed" title="Failed job" right-text="Failed" v-bind="args" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
ref="rowRef"
|
||||
class="relative"
|
||||
@mouseenter="onRowEnter"
|
||||
@mouseleave="onRowLeave"
|
||||
@contextmenu.stop.prevent="onContextMenu"
|
||||
>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="!isPreviewVisible && showDetails && popoverPosition"
|
||||
class="fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
left: `${popoverPosition.left}px`
|
||||
}"
|
||||
@mouseenter="onPopoverEnter"
|
||||
@mouseleave="onPopoverLeave"
|
||||
>
|
||||
<JobDetailsPopover :job-id="jobId" :workflow-id="workflowId" />
|
||||
</div>
|
||||
</Teleport>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isPreviewVisible && canShowPreview && popoverPosition"
|
||||
class="fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
left: `${popoverPosition.left}px`
|
||||
}"
|
||||
@mouseenter="onPreviewEnter"
|
||||
@mouseleave="onPreviewLeave"
|
||||
>
|
||||
<QueueAssetPreview
|
||||
:image-url="iconImageUrl!"
|
||||
:name="title"
|
||||
:time-label="rightText || undefined"
|
||||
@image-click="emit('view')"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
<div
|
||||
class="relative flex items-center justify-between gap-2 overflow-hidden rounded-lg border border-secondary-background bg-secondary-background p-1 text-[12px] text-text-primary transition-colors duration-150 ease-in-out hover:border-secondary-background-hover hover:bg-secondary-background-hover"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
state === 'running' &&
|
||||
hasAnyProgressPercent(progressTotalPercent, progressCurrentPercent)
|
||||
"
|
||||
:class="progressBarContainerClass"
|
||||
>
|
||||
<div
|
||||
v-if="hasProgressPercent(progressTotalPercent)"
|
||||
:class="progressBarPrimaryClass"
|
||||
:style="progressPercentStyle(progressTotalPercent)"
|
||||
/>
|
||||
<div
|
||||
v-if="hasProgressPercent(progressCurrentPercent)"
|
||||
:class="progressBarSecondaryClass"
|
||||
:style="progressPercentStyle(progressCurrentPercent)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="relative z-1 flex items-center gap-1">
|
||||
<div class="relative inline-flex items-center justify-center">
|
||||
<div
|
||||
class="absolute top-1/2 left-1/2 size-10 -translate-1/2"
|
||||
@mouseenter.stop="onIconEnter"
|
||||
@mouseleave.stop="onIconLeave"
|
||||
/>
|
||||
<div
|
||||
class="inline-flex size-6 items-center justify-center overflow-hidden rounded-[6px]"
|
||||
>
|
||||
<img
|
||||
v-if="iconImageUrl"
|
||||
:src="iconImageUrl"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
:class="cn(iconClass, 'size-4', shouldSpin && 'animate-spin')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-1 min-w-0 flex-1">
|
||||
<div class="truncate opacity-90" :title="title">
|
||||
<slot name="primary">{{ title }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
TODO: Refactor action buttons to use a declarative config system.
|
||||
|
||||
Instead of hardcoding button visibility logic in the template, define an array of
|
||||
action button configs with properties like:
|
||||
- icon, label, action, tooltip
|
||||
- visibleStates: JobState[] (which job states show this button)
|
||||
- alwaysVisible: boolean (show without hover)
|
||||
- destructive: boolean (use destructive styling)
|
||||
|
||||
Then render buttons in two groups:
|
||||
1. Always-visible buttons (outside Transition)
|
||||
2. Hover-only buttons (inside Transition)
|
||||
|
||||
This would eliminate the current duplication where the cancel button exists
|
||||
both outside (for running) and inside (for pending) the Transition.
|
||||
-->
|
||||
<div class="relative z-1 flex items-center gap-2 text-text-secondary">
|
||||
<Transition
|
||||
mode="out-in"
|
||||
enter-active-class="transition-opacity transition-transform duration-150 ease-out"
|
||||
leave-active-class="transition-opacity transition-transform duration-150 ease-in"
|
||||
enter-from-class="opacity-0 translate-y-0.5"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-0.5"
|
||||
>
|
||||
<div
|
||||
v-if="isHovered"
|
||||
key="actions"
|
||||
class="inline-flex items-center gap-2 pr-1"
|
||||
>
|
||||
<Button
|
||||
v-if="state === 'failed' && computedShowClear"
|
||||
v-tooltip.top="deleteTooltipConfig"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="onDeleteClick"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="
|
||||
state !== 'completed' &&
|
||||
state !== 'running' &&
|
||||
computedShowClear
|
||||
"
|
||||
v-tooltip.top="cancelTooltipConfig"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="onCancelClick"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="state === 'completed'"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click.stop="emit('view')"
|
||||
>{{ t('menuLabels.View') }}</Button
|
||||
>
|
||||
<Button
|
||||
v-if="showMenu !== undefined ? showMenu : true"
|
||||
v-tooltip.top="moreTooltipConfig"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop="emit('menu', $event)"
|
||||
>
|
||||
<i class="icon-[lucide--more-horizontal] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else-if="state !== 'running'" key="secondary" class="pr-2">
|
||||
<slot name="secondary">{{ rightText }}</slot>
|
||||
</div>
|
||||
</Transition>
|
||||
<!-- Running job cancel button - always visible -->
|
||||
<Button
|
||||
v-if="state === 'running' && computedShowClear"
|
||||
v-tooltip.top="cancelTooltipConfig"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="onCancelClick"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
|
||||
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
jobId,
|
||||
workflowId,
|
||||
state,
|
||||
title,
|
||||
rightText = '',
|
||||
iconName,
|
||||
iconImageUrl,
|
||||
showClear,
|
||||
showMenu,
|
||||
progressTotalPercent,
|
||||
progressCurrentPercent,
|
||||
activeDetailsId = null
|
||||
} = defineProps<{
|
||||
jobId: string
|
||||
workflowId?: string
|
||||
state: JobState
|
||||
title: string
|
||||
rightText?: string
|
||||
iconName?: string
|
||||
iconImageUrl?: string
|
||||
showClear?: boolean
|
||||
showMenu?: boolean
|
||||
progressTotalPercent?: number
|
||||
progressCurrentPercent?: number
|
||||
activeDetailsId?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancel'): void
|
||||
(e: 'delete'): void
|
||||
(e: 'menu', event: MouseEvent): void
|
||||
(e: 'view'): void
|
||||
(e: 'details-enter', jobId: string): void
|
||||
(e: 'details-leave', jobId: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
progressBarContainerClass,
|
||||
progressBarPrimaryClass,
|
||||
progressBarSecondaryClass,
|
||||
hasProgressPercent,
|
||||
hasAnyProgressPercent,
|
||||
progressPercentStyle
|
||||
} = useProgressBarBackground()
|
||||
|
||||
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
|
||||
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
|
||||
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
|
||||
|
||||
const rowRef = ref<HTMLDivElement | null>(null)
|
||||
const showDetails = computed(() => activeDetailsId === jobId)
|
||||
|
||||
const onRowEnter = () => {
|
||||
if (!isPreviewVisible.value) emit('details-enter', jobId)
|
||||
}
|
||||
const onRowLeave = () => emit('details-leave', jobId)
|
||||
const onPopoverEnter = () => emit('details-enter', jobId)
|
||||
const onPopoverLeave = () => emit('details-leave', jobId)
|
||||
|
||||
const isPreviewVisible = ref(false)
|
||||
const previewHideTimer = ref<number | null>(null)
|
||||
const previewShowTimer = ref<number | null>(null)
|
||||
const clearPreviewHideTimer = () => {
|
||||
if (previewHideTimer.value !== null) {
|
||||
clearTimeout(previewHideTimer.value)
|
||||
previewHideTimer.value = null
|
||||
}
|
||||
}
|
||||
const clearPreviewShowTimer = () => {
|
||||
if (previewShowTimer.value !== null) {
|
||||
clearTimeout(previewShowTimer.value)
|
||||
previewShowTimer.value = null
|
||||
}
|
||||
}
|
||||
const canShowPreview = computed(() => state === 'completed' && !!iconImageUrl)
|
||||
const scheduleShowPreview = () => {
|
||||
if (!canShowPreview.value) return
|
||||
clearPreviewHideTimer()
|
||||
clearPreviewShowTimer()
|
||||
previewShowTimer.value = window.setTimeout(() => {
|
||||
isPreviewVisible.value = true
|
||||
previewShowTimer.value = null
|
||||
}, 200)
|
||||
}
|
||||
const scheduleHidePreview = () => {
|
||||
clearPreviewHideTimer()
|
||||
clearPreviewShowTimer()
|
||||
previewHideTimer.value = window.setTimeout(() => {
|
||||
isPreviewVisible.value = false
|
||||
previewHideTimer.value = null
|
||||
}, 150)
|
||||
}
|
||||
const onIconEnter = () => scheduleShowPreview()
|
||||
const onIconLeave = () => scheduleHidePreview()
|
||||
const onPreviewEnter = () => scheduleShowPreview()
|
||||
const onPreviewLeave = () => scheduleHidePreview()
|
||||
|
||||
const popoverPosition = ref<{ top: number; left: number } | null>(null)
|
||||
|
||||
const updatePopoverPosition = () => {
|
||||
const el = rowRef.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
popoverPosition.value = getHoverPopoverPosition(rect, window.innerWidth)
|
||||
}
|
||||
|
||||
const isAnyPopoverVisible = computed(
|
||||
() => showDetails.value || (isPreviewVisible.value && canShowPreview.value)
|
||||
)
|
||||
|
||||
watch(
|
||||
isAnyPopoverVisible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
nextTick(updatePopoverPosition)
|
||||
} else {
|
||||
popoverPosition.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const isHovered = ref(false)
|
||||
|
||||
const iconClass = computed(() => {
|
||||
if (iconName) return iconName
|
||||
return iconForJobState(state)
|
||||
})
|
||||
|
||||
const shouldSpin = computed(
|
||||
() =>
|
||||
state === 'pending' &&
|
||||
iconClass.value === iconForJobState('pending') &&
|
||||
!iconImageUrl
|
||||
)
|
||||
|
||||
const computedShowClear = computed(() => {
|
||||
if (showClear !== undefined) return showClear
|
||||
return state !== 'completed'
|
||||
})
|
||||
|
||||
const emitDetailsLeave = () => emit('details-leave', jobId)
|
||||
|
||||
const onCancelClick = () => {
|
||||
emitDetailsLeave()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
const onDeleteClick = () => {
|
||||
emitDetailsLeave()
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
const onContextMenu = (event: MouseEvent) => {
|
||||
const shouldShowMenu = showMenu !== undefined ? showMenu : true
|
||||
if (shouldShowMenu) emit('menu', event)
|
||||
}
|
||||
</script>
|
||||
@@ -5,9 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
|
||||
import {
|
||||
createMockNodeDef,
|
||||
setViewport,
|
||||
setupTestPinia,
|
||||
testI18n
|
||||
} from '@/components/searchbox/v2/__test__/testUtils'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -15,10 +17,14 @@ import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType } from '@/types/nodeSource'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
|
||||
const DESKTOP_VIEWPORT = { width: 1280, height: 800 }
|
||||
const MOBILE_VIEWPORT = { width: 360, height: 800 }
|
||||
|
||||
describe('NodeSearchContent', () => {
|
||||
beforeEach(() => {
|
||||
setupTestPinia()
|
||||
vi.restoreAllMocks()
|
||||
setViewport(DESKTOP_VIEWPORT)
|
||||
const settings = useSettingStore()
|
||||
settings.settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = []
|
||||
settings.settingValues['Comfy.NodeLibrary.BookmarksCustomization'] = {}
|
||||
@@ -547,7 +553,7 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
|
||||
describe('filter integration', () => {
|
||||
it('should display active filters in the input area', () => {
|
||||
it('renders one chip per active filter with the filter value', () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'ImageNode',
|
||||
@@ -556,16 +562,20 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
])
|
||||
|
||||
const inputFilter = useNodeDefStore().nodeSearchService.inputTypeFilter
|
||||
renderComponent({
|
||||
filters: [
|
||||
{
|
||||
filterDef: useNodeDefStore().nodeSearchService.inputTypeFilter,
|
||||
value: 'IMAGE'
|
||||
}
|
||||
{ filterDef: inputFilter, value: 'IMAGE' },
|
||||
{ filterDef: inputFilter, value: 'LATENT' }
|
||||
]
|
||||
})
|
||||
|
||||
expect(screen.getAllByTestId('filter-chip').length).toBeGreaterThan(0)
|
||||
const chipTexts = screen
|
||||
.getAllByTestId('filter-chip')
|
||||
.map((c) => c.textContent ?? '')
|
||||
expect(chipTexts).toHaveLength(2)
|
||||
expect(chipTexts.some((t) => t.includes('IMAGE'))).toBe(true)
|
||||
expect(chipTexts.some((t) => t.includes('LATENT'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -659,6 +669,95 @@ describe('NodeSearchContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('sidebar toggle', () => {
|
||||
it('should hide and show the category sidebar when the toggle is clicked', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler',
|
||||
category: 'sampling'
|
||||
})
|
||||
])
|
||||
|
||||
const { user } = renderComponent()
|
||||
|
||||
const sidebar = await screen.findByTestId('category-sampling')
|
||||
expect(sidebar).toBeVisible()
|
||||
|
||||
const toggle = screen.getByTestId('toggle-category-sidebar')
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
await user.click(toggle)
|
||||
await waitFor(() => {
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.getByTestId('category-sampling')).not.toBeVisible()
|
||||
})
|
||||
|
||||
await user.click(toggle)
|
||||
await waitFor(() => {
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByTestId('category-sampling')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close the sidebar when the search input gains focus on mobile', async () => {
|
||||
setViewport(MOBILE_VIEWPORT)
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler',
|
||||
category: 'sampling'
|
||||
})
|
||||
])
|
||||
|
||||
const { user } = renderComponent()
|
||||
|
||||
const toggle = screen.getByTestId('toggle-category-sidebar')
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await user.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
await user.click(screen.getByRole('combobox'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve user state across mobile/desktop resizes', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'KSampler',
|
||||
display_name: 'KSampler',
|
||||
category: 'sampling'
|
||||
})
|
||||
])
|
||||
|
||||
const { user } = renderComponent()
|
||||
|
||||
const toggle = screen.getByTestId('toggle-category-sidebar')
|
||||
const expectExpanded = (value: 'true' | 'false') =>
|
||||
waitFor(() => expect(toggle).toHaveAttribute('aria-expanded', value))
|
||||
|
||||
await expectExpanded('true')
|
||||
|
||||
setViewport(MOBILE_VIEWPORT)
|
||||
await expectExpanded('false')
|
||||
|
||||
await user.click(toggle)
|
||||
setViewport(DESKTOP_VIEWPORT)
|
||||
await expectExpanded('true')
|
||||
|
||||
await user.click(toggle)
|
||||
setViewport(MOBILE_VIEWPORT)
|
||||
await expectExpanded('false')
|
||||
|
||||
setViewport(DESKTOP_VIEWPORT)
|
||||
await expectExpanded('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rootFilter + category + search combination', () => {
|
||||
it('should intersect rootFilter, selected category, and search query', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
@navigate-down="navigateResults(1)"
|
||||
@navigate-up="navigateResults(-1)"
|
||||
@select-current="selectCurrentResult"
|
||||
@focusin="onSearchFocus"
|
||||
/>
|
||||
|
||||
<!-- Filter header row -->
|
||||
<div class="flex items-center">
|
||||
<NodeSearchFilterBar
|
||||
v-model:is-sidebar-open="isSidebarOpen"
|
||||
class="flex-1"
|
||||
:filters="filters"
|
||||
:active-category="rootFilter"
|
||||
@@ -34,11 +36,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="flex min-h-0 flex-1 overflow-hidden">
|
||||
<!-- Category sidebar -->
|
||||
<div class="relative flex min-h-0 flex-1 overflow-hidden">
|
||||
<NodeSearchCategorySidebar
|
||||
v-show="isSidebarOpen"
|
||||
id="node-search-category-sidebar"
|
||||
v-model:selected-category="sidebarCategory"
|
||||
class="w-52 shrink-0"
|
||||
:aria-label="isMobile ? t('g.categories') : undefined"
|
||||
class="w-52 shrink-0 max-md:absolute max-md:inset-y-0 max-md:left-0 max-md:z-20 max-md:bg-base-background max-md:shadow-interface"
|
||||
:hide-chevrons="!anyTreeCategoryHasChildren"
|
||||
:hide-presets="rootFilter !== null"
|
||||
:node-defs="rootFilteredNodeDefs"
|
||||
@@ -47,6 +51,14 @@
|
||||
@auto-expand="selectedCategory = $event"
|
||||
/>
|
||||
|
||||
<!-- Mobile overlay backdrop to close sidebar on outside click -->
|
||||
<div
|
||||
v-if="isMobile && isSidebarOpen"
|
||||
data-testid="sidebar-backdrop"
|
||||
class="absolute inset-0 z-10 md:hidden"
|
||||
@click="isSidebarOpen = false"
|
||||
/>
|
||||
|
||||
<!-- Results list -->
|
||||
<div
|
||||
id="results-list"
|
||||
@@ -78,8 +90,8 @@
|
||||
:node-def="node"
|
||||
:current-query="searchQuery"
|
||||
show-description
|
||||
:show-source-badge="rootFilter !== 'essentials'"
|
||||
:hide-bookmark-icon="selectedCategory === 'favorites'"
|
||||
:show-source-badge="rootFilter !== RootCategory.Essentials"
|
||||
:hide-bookmark-icon="selectedCategory === RootCategory.Favorites"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -96,6 +108,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import { FocusScope } from 'reka-ui'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -106,6 +119,8 @@ import NodeSearchCategorySidebar, {
|
||||
} from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
|
||||
import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
|
||||
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
@@ -121,9 +136,9 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
|
||||
{
|
||||
essentials: isEssentialNode,
|
||||
comfy: (n) => n.nodeSource.type === NodeSourceType.Core,
|
||||
custom: isCustomNode
|
||||
[RootCategory.Essentials]: isEssentialNode,
|
||||
[RootCategory.Comfy]: (n) => n.nodeSource.type === NodeSourceType.Core,
|
||||
[RootCategory.Custom]: isCustomNode
|
||||
}
|
||||
|
||||
const { filters } = defineProps<{
|
||||
@@ -167,22 +182,33 @@ const searchQuery = ref('')
|
||||
const selectedCategory = ref(DEFAULT_CATEGORY)
|
||||
const selectedIndex = ref(0)
|
||||
|
||||
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
const isSidebarOpen = ref(!isMobile.value)
|
||||
watch(isMobile, (mobile) => {
|
||||
// On transitioning to mobile state, close the sidebar
|
||||
if (mobile) isSidebarOpen.value = false
|
||||
})
|
||||
|
||||
function onSearchFocus() {
|
||||
if (isMobile.value) isSidebarOpen.value = false
|
||||
}
|
||||
|
||||
// Root filter from filter bar category buttons (radio toggle)
|
||||
const rootFilter = ref<string | null>(null)
|
||||
const rootFilter = ref<RootCategoryId | null>(null)
|
||||
|
||||
const rootFilterLabel = computed(() => {
|
||||
switch (rootFilter.value) {
|
||||
case 'favorites':
|
||||
case RootCategory.Favorites:
|
||||
return t('g.bookmarked')
|
||||
case BLUEPRINT_CATEGORY:
|
||||
case RootCategory.Blueprint:
|
||||
return t('g.blueprints')
|
||||
case 'partner-nodes':
|
||||
case RootCategory.PartnerNodes:
|
||||
return t('g.partner')
|
||||
case 'essentials':
|
||||
case RootCategory.Essentials:
|
||||
return t('g.essentials')
|
||||
case 'comfy':
|
||||
case RootCategory.Comfy:
|
||||
return t('g.comfy')
|
||||
case 'custom':
|
||||
case RootCategory.Custom:
|
||||
return t('g.extensions')
|
||||
default:
|
||||
return undefined
|
||||
@@ -195,11 +221,11 @@ const rootFilteredNodeDefs = computed(() => {
|
||||
const sourceFilter = sourceCategoryFilters[rootFilter.value]
|
||||
if (sourceFilter) return allNodes.filter(sourceFilter)
|
||||
switch (rootFilter.value) {
|
||||
case 'favorites':
|
||||
case RootCategory.Favorites:
|
||||
return allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
|
||||
case BLUEPRINT_CATEGORY:
|
||||
return allNodes.filter((n) => n.category.startsWith(rootFilter.value!))
|
||||
case 'partner-nodes':
|
||||
case RootCategory.Blueprint:
|
||||
return allNodes.filter((n) => n.category.startsWith(BLUEPRINT_CATEGORY))
|
||||
case RootCategory.PartnerNodes:
|
||||
return allNodes.filter((n) => n.api_node)
|
||||
default:
|
||||
return allNodes
|
||||
@@ -226,7 +252,7 @@ function onClearFilterGroup(filterId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectCategory(category: string) {
|
||||
function onSelectCategory(category: RootCategoryId) {
|
||||
if (rootFilter.value === category) {
|
||||
rootFilter.value = null
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -9,23 +9,16 @@ import {
|
||||
setupTestPinia,
|
||||
testI18n
|
||||
} from '@/components/searchbox/v2/__test__/testUtils'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
|
||||
if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
|
||||
return undefined
|
||||
}),
|
||||
set: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
describe(NodeSearchFilterBar, () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setupTestPinia()
|
||||
const settings = useSettingStore()
|
||||
settings.settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = []
|
||||
settings.settingValues['Comfy.NodeLibrary.BookmarksCustomization'] = {}
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
createMockNodeDef({
|
||||
name: 'ImageNode',
|
||||
@@ -38,8 +31,13 @@ describe(NodeSearchFilterBar, () => {
|
||||
async function createRender(props = {}) {
|
||||
const user = userEvent.setup()
|
||||
const onSelectCategory = vi.fn()
|
||||
const onUpdateIsSidebarOpen = vi.fn()
|
||||
render(NodeSearchFilterBar, {
|
||||
props: { onSelectCategory, ...props },
|
||||
props: {
|
||||
onSelectCategory,
|
||||
'onUpdate:isSidebarOpen': onUpdateIsSidebarOpen,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
plugins: [testI18n],
|
||||
stubs: {
|
||||
@@ -51,51 +49,38 @@ describe(NodeSearchFilterBar, () => {
|
||||
}
|
||||
})
|
||||
await nextTick()
|
||||
return { user, onSelectCategory }
|
||||
return { user, onSelectCategory, onUpdateIsSidebarOpen }
|
||||
}
|
||||
|
||||
it('should render Extensions button and Input/Output popover triggers', async () => {
|
||||
await createRender({ hasCustomNodes: true })
|
||||
const buttonTexts = () =>
|
||||
screen.getAllByRole('button').map((b) => b.textContent?.trim())
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const texts = buttons.map((b) => b.textContent?.trim())
|
||||
expect(texts).toContain('Extensions')
|
||||
it.each([
|
||||
{ prop: 'hasFavorites', label: 'Bookmarked' },
|
||||
{ prop: 'hasBlueprintNodes', label: 'Blueprints' },
|
||||
{ prop: 'hasEssentialNodes', label: 'Essentials' },
|
||||
{ prop: 'hasPartnerNodes', label: 'Partner' },
|
||||
{ prop: 'hasCustomNodes', label: 'Extensions' }
|
||||
] as const)(
|
||||
'shows the $label button only when $prop is true',
|
||||
async ({ prop, label }) => {
|
||||
await createRender()
|
||||
expect(buttonTexts()).not.toContain(label)
|
||||
|
||||
cleanup()
|
||||
await createRender({ [prop]: true })
|
||||
expect(buttonTexts()).toContain(label)
|
||||
}
|
||||
)
|
||||
|
||||
it('always renders the Comfy button and Input/Output type filter triggers', async () => {
|
||||
await createRender()
|
||||
const texts = buttonTexts()
|
||||
expect(texts).toContain('Comfy')
|
||||
expect(texts).toContain('Input')
|
||||
expect(texts).toContain('Output')
|
||||
})
|
||||
|
||||
it('should always render Comfy button', async () => {
|
||||
await createRender()
|
||||
const texts = screen
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent?.trim())
|
||||
expect(texts).toContain('Comfy')
|
||||
})
|
||||
|
||||
it('should render conditional category buttons when matching nodes exist', async () => {
|
||||
await createRender({
|
||||
hasFavorites: true,
|
||||
hasEssentialNodes: true,
|
||||
hasBlueprintNodes: true,
|
||||
hasPartnerNodes: true
|
||||
})
|
||||
const texts = screen
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent?.trim())
|
||||
expect(texts).toContain('Bookmarked')
|
||||
expect(texts).toContain('Blueprints')
|
||||
expect(texts).toContain('Partner')
|
||||
expect(texts).toContain('Essentials')
|
||||
})
|
||||
|
||||
it('should not render Extensions button when no custom nodes exist', async () => {
|
||||
await createRender()
|
||||
const texts = screen
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent?.trim())
|
||||
expect(texts).not.toContain('Extensions')
|
||||
})
|
||||
|
||||
it('should emit selectCategory when category button is clicked', async () => {
|
||||
const { user, onSelectCategory } = await createRender({
|
||||
hasCustomNodes: true
|
||||
@@ -114,4 +99,24 @@ describe(NodeSearchFilterBar, () => {
|
||||
'true'
|
||||
)
|
||||
})
|
||||
|
||||
it('should expose aria-expanded=false and emit update:isSidebarOpen=true when toggled from collapsed', async () => {
|
||||
const { user, onUpdateIsSidebarOpen } = await createRender({
|
||||
isSidebarOpen: false
|
||||
})
|
||||
const toggle = screen.getByTestId('toggle-category-sidebar')
|
||||
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await user.click(toggle)
|
||||
expect(onUpdateIsSidebarOpen).toHaveBeenCalledExactlyOnceWith(true)
|
||||
})
|
||||
|
||||
it('should expose aria-expanded=true when isSidebarOpen prop is true', async () => {
|
||||
await createRender({ isSidebarOpen: true })
|
||||
expect(screen.getByTestId('toggle-category-sidebar')).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,48 +1,67 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2.5 px-3">
|
||||
<!-- Category filter buttons -->
|
||||
<div class="flex min-w-0 items-center gap-2.5 pl-3">
|
||||
<button
|
||||
v-for="btn in categoryButtons"
|
||||
:key="btn.id"
|
||||
type="button"
|
||||
:data-testid="`search-category-${btn.id}`"
|
||||
:aria-pressed="activeCategory === btn.id"
|
||||
:class="chipClass(activeCategory === btn.id)"
|
||||
@click="emit('selectCategory', btn.id)"
|
||||
data-testid="toggle-category-sidebar"
|
||||
aria-controls="node-search-category-sidebar"
|
||||
:aria-expanded="isSidebarOpen"
|
||||
:aria-label="isSidebarOpen ? t('g.hideLeftPanel') : t('g.showLeftPanel')"
|
||||
:class="chipClass(isSidebarOpen)"
|
||||
@click="isSidebarOpen = !isSidebarOpen"
|
||||
>
|
||||
{{ btn.label }}
|
||||
<i class="icon-[lucide--panel-left] size-4" />
|
||||
</button>
|
||||
|
||||
<div class="h-5 w-px shrink-0 bg-border-subtle" />
|
||||
|
||||
<!-- Type filter popovers (Input / Output) -->
|
||||
<NodeSearchTypeFilterPopover
|
||||
v-for="tf in typeFilters"
|
||||
:key="tf.chip.key"
|
||||
:chip="tf.chip"
|
||||
:selected-values="tf.values"
|
||||
@toggle="(v) => emit('toggleFilter', tf.chip.filter, v)"
|
||||
@clear="emit('clearFilterGroup', tf.chip.filter.id)"
|
||||
@escape-close="emit('focusSearch')"
|
||||
<div
|
||||
data-testid="filter-chips-scroll"
|
||||
class="flex min-w-0 flex-1 items-center gap-2.5 overflow-x-auto pr-3"
|
||||
>
|
||||
<!-- Category filter buttons -->
|
||||
<button
|
||||
v-for="btn in categoryButtons"
|
||||
:key="btn.id"
|
||||
type="button"
|
||||
:data-testid="`search-filter-${tf.chip.key}`"
|
||||
:class="chipClass(false, tf.values.length > 0)"
|
||||
:data-testid="`search-category-${btn.id}`"
|
||||
:aria-pressed="activeCategory === btn.id"
|
||||
:class="chipClass(activeCategory === btn.id)"
|
||||
@click="emit('selectCategory', btn.id)"
|
||||
>
|
||||
<span v-if="tf.values.length > 0" class="flex items-center">
|
||||
<span
|
||||
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
|
||||
:key="val"
|
||||
class="-mx-[2px] text-lg leading-none"
|
||||
:style="{ color: getLinkTypeColor(val) }"
|
||||
>•</span
|
||||
>
|
||||
</span>
|
||||
{{ tf.chip.label }}
|
||||
<i class="icon-[lucide--chevron-down] size-3.5" />
|
||||
{{ btn.label }}
|
||||
</button>
|
||||
</NodeSearchTypeFilterPopover>
|
||||
|
||||
<div class="h-5 w-px shrink-0 bg-border-subtle" />
|
||||
|
||||
<!-- Type filter popovers (Input / Output) -->
|
||||
<NodeSearchTypeFilterPopover
|
||||
v-for="tf in typeFilters"
|
||||
:key="tf.chip.key"
|
||||
:chip="tf.chip"
|
||||
:selected-values="tf.values"
|
||||
@toggle="(v) => emit('toggleFilter', tf.chip.filter, v)"
|
||||
@clear="emit('clearFilterGroup', tf.chip.filter.id)"
|
||||
@escape-close="emit('focusSearch')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:data-testid="`search-filter-${tf.chip.key}`"
|
||||
:class="chipClass(false, tf.values.length > 0)"
|
||||
>
|
||||
<span v-if="tf.values.length > 0" class="flex items-center">
|
||||
<span
|
||||
v-for="val in tf.values.slice(0, MAX_VISIBLE_DOTS)"
|
||||
:key="val"
|
||||
class="-mx-[2px] text-lg leading-none"
|
||||
:style="{ color: getLinkTypeColor(val) }"
|
||||
>•</span
|
||||
>
|
||||
</span>
|
||||
{{ tf.chip.label }}
|
||||
<i class="icon-[lucide--chevron-down] size-3.5" />
|
||||
</button>
|
||||
</NodeSearchTypeFilterPopover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -63,6 +82,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NodeSearchTypeFilterPopover from '@/components/searchbox/v2/NodeSearchTypeFilterPopover.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
import { getLinkTypeColor } from '@/utils/litegraphUtil'
|
||||
@@ -86,11 +106,13 @@ const {
|
||||
hasCustomNodes?: boolean
|
||||
}>()
|
||||
|
||||
const isSidebarOpen = defineModel<boolean>('isSidebarOpen', { default: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleFilter: [filterDef: FuseFilter<ComfyNodeDefImpl, string>, value: string]
|
||||
clearFilterGroup: [filterId: string]
|
||||
focusSearch: []
|
||||
selectCategory: [category: string]
|
||||
selectCategory: [category: RootCategoryId]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -99,20 +121,20 @@ const nodeDefStore = useNodeDefStore()
|
||||
const MAX_VISIBLE_DOTS = 4
|
||||
|
||||
const categoryButtons = computed(() => {
|
||||
const buttons: { id: string; label: string }[] = []
|
||||
const buttons: { id: RootCategoryId; label: string }[] = []
|
||||
if (hasFavorites) {
|
||||
buttons.push({ id: RootCategory.Favorites, label: t('g.bookmarked') })
|
||||
}
|
||||
if (hasBlueprintNodes) {
|
||||
buttons.push({ id: RootCategory.Blueprint, label: t('g.blueprints') })
|
||||
}
|
||||
if (hasPartnerNodes) {
|
||||
buttons.push({ id: RootCategory.PartnerNodes, label: t('g.partner') })
|
||||
}
|
||||
buttons.push({ id: RootCategory.Comfy, label: t('g.comfy') })
|
||||
if (hasEssentialNodes) {
|
||||
buttons.push({ id: RootCategory.Essentials, label: t('g.essentials') })
|
||||
}
|
||||
buttons.push({ id: RootCategory.Comfy, label: t('g.comfy') })
|
||||
if (hasPartnerNodes) {
|
||||
buttons.push({ id: RootCategory.PartnerNodes, label: t('g.partner') })
|
||||
}
|
||||
if (hasCustomNodes) {
|
||||
buttons.push({ id: RootCategory.Custom, label: t('g.extensions') })
|
||||
}
|
||||
@@ -146,7 +168,7 @@ const typeFilters = computed(() => [
|
||||
|
||||
function chipClass(isActive: boolean, hasSelections = false) {
|
||||
return cn(
|
||||
'flex cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 font-inter text-sm transition-colors',
|
||||
'flex shrink-0 cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 font-inter text-sm transition-colors',
|
||||
isActive
|
||||
? 'border-base-foreground bg-base-foreground text-base-background'
|
||||
: hasSelections
|
||||
|
||||
@@ -57,6 +57,19 @@ describe('NodeSearchListItem', () => {
|
||||
})
|
||||
expect(screen.queryByText('KSamplerNode')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides id name for subgraph blueprints even when ShowIdName is enabled', () => {
|
||||
useSettingStore().settingValues['Comfy.NodeSearchBoxImpl.ShowIdName'] =
|
||||
true
|
||||
renderItem({
|
||||
nodeDef: createMockNodeDef({
|
||||
name: 'SubgraphBlueprint.e21be61fc452df75e1324e3cc97c41fb0c01a08a5dad4dcd3a2ac118d8907025',
|
||||
display_name: 'My Blueprint',
|
||||
python_module: 'blueprint'
|
||||
})
|
||||
})
|
||||
expect(screen.queryByTestId('node-id-badge')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('showDescription mode', () => {
|
||||
|
||||
@@ -155,8 +155,10 @@ const settingStore = useSettingStore()
|
||||
const showCategory = computed(() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
|
||||
)
|
||||
const showIdName = computed(() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName')
|
||||
const showIdName = computed(
|
||||
() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName') &&
|
||||
nodeDef.nodeSource.type !== NodeSourceType.Blueprint
|
||||
)
|
||||
const showNodeFrequency = computed(() =>
|
||||
settingStore.get('Comfy.NodeSearchBoxImpl.ShowNodeFrequency')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import type { DetachedWindowAPI } from 'happy-dom'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -35,3 +36,12 @@ export const testI18n = createI18n({
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
export function setViewport(viewport: { width: number; height: number }) {
|
||||
const happyDOM = (window as unknown as { happyDOM?: DetachedWindowAPI })
|
||||
.happyDOM
|
||||
if (!happyDOM) {
|
||||
throw new Error('window.happyDOM is unavailable to set viewport')
|
||||
}
|
||||
happyDOM.setViewport(viewport)
|
||||
}
|
||||
|
||||
@@ -327,7 +327,7 @@ const {
|
||||
} = useAssetSelection()
|
||||
|
||||
const {
|
||||
downloadMultipleAssets,
|
||||
downloadAssets,
|
||||
deleteAssets,
|
||||
addMultipleToWorkflow,
|
||||
openMultipleWorkflows,
|
||||
@@ -533,7 +533,7 @@ function handleContextMenuHide() {
|
||||
}
|
||||
|
||||
const handleBulkDownload = (assets: AssetItem[]) => {
|
||||
downloadMultipleAssets(assets)
|
||||
downloadAssets(assets)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
@@ -559,7 +559,7 @@ const handleBulkExportWorkflow = async (assets: AssetItem[]) => {
|
||||
}
|
||||
|
||||
const handleDownloadSelected = () => {
|
||||
downloadMultipleAssets(selectedAssets.value)
|
||||
downloadAssets(selectedAssets.value)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
ref="sidebarTabRef"
|
||||
:title="title"
|
||||
v-bind="$attrs"
|
||||
:data-testid="dataTestid"
|
||||
class="workflows-sidebar-tab"
|
||||
:class="
|
||||
cn(
|
||||
'workflows-sidebar-tab',
|
||||
isOverDropZone && 'bg-primary-500/10 ring-4 ring-primary-500 ring-inset'
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #alt-title>
|
||||
<slot name="alt-title" />
|
||||
@@ -140,8 +146,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { unrefElement, useDropZone } from '@vueuse/core'
|
||||
import ConfirmDialog from 'primevue/confirmdialog'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
@@ -162,6 +170,8 @@ import {
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { getDataFromJSON } from '@/scripts/metadata/json'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
|
||||
import {
|
||||
@@ -189,6 +199,7 @@ const settingStore = useSettingStore()
|
||||
const workflowTabsPosition = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
|
||||
)
|
||||
const sidebarTabRef = useTemplateRef('sidebarTabRef')
|
||||
|
||||
const searchBoxRef = ref()
|
||||
|
||||
@@ -349,4 +360,34 @@ onMounted(async () => {
|
||||
searchBoxRef.value?.focus()
|
||||
await workflowBookmarkStore.loadBookmarks()
|
||||
})
|
||||
|
||||
const sidebarTabGetter = () => {
|
||||
const el = unrefElement(sidebarTabRef)
|
||||
return el instanceof HTMLElement ? el : undefined
|
||||
}
|
||||
|
||||
const { isOverDropZone } = useDropZone(sidebarTabGetter, {
|
||||
onDrop: async (files) => {
|
||||
if (!files?.length) return
|
||||
await Promise.allSettled(
|
||||
files.map(async (file) => {
|
||||
const { workflow } = (await getDataFromJSON(file)) ?? {}
|
||||
if (!workflow) return
|
||||
const workflowJSON = await validateComfyWorkflow(workflow)
|
||||
if (!workflowJSON) return
|
||||
|
||||
const comfyWorkflow = workflowStore.createNewTemporary(
|
||||
file.name,
|
||||
workflowJSON
|
||||
)
|
||||
await workflowStore.closeWorkflow(comfyWorkflow)
|
||||
await comfyWorkflow.save()
|
||||
})
|
||||
)
|
||||
await workflowStore.syncWorkflows()
|
||||
},
|
||||
dataTypes: ['application/json'],
|
||||
multiple: true,
|
||||
preventDefaultForUnhandled: false
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useCoreCommands } from '@/composables/useCoreCommands'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
@@ -23,12 +24,22 @@ vi.mock('vue-i18n', async () => {
|
||||
|
||||
vi.mock('@/scripts/app', () => {
|
||||
const mockGraphClear = vi.fn()
|
||||
const mockDs = {
|
||||
scale: 1,
|
||||
element: { width: 800, height: 600 } as Pick<
|
||||
HTMLCanvasElement,
|
||||
'width' | 'height'
|
||||
>,
|
||||
changeScale: vi.fn()
|
||||
}
|
||||
const mockCanvas = {
|
||||
subgraph: undefined,
|
||||
selectedItems: new Set(),
|
||||
copyToClipboard: vi.fn(),
|
||||
pasteFromClipboard: vi.fn(),
|
||||
selectItems: vi.fn()
|
||||
selectItems: vi.fn(),
|
||||
ds: mockDs,
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -39,6 +50,8 @@ vi.mock('@/scripts/app', () => {
|
||||
mockGraphClear()
|
||||
}
|
||||
}),
|
||||
openClipspace: vi.fn(),
|
||||
refreshComboInNodes: vi.fn().mockResolvedValue(undefined),
|
||||
canvas: mockCanvas,
|
||||
rootGraph: {
|
||||
clear: mockGraphClear
|
||||
@@ -81,8 +94,27 @@ vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => mockDialogService)
|
||||
}))
|
||||
|
||||
const mockResetView = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: vi.fn(() => ({}))
|
||||
useLitegraphService: vi.fn(() => ({
|
||||
resetView: mockResetView
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockTrackHelpResourceClicked = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackHelpResourceClicked: mockTrackHelpResourceClicked
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockShowAbout = vi.hoisted(() => vi.fn())
|
||||
const mockShowSettings = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
useSettingsDialog: vi.fn(() => ({
|
||||
show: mockShowSettings,
|
||||
showAbout: mockShowAbout
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
@@ -482,4 +514,97 @@ describe('useCoreCommands', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Canvas view commands', () => {
|
||||
const findCmd = (id: string) =>
|
||||
useCoreCommands().find((cmd) => cmd.id === id)!
|
||||
|
||||
it('Comfy.Canvas.ResetView delegates to litegraphService.resetView', async () => {
|
||||
await findCmd('Comfy.Canvas.ResetView').function()
|
||||
|
||||
expect(mockResetView).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Comfy.Canvas.ZoomIn scales the canvas up by 1.1× and marks it dirty', async () => {
|
||||
app.canvas.ds.scale = 1
|
||||
await findCmd('Comfy.Canvas.ZoomIn').function()
|
||||
|
||||
expect(app.canvas.ds.changeScale).toHaveBeenCalledWith(
|
||||
1.1,
|
||||
expect.any(Array)
|
||||
)
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('Comfy.Canvas.ZoomOut scales the canvas down by 1/1.1× and marks it dirty', async () => {
|
||||
app.canvas.ds.scale = 1
|
||||
await findCmd('Comfy.Canvas.ZoomOut').function()
|
||||
|
||||
expect(app.canvas.ds.changeScale).toHaveBeenCalledWith(
|
||||
1 / 1.1,
|
||||
expect.any(Array)
|
||||
)
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workflow lifecycle commands', () => {
|
||||
const findCmd = (id: string) =>
|
||||
useCoreCommands().find((cmd) => cmd.id === id)!
|
||||
|
||||
it('Comfy.OpenClipspace delegates to app.openClipspace', async () => {
|
||||
await findCmd('Comfy.OpenClipspace').function()
|
||||
|
||||
expect(app.openClipspace).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Comfy.RefreshNodeDefinitions awaits app.refreshComboInNodes', async () => {
|
||||
await findCmd('Comfy.RefreshNodeDefinitions').function()
|
||||
|
||||
expect(app.refreshComboInNodes).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Help commands', () => {
|
||||
const findCmd = (id: string) =>
|
||||
useCoreCommands().find((cmd) => cmd.id === id)!
|
||||
const { staticUrls } = useExternalLink()
|
||||
let openSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
openSpy = vi
|
||||
.spyOn(window, 'open')
|
||||
.mockImplementation(() => null as unknown as Window)
|
||||
})
|
||||
|
||||
it('Comfy.Help.OpenComfyUIIssues opens the GitHub issues URL and tracks telemetry', async () => {
|
||||
await findCmd('Comfy.Help.OpenComfyUIIssues').function()
|
||||
|
||||
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resource_type: 'github',
|
||||
is_external: true,
|
||||
source: 'menu'
|
||||
})
|
||||
)
|
||||
expect(openSpy).toHaveBeenCalledWith(staticUrls.githubIssues, '_blank')
|
||||
})
|
||||
|
||||
it('Comfy.Help.OpenComfyOrgDiscord opens the Discord URL and tracks telemetry', async () => {
|
||||
await findCmd('Comfy.Help.OpenComfyOrgDiscord').function()
|
||||
|
||||
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resource_type: 'discord'
|
||||
})
|
||||
)
|
||||
expect(openSpy).toHaveBeenCalledWith(staticUrls.discord, '_blank')
|
||||
})
|
||||
|
||||
it('Comfy.Help.AboutComfyUI opens the About dialog', async () => {
|
||||
await findCmd('Comfy.Help.AboutComfyUI').function()
|
||||
|
||||
expect(mockShowAbout).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
324
src/extensions/core/load3d/AnimationManager.test.ts
Normal file
324
src/extensions/core/load3d/AnimationManager.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import * as THREE from 'three'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AnimationManager } from './AnimationManager'
|
||||
import type { EventManagerInterface } from './interfaces'
|
||||
|
||||
function makeMockEventManager() {
|
||||
return {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
emitEvent: vi.fn()
|
||||
} satisfies EventManagerInterface
|
||||
}
|
||||
|
||||
function makeClip(name: string, duration: number): THREE.AnimationClip {
|
||||
return new THREE.AnimationClip(name, duration, [])
|
||||
}
|
||||
|
||||
function makeAnimatedModel(
|
||||
clips: THREE.AnimationClip[] = []
|
||||
): THREE.Object3D & { animations: THREE.AnimationClip[] } {
|
||||
const obj = new THREE.Object3D() as THREE.Object3D & {
|
||||
animations: THREE.AnimationClip[]
|
||||
}
|
||||
obj.animations = clips
|
||||
return obj
|
||||
}
|
||||
|
||||
describe('AnimationManager', () => {
|
||||
let events: ReturnType<typeof makeMockEventManager>
|
||||
let manager: AnimationManager
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
events = makeMockEventManager()
|
||||
manager = new AnimationManager(events)
|
||||
})
|
||||
|
||||
describe('setupModelAnimations', () => {
|
||||
it('creates a mixer and selects the first clip when the model has animations', () => {
|
||||
const clips = [makeClip('walk', 2), makeClip('run', 3)]
|
||||
const model = makeAnimatedModel(clips)
|
||||
|
||||
manager.setupModelAnimations(model, null)
|
||||
|
||||
expect(manager.currentAnimation).not.toBeNull()
|
||||
expect(manager.animationClips).toEqual(clips)
|
||||
expect(manager.selectedAnimationIndex).toBe(0)
|
||||
expect(manager.animationActions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('falls back to originalModel.animations when the model itself has none', () => {
|
||||
const clips = [makeClip('idle', 1.5)]
|
||||
const model = makeAnimatedModel([])
|
||||
const originalModel = { animations: clips } as unknown as THREE.Object3D
|
||||
|
||||
manager.setupModelAnimations(model, originalModel)
|
||||
|
||||
expect(manager.animationClips).toEqual(clips)
|
||||
expect(manager.currentAnimation).not.toBeNull()
|
||||
})
|
||||
|
||||
it('emits the localized animation list with default names when clips are unnamed', () => {
|
||||
const clips = [makeClip('', 1), makeClip('named', 1)]
|
||||
const model = makeAnimatedModel(clips)
|
||||
|
||||
manager.setupModelAnimations(model, null)
|
||||
|
||||
expect(events.emitEvent).toHaveBeenCalledWith('animationListChange', [
|
||||
{ name: 'Animation 1', index: 0 },
|
||||
{ name: 'named', index: 1 }
|
||||
])
|
||||
})
|
||||
|
||||
it('emits an empty list and leaves no actions when neither source has animations', () => {
|
||||
const model = makeAnimatedModel([])
|
||||
|
||||
manager.setupModelAnimations(model, null)
|
||||
|
||||
expect(manager.animationClips).toEqual([])
|
||||
expect(manager.animationActions).toEqual([])
|
||||
expect(events.emitEvent).toHaveBeenLastCalledWith(
|
||||
'animationListChange',
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('stops previously running actions before loading a new model', () => {
|
||||
const firstClips = [makeClip('a', 1)]
|
||||
manager.setupModelAnimations(makeAnimatedModel(firstClips), null)
|
||||
const firstAction = manager.animationActions[0]
|
||||
const stopSpy = vi.spyOn(firstAction, 'stop')
|
||||
|
||||
const secondClips = [makeClip('b', 1)]
|
||||
manager.setupModelAnimations(makeAnimatedModel(secondClips), null)
|
||||
|
||||
expect(stopSpy).toHaveBeenCalled()
|
||||
expect(manager.animationClips).toEqual(secondClips)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateSelectedAnimation', () => {
|
||||
it('warns and does nothing when called before any setup', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
manager.updateSelectedAnimation(0)
|
||||
|
||||
expect(warn).toHaveBeenCalled()
|
||||
expect(manager.animationActions).toEqual([])
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('warns when the index is out of bounds', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
manager.setupModelAnimations(
|
||||
makeAnimatedModel([makeClip('only', 1)]),
|
||||
null
|
||||
)
|
||||
|
||||
manager.updateSelectedAnimation(5)
|
||||
|
||||
expect(warn).toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('switches to the requested clip and emits an initial progress event', () => {
|
||||
const clips = [makeClip('a', 2), makeClip('b', 4)]
|
||||
manager.setupModelAnimations(makeAnimatedModel(clips), null)
|
||||
events.emitEvent.mockClear()
|
||||
|
||||
manager.updateSelectedAnimation(1)
|
||||
|
||||
expect(manager.selectedAnimationIndex).toBe(1)
|
||||
expect(manager.animationActions).toHaveLength(1)
|
||||
expect(events.emitEvent).toHaveBeenCalledWith('animationProgressChange', {
|
||||
progress: 0,
|
||||
currentTime: 0,
|
||||
duration: 4
|
||||
})
|
||||
})
|
||||
|
||||
it('starts the action paused when the manager is not currently playing', () => {
|
||||
const clips = [makeClip('a', 2)]
|
||||
manager.setupModelAnimations(makeAnimatedModel(clips), null)
|
||||
|
||||
const action = manager.animationActions[0]
|
||||
expect(action.paused).toBe(true)
|
||||
})
|
||||
|
||||
it('starts the action running when the manager is already playing', () => {
|
||||
const clips = [makeClip('a', 2), makeClip('b', 2)]
|
||||
manager.setupModelAnimations(makeAnimatedModel(clips), null)
|
||||
manager.toggleAnimation(true)
|
||||
|
||||
manager.updateSelectedAnimation(1)
|
||||
|
||||
expect(manager.animationActions[0].paused).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleAnimation', () => {
|
||||
it('warns and is a no-op when there is no animation loaded', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
manager.toggleAnimation(true)
|
||||
|
||||
expect(warn).toHaveBeenCalled()
|
||||
expect(manager.isAnimationPlaying).toBe(false)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('flips the playing state when called without an explicit value', () => {
|
||||
manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 1)]), null)
|
||||
|
||||
manager.toggleAnimation()
|
||||
expect(manager.isAnimationPlaying).toBe(true)
|
||||
manager.toggleAnimation()
|
||||
expect(manager.isAnimationPlaying).toBe(false)
|
||||
})
|
||||
|
||||
it('resets time to zero when starting from the end of the clip', () => {
|
||||
manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 2)]), null)
|
||||
const action = manager.animationActions[0]
|
||||
action.time = action.getClip().duration
|
||||
|
||||
manager.toggleAnimation(true)
|
||||
|
||||
expect(action.time).toBe(0)
|
||||
expect(action.paused).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setAnimationSpeed', () => {
|
||||
it('records the speed and propagates it to all current actions', () => {
|
||||
manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 1)]), null)
|
||||
const action = manager.animationActions[0]
|
||||
const setEffectiveTimeScale = vi.spyOn(action, 'setEffectiveTimeScale')
|
||||
|
||||
manager.setAnimationSpeed(2.5)
|
||||
|
||||
expect(manager.animationSpeed).toBe(2.5)
|
||||
expect(setEffectiveTimeScale).toHaveBeenCalledWith(2.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setAnimationTime', () => {
|
||||
it('clamps the requested time to [0, duration]', () => {
|
||||
manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 4)]), null)
|
||||
events.emitEvent.mockClear()
|
||||
|
||||
manager.setAnimationTime(-5)
|
||||
expect(events.emitEvent).toHaveBeenLastCalledWith(
|
||||
'animationProgressChange',
|
||||
{ progress: 0, currentTime: 0, duration: 4 }
|
||||
)
|
||||
|
||||
manager.setAnimationTime(99)
|
||||
expect(events.emitEvent).toHaveBeenLastCalledWith(
|
||||
'animationProgressChange',
|
||||
{ progress: 100, currentTime: 4, duration: 4 }
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the paused state across the seek', () => {
|
||||
manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 4)]), null)
|
||||
const action = manager.animationActions[0]
|
||||
action.paused = true
|
||||
|
||||
manager.setAnimationTime(2)
|
||||
|
||||
expect(action.paused).toBe(true)
|
||||
expect(action.time).toBe(2)
|
||||
})
|
||||
|
||||
it('emits a progress event reflecting the seek target', () => {
|
||||
manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 4)]), null)
|
||||
events.emitEvent.mockClear()
|
||||
|
||||
manager.setAnimationTime(1)
|
||||
|
||||
expect(events.emitEvent).toHaveBeenCalledWith('animationProgressChange', {
|
||||
progress: 25,
|
||||
currentTime: 1,
|
||||
duration: 4
|
||||
})
|
||||
})
|
||||
|
||||
it('is a no-op when no actions are loaded', () => {
|
||||
expect(() => manager.setAnimationTime(1)).not.toThrow()
|
||||
expect(events.emitEvent).not.toHaveBeenCalledWith(
|
||||
'animationProgressChange',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('update', () => {
|
||||
it('does not advance the mixer when not playing', () => {
|
||||
manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 4)]), null)
|
||||
const updateSpy = vi.spyOn(manager.currentAnimation!, 'update')
|
||||
|
||||
manager.update(0.5)
|
||||
|
||||
expect(updateSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('advances the mixer and emits progress while playing', () => {
|
||||
manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 4)]), null)
|
||||
manager.toggleAnimation(true)
|
||||
const updateSpy = vi.spyOn(manager.currentAnimation!, 'update')
|
||||
events.emitEvent.mockClear()
|
||||
|
||||
manager.update(0.25)
|
||||
|
||||
expect(updateSpy).toHaveBeenCalledWith(0.25)
|
||||
expect(events.emitEvent).toHaveBeenCalledWith(
|
||||
'animationProgressChange',
|
||||
expect.objectContaining({ duration: 4 })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getters', () => {
|
||||
it('return zero when nothing is loaded', () => {
|
||||
expect(manager.getAnimationTime()).toBe(0)
|
||||
expect(manager.getAnimationDuration()).toBe(0)
|
||||
})
|
||||
|
||||
it('reflect the current action time and clip duration', () => {
|
||||
manager.setupModelAnimations(makeAnimatedModel([makeClip('a', 7)]), null)
|
||||
|
||||
expect(manager.getAnimationDuration()).toBe(7)
|
||||
manager.animationActions[0].time = 3
|
||||
expect(manager.getAnimationTime()).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose', () => {
|
||||
it('stops all actions, clears state, and emits an empty list', () => {
|
||||
manager.setupModelAnimations(
|
||||
makeAnimatedModel([makeClip('a', 1), makeClip('b', 1)]),
|
||||
null
|
||||
)
|
||||
manager.toggleAnimation(true)
|
||||
manager.setAnimationSpeed(2)
|
||||
manager.selectedAnimationIndex = 1
|
||||
const stopSpies = manager.animationActions.map((action) =>
|
||||
vi.spyOn(action, 'stop')
|
||||
)
|
||||
events.emitEvent.mockClear()
|
||||
|
||||
manager.dispose()
|
||||
|
||||
stopSpies.forEach((spy) => expect(spy).toHaveBeenCalled())
|
||||
expect(manager.currentAnimation).toBeNull()
|
||||
expect(manager.animationActions).toEqual([])
|
||||
expect(manager.animationClips).toEqual([])
|
||||
expect(manager.selectedAnimationIndex).toBe(0)
|
||||
expect(manager.isAnimationPlaying).toBe(false)
|
||||
expect(manager.animationSpeed).toBe(1.0)
|
||||
expect(events.emitEvent).toHaveBeenCalledWith('animationListChange', [])
|
||||
})
|
||||
})
|
||||
})
|
||||
233
src/extensions/core/load3d/CameraManager.test.ts
Normal file
233
src/extensions/core/load3d/CameraManager.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import * as THREE from 'three'
|
||||
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CameraManager } from './CameraManager'
|
||||
import type { CameraState, EventManagerInterface } from './interfaces'
|
||||
|
||||
function makeMockEventManager() {
|
||||
return {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
emitEvent: vi.fn()
|
||||
} satisfies EventManagerInterface
|
||||
}
|
||||
|
||||
type ControlsListener = () => void
|
||||
|
||||
function makeControlsStub() {
|
||||
const listeners: Record<string, ControlsListener[]> = {}
|
||||
return {
|
||||
target: new THREE.Vector3(),
|
||||
object: null as THREE.Camera | null,
|
||||
update: vi.fn(),
|
||||
addEventListener: vi.fn((event: string, cb: ControlsListener) => {
|
||||
listeners[event] = listeners[event] ?? []
|
||||
listeners[event].push(cb)
|
||||
}),
|
||||
fire(event: string) {
|
||||
listeners[event]?.forEach((cb) => cb())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeRenderer(): THREE.WebGLRenderer {
|
||||
// CameraManager only stores `_renderer` but never reads it. An empty object
|
||||
// suffices and avoids needing a WebGL context in happy-dom.
|
||||
return {} as THREE.WebGLRenderer
|
||||
}
|
||||
|
||||
describe('CameraManager', () => {
|
||||
let events: ReturnType<typeof makeMockEventManager>
|
||||
let manager: CameraManager
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
events = makeMockEventManager()
|
||||
manager = new CameraManager(makeRenderer(), events)
|
||||
})
|
||||
|
||||
describe('construction', () => {
|
||||
it('creates both cameras and starts in perspective mode at the default position', () => {
|
||||
expect(manager.perspectiveCamera).toBeInstanceOf(THREE.PerspectiveCamera)
|
||||
expect(manager.orthographicCamera).toBeInstanceOf(
|
||||
THREE.OrthographicCamera
|
||||
)
|
||||
expect(manager.activeCamera).toBe(manager.perspectiveCamera)
|
||||
expect(manager.getCurrentCameraType()).toBe('perspective')
|
||||
expect(manager.perspectiveCamera.position.toArray()).toEqual([10, 10, 10])
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleCamera', () => {
|
||||
it('without an argument flips between perspective and orthographic', () => {
|
||||
manager.toggleCamera()
|
||||
expect(manager.getCurrentCameraType()).toBe('orthographic')
|
||||
manager.toggleCamera()
|
||||
expect(manager.getCurrentCameraType()).toBe('perspective')
|
||||
})
|
||||
|
||||
it('with an explicit type switches to that type', () => {
|
||||
manager.toggleCamera('orthographic')
|
||||
expect(manager.activeCamera).toBe(manager.orthographicCamera)
|
||||
})
|
||||
|
||||
it('is a no-op when explicitly switched to the active type', () => {
|
||||
const before = manager.activeCamera
|
||||
manager.toggleCamera('perspective')
|
||||
expect(manager.activeCamera).toBe(before)
|
||||
expect(events.emitEvent).not.toHaveBeenCalledWith(
|
||||
'cameraTypeChange',
|
||||
'perspective'
|
||||
)
|
||||
})
|
||||
|
||||
it('copies position, rotation, and zoom from the old camera to the new one', () => {
|
||||
manager.perspectiveCamera.position.set(1, 2, 3)
|
||||
manager.perspectiveCamera.rotation.set(0.1, 0.2, 0.3)
|
||||
manager.perspectiveCamera.zoom = 1.5
|
||||
|
||||
manager.toggleCamera('orthographic')
|
||||
|
||||
expect(manager.orthographicCamera.position.toArray()).toEqual([1, 2, 3])
|
||||
expect(manager.orthographicCamera.zoom).toBe(1.5)
|
||||
})
|
||||
|
||||
it('emits cameraTypeChange with the requested type', () => {
|
||||
manager.toggleCamera('orthographic')
|
||||
expect(events.emitEvent).toHaveBeenCalledWith(
|
||||
'cameraTypeChange',
|
||||
'orthographic'
|
||||
)
|
||||
})
|
||||
|
||||
it('rebinds the controls object and target after switching', () => {
|
||||
const controls = makeControlsStub()
|
||||
controls.target.set(5, 6, 7)
|
||||
manager.setControls(controls as unknown as OrbitControls)
|
||||
|
||||
manager.toggleCamera('orthographic')
|
||||
|
||||
expect(controls.object).toBe(manager.orthographicCamera)
|
||||
expect(controls.target.toArray()).toEqual([5, 6, 7])
|
||||
expect(controls.update).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setFOV', () => {
|
||||
it('updates the perspective FOV when perspective is active and emits the value', () => {
|
||||
manager.setFOV(60)
|
||||
|
||||
expect(manager.perspectiveCamera.fov).toBe(60)
|
||||
expect(events.emitEvent).toHaveBeenCalledWith('fovChange', 60)
|
||||
})
|
||||
|
||||
it('does not modify the perspective FOV when orthographic is active', () => {
|
||||
manager.toggleCamera('orthographic')
|
||||
events.emitEvent.mockClear()
|
||||
const before = manager.perspectiveCamera.fov
|
||||
|
||||
manager.setFOV(99)
|
||||
|
||||
expect(manager.perspectiveCamera.fov).toBe(before)
|
||||
expect(events.emitEvent).toHaveBeenCalledWith('fovChange', 99)
|
||||
})
|
||||
})
|
||||
|
||||
describe('camera state round-trip', () => {
|
||||
it('captures and restores position, target, zoom, and type', () => {
|
||||
const controls = makeControlsStub()
|
||||
controls.target.set(2, 3, 4)
|
||||
manager.setControls(controls as unknown as OrbitControls)
|
||||
manager.perspectiveCamera.position.set(7, 8, 9)
|
||||
manager.perspectiveCamera.zoom = 2
|
||||
|
||||
const snapshot = manager.getCameraState()
|
||||
|
||||
expect(snapshot.position.toArray()).toEqual([7, 8, 9])
|
||||
expect(snapshot.target.toArray()).toEqual([2, 3, 4])
|
||||
expect(snapshot.zoom).toBe(2)
|
||||
expect(snapshot.cameraType).toBe('perspective')
|
||||
|
||||
manager.perspectiveCamera.position.set(0, 0, 0)
|
||||
manager.perspectiveCamera.zoom = 1
|
||||
manager.setCameraState(snapshot)
|
||||
|
||||
expect(manager.perspectiveCamera.position.toArray()).toEqual([7, 8, 9])
|
||||
expect(manager.perspectiveCamera.zoom).toBe(2)
|
||||
expect(controls.target.toArray()).toEqual([2, 3, 4])
|
||||
})
|
||||
|
||||
it('returns a default target when no controls are attached', () => {
|
||||
const snapshot = manager.getCameraState()
|
||||
expect(snapshot.target.toArray()).toEqual([0, 0, 0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('setControls', () => {
|
||||
it('emits cameraChanged when the controls fire their end event', () => {
|
||||
const controls = makeControlsStub()
|
||||
manager.setControls(controls as unknown as OrbitControls)
|
||||
events.emitEvent.mockClear()
|
||||
|
||||
controls.fire('end')
|
||||
|
||||
expect(events.emitEvent).toHaveBeenCalledWith(
|
||||
'cameraChanged',
|
||||
expect.objectContaining({
|
||||
cameraType: 'perspective'
|
||||
}) satisfies Partial<CameraState>
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleResize', () => {
|
||||
it('updates perspective aspect when perspective is active', () => {
|
||||
manager.handleResize(800, 400)
|
||||
expect(manager.perspectiveCamera.aspect).toBeCloseTo(2)
|
||||
})
|
||||
|
||||
it('updates orthographic frustum bounds when orthographic is active', () => {
|
||||
manager.toggleCamera('orthographic')
|
||||
|
||||
manager.handleResize(800, 400)
|
||||
|
||||
const cam = manager.orthographicCamera
|
||||
const aspect = 2
|
||||
const frustumSize = 10
|
||||
expect(cam.left).toBeCloseTo((-frustumSize * aspect) / 2)
|
||||
expect(cam.right).toBeCloseTo((frustumSize * aspect) / 2)
|
||||
expect(cam.top).toBeCloseTo(frustumSize / 2)
|
||||
expect(cam.bottom).toBeCloseTo(-frustumSize / 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupForModel', () => {
|
||||
it('positions both cameras based on the model size and centers controls on the target', () => {
|
||||
const controls = makeControlsStub()
|
||||
manager.setControls(controls as unknown as OrbitControls)
|
||||
|
||||
const size = new THREE.Vector3(2, 4, 2)
|
||||
manager.setupForModel(size)
|
||||
|
||||
expect(manager.perspectiveCamera.position.toArray()).toEqual([4, 6, 4])
|
||||
expect(manager.orthographicCamera.position.toArray()).toEqual([4, 6, 4])
|
||||
expect(controls.target.toArray()).toEqual([0, 2, 0])
|
||||
expect(controls.update).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset', () => {
|
||||
it('returns both cameras to the default starting position', () => {
|
||||
manager.perspectiveCamera.position.set(99, 99, 99)
|
||||
manager.orthographicCamera.position.set(99, 99, 99)
|
||||
|
||||
manager.reset()
|
||||
|
||||
expect(manager.perspectiveCamera.position.toArray()).toEqual([10, 10, 10])
|
||||
expect(manager.orthographicCamera.position.toArray()).toEqual([
|
||||
10, 10, 10
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
168
src/extensions/core/load3d/ControlsManager.test.ts
Normal file
168
src/extensions/core/load3d/ControlsManager.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import * as THREE from 'three'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ControlsManager } from './ControlsManager'
|
||||
import type { EventManagerInterface } from './interfaces'
|
||||
|
||||
const { mockOrbitControls } = vi.hoisted(() => ({
|
||||
mockOrbitControls: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/controls/OrbitControls', () => {
|
||||
type Listener = () => void
|
||||
class OrbitControls {
|
||||
object: THREE.Camera
|
||||
domElement: HTMLElement
|
||||
enableDamping = false
|
||||
target = new THREE.Vector3()
|
||||
update = vi.fn()
|
||||
dispose = vi.fn()
|
||||
private listeners = new Map<string, Listener[]>()
|
||||
constructor(camera: THREE.Camera, domElement: HTMLElement) {
|
||||
this.object = camera
|
||||
this.domElement = domElement
|
||||
mockOrbitControls(camera, domElement)
|
||||
}
|
||||
addEventListener(event: string, cb: Listener) {
|
||||
if (!this.listeners.has(event)) this.listeners.set(event, [])
|
||||
this.listeners.get(event)!.push(cb)
|
||||
}
|
||||
fire(event: string) {
|
||||
this.listeners.get(event)?.forEach((cb) => cb())
|
||||
}
|
||||
}
|
||||
return { OrbitControls }
|
||||
})
|
||||
|
||||
function makeMockEventManager() {
|
||||
return {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
emitEvent: vi.fn()
|
||||
} satisfies EventManagerInterface
|
||||
}
|
||||
|
||||
function makeRenderer(opts: { withParent?: boolean } = {}) {
|
||||
const canvas = document.createElement('canvas')
|
||||
if (opts.withParent) {
|
||||
const parent = document.createElement('div')
|
||||
parent.appendChild(canvas)
|
||||
}
|
||||
return { domElement: canvas } as unknown as THREE.WebGLRenderer
|
||||
}
|
||||
|
||||
describe('ControlsManager', () => {
|
||||
let events: ReturnType<typeof makeMockEventManager>
|
||||
let camera: THREE.PerspectiveCamera
|
||||
let manager: ControlsManager
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
events = makeMockEventManager()
|
||||
camera = new THREE.PerspectiveCamera()
|
||||
})
|
||||
|
||||
describe('construction', () => {
|
||||
it('attaches OrbitControls to the canvas parent when one exists', () => {
|
||||
const renderer = makeRenderer({ withParent: true })
|
||||
|
||||
manager = new ControlsManager(renderer, camera, events)
|
||||
|
||||
expect(mockOrbitControls).toHaveBeenCalledWith(
|
||||
camera,
|
||||
renderer.domElement.parentElement
|
||||
)
|
||||
expect(manager.controls.enableDamping).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the canvas itself when there is no parent', () => {
|
||||
const renderer = makeRenderer({ withParent: false })
|
||||
|
||||
manager = new ControlsManager(renderer, camera, events)
|
||||
|
||||
expect(mockOrbitControls).toHaveBeenCalledWith(
|
||||
camera,
|
||||
renderer.domElement
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('init', () => {
|
||||
it('emits cameraChanged with a perspective state when the controls fire end', () => {
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
camera.position.set(1, 2, 3)
|
||||
camera.zoom = 1.25
|
||||
manager.controls.target.set(4, 5, 6)
|
||||
manager.init()
|
||||
|
||||
;(manager.controls as unknown as { fire(e: string): void }).fire('end')
|
||||
|
||||
expect(events.emitEvent).toHaveBeenCalledWith('cameraChanged', {
|
||||
position: expect.objectContaining({ x: 1, y: 2, z: 3 }),
|
||||
target: expect.objectContaining({ x: 4, y: 5, z: 6 }),
|
||||
zoom: 1.25,
|
||||
cameraType: 'perspective'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports orthographic camera type when initialized with one', () => {
|
||||
const ortho = new THREE.OrthographicCamera()
|
||||
ortho.zoom = 0.5
|
||||
manager = new ControlsManager(makeRenderer(), ortho, events)
|
||||
manager.init()
|
||||
|
||||
;(manager.controls as unknown as { fire(e: string): void }).fire('end')
|
||||
|
||||
expect(events.emitEvent).toHaveBeenCalledWith(
|
||||
'cameraChanged',
|
||||
expect.objectContaining({ cameraType: 'orthographic', zoom: 0.5 })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateCamera', () => {
|
||||
it('rebinds controls to the new camera, copies position from the previous one, and preserves the target', () => {
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
camera.position.set(7, 8, 9)
|
||||
manager.controls.target.set(1, 1, 1)
|
||||
|
||||
const newCamera = new THREE.PerspectiveCamera()
|
||||
manager.updateCamera(newCamera)
|
||||
|
||||
expect(manager.controls.object).toBe(newCamera)
|
||||
expect(newCamera.position.toArray()).toEqual([7, 8, 9])
|
||||
expect(manager.controls.target.toArray()).toEqual([1, 1, 1])
|
||||
expect(manager.controls.update).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('update / reset', () => {
|
||||
it('update delegates to controls.update', () => {
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
|
||||
manager.update()
|
||||
|
||||
expect(manager.controls.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reset clears the target back to the origin and refreshes', () => {
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
manager.controls.target.set(5, 6, 7)
|
||||
|
||||
manager.reset()
|
||||
|
||||
expect(manager.controls.target.toArray()).toEqual([0, 0, 0])
|
||||
expect(manager.controls.update).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose', () => {
|
||||
it('disposes the underlying OrbitControls', () => {
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
|
||||
manager.dispose()
|
||||
|
||||
expect(manager.controls.dispose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
68
src/extensions/core/load3d/EventManager.test.ts
Normal file
68
src/extensions/core/load3d/EventManager.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { EventManager } from './EventManager'
|
||||
|
||||
describe('EventManager', () => {
|
||||
let manager: EventManager
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
manager = new EventManager()
|
||||
})
|
||||
|
||||
describe('emitEvent', () => {
|
||||
it('does nothing when there are no listeners for the event', () => {
|
||||
expect(() => manager.emitEvent('unknown', { x: 1 })).not.toThrow()
|
||||
})
|
||||
|
||||
it('invokes every listener registered for the event with the payload', () => {
|
||||
const a = vi.fn()
|
||||
const b = vi.fn()
|
||||
manager.addEventListener('change', a)
|
||||
manager.addEventListener('change', b)
|
||||
|
||||
manager.emitEvent('change', { value: 7 })
|
||||
|
||||
expect(a).toHaveBeenCalledWith({ value: 7 })
|
||||
expect(b).toHaveBeenCalledWith({ value: 7 })
|
||||
})
|
||||
|
||||
it('does not invoke listeners registered for a different event', () => {
|
||||
const cb = vi.fn()
|
||||
manager.addEventListener('a', cb)
|
||||
|
||||
manager.emitEvent('b', null)
|
||||
|
||||
expect(cb).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeEventListener', () => {
|
||||
it('detaches a previously added listener', () => {
|
||||
const cb = vi.fn()
|
||||
manager.addEventListener('change', cb)
|
||||
|
||||
manager.removeEventListener('change', cb)
|
||||
manager.emitEvent('change', null)
|
||||
|
||||
expect(cb).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves other listeners on the same event intact', () => {
|
||||
const a = vi.fn()
|
||||
const b = vi.fn()
|
||||
manager.addEventListener('change', a)
|
||||
manager.addEventListener('change', b)
|
||||
|
||||
manager.removeEventListener('change', a)
|
||||
manager.emitEvent('change', null)
|
||||
|
||||
expect(a).not.toHaveBeenCalled()
|
||||
expect(b).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is safely a no-op for an event that has never been listened to', () => {
|
||||
expect(() => manager.removeEventListener('never', vi.fn())).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
150
src/extensions/core/load3d/LightingManager.test.ts
Normal file
150
src/extensions/core/load3d/LightingManager.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import * as THREE from 'three'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { EventManagerInterface } from './interfaces'
|
||||
import { LightingManager } from './LightingManager'
|
||||
|
||||
function makeMockEventManager() {
|
||||
return {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
emitEvent: vi.fn()
|
||||
} satisfies EventManagerInterface
|
||||
}
|
||||
|
||||
describe('LightingManager', () => {
|
||||
let scene: THREE.Scene
|
||||
let events: ReturnType<typeof makeMockEventManager>
|
||||
let manager: LightingManager
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
scene = new THREE.Scene()
|
||||
events = makeMockEventManager()
|
||||
manager = new LightingManager(scene, events)
|
||||
})
|
||||
|
||||
describe('init / setupLights', () => {
|
||||
it('adds six lights — one ambient and five directionals — to the scene', () => {
|
||||
manager.init()
|
||||
|
||||
expect(manager.lights).toHaveLength(6)
|
||||
const ambient = manager.lights.filter(
|
||||
(l) => l instanceof THREE.AmbientLight
|
||||
)
|
||||
const directional = manager.lights.filter(
|
||||
(l) => l instanceof THREE.DirectionalLight
|
||||
)
|
||||
expect(ambient).toHaveLength(1)
|
||||
expect(directional).toHaveLength(5)
|
||||
manager.lights.forEach((light) => {
|
||||
expect(scene.children).toContain(light)
|
||||
})
|
||||
})
|
||||
|
||||
it('positions the directional lights to surround the model', () => {
|
||||
manager.init()
|
||||
|
||||
const positions = manager.lights
|
||||
.filter(
|
||||
(l): l is THREE.DirectionalLight =>
|
||||
l instanceof THREE.DirectionalLight
|
||||
)
|
||||
.map((l) => l.position.toArray())
|
||||
|
||||
expect(positions).toEqual(
|
||||
expect.arrayContaining([
|
||||
[0, 10, 10],
|
||||
[0, 10, -10],
|
||||
[-10, 0, 0],
|
||||
[10, 0, 0],
|
||||
[0, -10, 0]
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setLightIntensity', () => {
|
||||
it('scales each light by its stored multiplier and records the requested intensity', () => {
|
||||
manager.init()
|
||||
const ambient = manager.lights.find(
|
||||
(l): l is THREE.AmbientLight => l instanceof THREE.AmbientLight
|
||||
)!
|
||||
const mainLight = manager.lights.find(
|
||||
(l): l is THREE.DirectionalLight =>
|
||||
l instanceof THREE.DirectionalLight &&
|
||||
l.position.y === 10 &&
|
||||
l.position.z === 10
|
||||
)!
|
||||
|
||||
manager.setLightIntensity(2)
|
||||
|
||||
expect(manager.currentIntensity).toBe(2)
|
||||
expect(ambient.intensity).toBeCloseTo(2 * 0.5)
|
||||
expect(mainLight.intensity).toBeCloseTo(2 * 0.8)
|
||||
})
|
||||
|
||||
it('emits lightIntensityChange with the new intensity', () => {
|
||||
manager.init()
|
||||
|
||||
manager.setLightIntensity(1.5)
|
||||
|
||||
expect(events.emitEvent).toHaveBeenCalledWith('lightIntensityChange', 1.5)
|
||||
})
|
||||
|
||||
it('is a no-op (no error) when called before init', () => {
|
||||
expect(() => manager.setLightIntensity(1)).not.toThrow()
|
||||
expect(events.emitEvent).toHaveBeenCalledWith('lightIntensityChange', 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setHDRIMode', () => {
|
||||
it('hides every light when HDRI is active', () => {
|
||||
manager.init()
|
||||
|
||||
manager.setHDRIMode(true)
|
||||
|
||||
manager.lights.forEach((light) => {
|
||||
expect(light.visible).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('restores visibility when HDRI is turned off', () => {
|
||||
manager.init()
|
||||
manager.setHDRIMode(true)
|
||||
|
||||
manager.setHDRIMode(false)
|
||||
|
||||
manager.lights.forEach((light) => {
|
||||
expect(light.visible).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose', () => {
|
||||
it('removes every light from the scene and clears internal state', () => {
|
||||
manager.init()
|
||||
const lightCount = manager.lights.length
|
||||
|
||||
manager.dispose()
|
||||
|
||||
expect(manager.lights).toEqual([])
|
||||
expect(
|
||||
scene.children.filter((c) => c instanceof THREE.Light)
|
||||
).toHaveLength(0)
|
||||
expect(lightCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('resets multipliers so subsequent setLightIntensity calls are no-ops', () => {
|
||||
manager.init()
|
||||
manager.dispose()
|
||||
|
||||
manager.setLightIntensity(5)
|
||||
|
||||
expect(events.emitEvent).toHaveBeenLastCalledWith(
|
||||
'lightIntensityChange',
|
||||
5
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
303
src/extensions/core/load3d/ModelExporter.test.ts
Normal file
303
src/extensions/core/load3d/ModelExporter.test.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ModelExporter } from './ModelExporter'
|
||||
|
||||
const {
|
||||
downloadBlobMock,
|
||||
addAlertMock,
|
||||
gltfParseMock,
|
||||
objParseMock,
|
||||
stlParseMock
|
||||
} = vi.hoisted(() => ({
|
||||
downloadBlobMock: vi.fn(),
|
||||
addAlertMock: vi.fn(),
|
||||
gltfParseMock: vi.fn(),
|
||||
objParseMock: vi.fn(),
|
||||
stlParseMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadBlob: downloadBlobMock
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars ? `${key}:${JSON.stringify(vars)}` : key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ addAlert: addAlertMock })
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/exporters/GLTFExporter', () => ({
|
||||
GLTFExporter: class {
|
||||
parse = gltfParseMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/exporters/OBJExporter', () => ({
|
||||
OBJExporter: class {
|
||||
parse = objParseMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/exporters/STLExporter', () => ({
|
||||
STLExporter: class {
|
||||
parse = stlParseMock
|
||||
}
|
||||
}))
|
||||
|
||||
describe('ModelExporter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('detectFormatFromURL', () => {
|
||||
it('extracts the lowercase extension from the filename query parameter', () => {
|
||||
expect(
|
||||
ModelExporter.detectFormatFromURL(
|
||||
'http://example.com/api/view?filename=model.GLB'
|
||||
)
|
||||
).toBe('glb')
|
||||
})
|
||||
|
||||
it('returns null when there is no filename parameter', () => {
|
||||
expect(
|
||||
ModelExporter.detectFormatFromURL('http://example.com/api/view?foo=bar')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when there is no query string at all', () => {
|
||||
expect(
|
||||
ModelExporter.detectFormatFromURL('http://example.com/file.glb')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the whole basename when the filename has no dotted extension', () => {
|
||||
// split('.').pop() returns the only segment when no dot is present.
|
||||
expect(
|
||||
ModelExporter.detectFormatFromURL(
|
||||
'http://example.com/api/view?filename=cube'
|
||||
)
|
||||
).toBe('cube')
|
||||
})
|
||||
})
|
||||
|
||||
describe('canUseDirectURL', () => {
|
||||
it('returns false for a null URL', () => {
|
||||
expect(ModelExporter.canUseDirectURL(null, 'glb')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when the URL extension matches the requested format (case-insensitive)', () => {
|
||||
expect(
|
||||
ModelExporter.canUseDirectURL(
|
||||
'http://example.com/api/view?filename=cube.GLB',
|
||||
'glb'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when the URL extension does not match', () => {
|
||||
expect(
|
||||
ModelExporter.canUseDirectURL(
|
||||
'http://example.com/api/view?filename=cube.obj',
|
||||
'glb'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when the URL has no detectable format', () => {
|
||||
expect(
|
||||
ModelExporter.canUseDirectURL('http://example.com/file.glb', 'glb')
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadFromURL', () => {
|
||||
it('fetches the URL and downloads the resulting blob', async () => {
|
||||
const blob = new Blob(['x'])
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) })
|
||||
)
|
||||
|
||||
await ModelExporter.downloadFromURL(
|
||||
'http://example.com/cube.glb',
|
||||
'cube.glb'
|
||||
)
|
||||
|
||||
expect(downloadBlobMock).toHaveBeenCalledWith('cube.glb', blob)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('rethrows and shows a toast alert when fetch fails', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')))
|
||||
|
||||
await expect(
|
||||
ModelExporter.downloadFromURL('http://example.com/cube.glb', 'cube.glb')
|
||||
).rejects.toThrow('network')
|
||||
expect(addAlertMock).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToDownloadFile'
|
||||
)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportGLB', () => {
|
||||
it('takes the direct-URL fast path when the original URL is already a .glb', async () => {
|
||||
const blob = new Blob(['x'])
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) })
|
||||
)
|
||||
const model = new THREE.Object3D()
|
||||
|
||||
await ModelExporter.exportGLB(
|
||||
model,
|
||||
'out.glb',
|
||||
'http://example.com/api/view?filename=src.glb'
|
||||
)
|
||||
|
||||
expect(downloadBlobMock).toHaveBeenCalledWith('out.glb', blob)
|
||||
expect(gltfParseMock).not.toHaveBeenCalled()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('falls through to GLTFExporter when there is no direct URL', async () => {
|
||||
gltfParseMock.mockImplementation(
|
||||
(
|
||||
_model: unknown,
|
||||
onDone: (gltf: ArrayBuffer) => void,
|
||||
_onError: unknown,
|
||||
options: { binary: boolean }
|
||||
) => {
|
||||
expect(options.binary).toBe(true)
|
||||
onDone(new ArrayBuffer(8))
|
||||
}
|
||||
)
|
||||
|
||||
const promise = ModelExporter.exportGLB(new THREE.Object3D(), 'out.glb')
|
||||
await vi.runAllTimersAsync()
|
||||
await promise
|
||||
|
||||
expect(gltfParseMock).toHaveBeenCalled()
|
||||
expect(downloadBlobMock).toHaveBeenCalledWith('out.glb', expect.any(Blob))
|
||||
})
|
||||
|
||||
it('alerts and rethrows when GLTFExporter rejects', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
gltfParseMock.mockImplementation(
|
||||
(_model: unknown, _onDone: unknown, onError: (e: Error) => void) =>
|
||||
onError(new Error('parse fail'))
|
||||
)
|
||||
|
||||
const promise = ModelExporter.exportGLB(new THREE.Object3D(), 'out.glb')
|
||||
const assertion = expect(promise).rejects.toThrow('parse fail')
|
||||
await vi.runAllTimersAsync()
|
||||
await assertion
|
||||
expect(addAlertMock).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToExportModel:{"format":"GLB"}'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportOBJ', () => {
|
||||
it('uses the direct-URL fast path for matching .obj URLs', async () => {
|
||||
const blob = new Blob(['x'])
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) })
|
||||
)
|
||||
|
||||
await ModelExporter.exportOBJ(
|
||||
new THREE.Object3D(),
|
||||
'out.obj',
|
||||
'http://example.com/api/view?filename=src.obj'
|
||||
)
|
||||
|
||||
expect(downloadBlobMock).toHaveBeenCalledWith('out.obj', blob)
|
||||
expect(objParseMock).not.toHaveBeenCalled()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('serializes via OBJExporter and downloads as text when there is no direct URL', async () => {
|
||||
objParseMock.mockReturnValue('# obj data')
|
||||
|
||||
const promise = ModelExporter.exportOBJ(new THREE.Object3D(), 'out.obj')
|
||||
await vi.runAllTimersAsync()
|
||||
await promise
|
||||
|
||||
expect(objParseMock).toHaveBeenCalled()
|
||||
expect(downloadBlobMock).toHaveBeenCalledWith('out.obj', expect.any(Blob))
|
||||
})
|
||||
|
||||
it('alerts and rethrows when OBJExporter throws', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
objParseMock.mockImplementation(() => {
|
||||
throw new Error('obj fail')
|
||||
})
|
||||
|
||||
const promise = ModelExporter.exportOBJ(new THREE.Object3D(), 'out.obj')
|
||||
const assertion = expect(promise).rejects.toThrow('obj fail')
|
||||
await vi.runAllTimersAsync()
|
||||
await assertion
|
||||
expect(addAlertMock).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToExportModel:{"format":"OBJ"}'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportSTL', () => {
|
||||
it('uses the direct-URL fast path for matching .stl URLs', async () => {
|
||||
const blob = new Blob(['x'])
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) })
|
||||
)
|
||||
|
||||
await ModelExporter.exportSTL(
|
||||
new THREE.Object3D(),
|
||||
'out.stl',
|
||||
'http://example.com/api/view?filename=src.stl'
|
||||
)
|
||||
|
||||
expect(downloadBlobMock).toHaveBeenCalledWith('out.stl', blob)
|
||||
expect(stlParseMock).not.toHaveBeenCalled()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('serializes via STLExporter and downloads as text when there is no direct URL', async () => {
|
||||
stlParseMock.mockReturnValue('solid model')
|
||||
|
||||
const promise = ModelExporter.exportSTL(new THREE.Object3D(), 'out.stl')
|
||||
await vi.runAllTimersAsync()
|
||||
await promise
|
||||
|
||||
expect(stlParseMock).toHaveBeenCalled()
|
||||
expect(downloadBlobMock).toHaveBeenCalledWith('out.stl', expect.any(Blob))
|
||||
})
|
||||
|
||||
it('alerts and rethrows when STLExporter throws', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
stlParseMock.mockImplementation(() => {
|
||||
throw new Error('stl fail')
|
||||
})
|
||||
|
||||
const promise = ModelExporter.exportSTL(new THREE.Object3D(), 'out.stl')
|
||||
const assertion = expect(promise).rejects.toThrow('stl fail')
|
||||
await vi.runAllTimersAsync()
|
||||
await assertion
|
||||
expect(addAlertMock).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToExportModel:{"format":"STL"}'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
317
src/extensions/core/load3d/RecordingManager.test.ts
Normal file
317
src/extensions/core/load3d/RecordingManager.test.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { EventManagerInterface } from './interfaces'
|
||||
import { RecordingManager } from './RecordingManager'
|
||||
|
||||
const { downloadBlobMock } = vi.hoisted(() => ({
|
||||
downloadBlobMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadBlob: downloadBlobMock
|
||||
}))
|
||||
|
||||
vi.mock('three', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof THREE>()
|
||||
// Avoid TextureLoader -> ImageLoader -> new Image() in happy-dom.
|
||||
class StubTextureLoader {
|
||||
load() {
|
||||
return new actual.Texture()
|
||||
}
|
||||
}
|
||||
return { ...actual, TextureLoader: StubTextureLoader }
|
||||
})
|
||||
|
||||
type DataAvailableHandler = (event: { data: Blob }) => void
|
||||
type StopHandler = () => void
|
||||
|
||||
class MockMediaRecorder {
|
||||
static instances: MockMediaRecorder[] = []
|
||||
ondataavailable: DataAvailableHandler | null = null
|
||||
onstop: StopHandler | null = null
|
||||
state: 'inactive' | 'recording' | 'paused' = 'inactive'
|
||||
constructor(
|
||||
public stream: MediaStream,
|
||||
public options?: MediaRecorderOptions
|
||||
) {
|
||||
MockMediaRecorder.instances.push(this)
|
||||
}
|
||||
start = vi.fn(() => {
|
||||
this.state = 'recording'
|
||||
})
|
||||
stop = vi.fn(() => {
|
||||
this.state = 'inactive'
|
||||
this.onstop?.()
|
||||
})
|
||||
pushChunk(blob: Blob) {
|
||||
this.ondataavailable?.({ data: blob })
|
||||
}
|
||||
}
|
||||
|
||||
function makeMockEventManager() {
|
||||
return {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
emitEvent: vi.fn()
|
||||
} satisfies EventManagerInterface
|
||||
}
|
||||
|
||||
function makeStream(): MediaStream {
|
||||
const tracks: { stop: ReturnType<typeof vi.fn> }[] = [{ stop: vi.fn() }]
|
||||
return {
|
||||
getTracks: () => tracks
|
||||
} as unknown as MediaStream
|
||||
}
|
||||
|
||||
function makeRenderer(): THREE.WebGLRenderer {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 800
|
||||
canvas.height = 600
|
||||
return { domElement: canvas } as unknown as THREE.WebGLRenderer
|
||||
}
|
||||
|
||||
describe('RecordingManager', () => {
|
||||
let scene: THREE.Scene
|
||||
let renderer: THREE.WebGLRenderer
|
||||
let events: ReturnType<typeof makeMockEventManager>
|
||||
let manager: RecordingManager
|
||||
let rafSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
MockMediaRecorder.instances = []
|
||||
vi.stubGlobal('MediaRecorder', MockMediaRecorder)
|
||||
vi.stubGlobal('URL', {
|
||||
...URL,
|
||||
createObjectURL: vi.fn(() => 'blob:mock'),
|
||||
revokeObjectURL: vi.fn()
|
||||
})
|
||||
// happy-dom canvases lack captureStream; stub it on the prototype so
|
||||
// every canvas the production code creates gets a usable stream.
|
||||
vi.spyOn(
|
||||
HTMLCanvasElement.prototype as unknown as {
|
||||
captureStream: (fps?: number) => MediaStream
|
||||
},
|
||||
'captureStream'
|
||||
).mockImplementation(makeStream)
|
||||
// happy-dom returns null from getContext('2d'); production code throws
|
||||
// without it. Provide a minimal context with the methods the manager calls.
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
drawImage: vi.fn()
|
||||
} as unknown as ReturnType<HTMLCanvasElement['getContext']>)
|
||||
rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(1)
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
|
||||
scene = new THREE.Scene()
|
||||
renderer = makeRenderer()
|
||||
events = makeMockEventManager()
|
||||
manager = new RecordingManager(scene, renderer, events)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('construction', () => {
|
||||
it('adds a hidden recording indicator sprite to the scene', () => {
|
||||
const sprite = scene.children.find((c) => c instanceof THREE.Sprite) as
|
||||
| THREE.Sprite
|
||||
| undefined
|
||||
|
||||
expect(sprite).toBeDefined()
|
||||
expect(sprite!.visible).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startRecording', () => {
|
||||
it('initializes a MediaRecorder, marks recording state, and emits recordingStarted', async () => {
|
||||
await manager.startRecording()
|
||||
|
||||
expect(MockMediaRecorder.instances).toHaveLength(1)
|
||||
expect(MockMediaRecorder.instances[0].start).toHaveBeenCalledWith(100)
|
||||
expect(manager.getIsRecording()).toBe(true)
|
||||
expect(events.emitEvent).toHaveBeenCalledWith('recordingStarted', null)
|
||||
})
|
||||
|
||||
it('shows the recording indicator sprite', async () => {
|
||||
const sprite = scene.children.find(
|
||||
(c) => c instanceof THREE.Sprite
|
||||
) as THREE.Sprite
|
||||
|
||||
await manager.startRecording()
|
||||
|
||||
expect(sprite.visible).toBe(true)
|
||||
})
|
||||
|
||||
it('begins capturing frames via requestAnimationFrame', async () => {
|
||||
await manager.startRecording()
|
||||
expect(rafSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is idempotent — a second startRecording while already recording is ignored', async () => {
|
||||
await manager.startRecording()
|
||||
await manager.startRecording()
|
||||
|
||||
expect(MockMediaRecorder.instances).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits recordingError when MediaRecorder construction fails', async () => {
|
||||
vi.stubGlobal(
|
||||
'MediaRecorder',
|
||||
class {
|
||||
constructor() {
|
||||
throw new Error('codec not supported')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await manager.startRecording()
|
||||
|
||||
expect(events.emitEvent).toHaveBeenCalledWith(
|
||||
'recordingError',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(manager.getIsRecording()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopRecording', () => {
|
||||
it('is a no-op when not currently recording', () => {
|
||||
manager.stopRecording()
|
||||
expect(events.emitEvent).not.toHaveBeenCalledWith(
|
||||
'recordingStopped',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the indicator, clears recording state, and emits recordingStopped', async () => {
|
||||
await manager.startRecording()
|
||||
const sprite = scene.children.find(
|
||||
(c) => c instanceof THREE.Sprite
|
||||
) as THREE.Sprite
|
||||
|
||||
manager.stopRecording()
|
||||
|
||||
expect(sprite.visible).toBe(false)
|
||||
expect(manager.getIsRecording()).toBe(false)
|
||||
expect(events.emitEvent).toHaveBeenCalledWith(
|
||||
'recordingStopped',
|
||||
expect.objectContaining({ hasRecording: false })
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a non-zero duration after recording', async () => {
|
||||
await manager.startRecording()
|
||||
// Force a known startTime so duration math is deterministic.
|
||||
;(
|
||||
manager as unknown as { recordingStartTime: number }
|
||||
).recordingStartTime = Date.now() - 2000
|
||||
|
||||
manager.stopRecording()
|
||||
|
||||
expect(manager.getRecordingDuration()).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasRecording / getRecordingData', () => {
|
||||
it('reports no recording until chunks have been received', async () => {
|
||||
await manager.startRecording()
|
||||
expect(manager.hasRecording()).toBe(false)
|
||||
expect(manager.getRecordingData()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns a blob URL once chunks exist', async () => {
|
||||
await manager.startRecording()
|
||||
MockMediaRecorder.instances[0].pushChunk(new Blob(['x']))
|
||||
|
||||
expect(manager.hasRecording()).toBe(true)
|
||||
expect(manager.getRecordingData()).toBe('blob:mock')
|
||||
})
|
||||
|
||||
it('does not push zero-byte chunks', async () => {
|
||||
await manager.startRecording()
|
||||
MockMediaRecorder.instances[0].pushChunk(new Blob([]))
|
||||
|
||||
expect(manager.hasRecording()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportRecording', () => {
|
||||
it('emits a recordingError when there is nothing to export', () => {
|
||||
manager.exportRecording()
|
||||
|
||||
expect(events.emitEvent).toHaveBeenCalledWith(
|
||||
'recordingError',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(downloadBlobMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('downloads the blob with the requested filename and emits exportingRecording then recordingExported', async () => {
|
||||
await manager.startRecording()
|
||||
MockMediaRecorder.instances[0].pushChunk(new Blob(['x']))
|
||||
|
||||
manager.exportRecording('clip.webm')
|
||||
|
||||
expect(downloadBlobMock).toHaveBeenCalledWith(
|
||||
'clip.webm',
|
||||
expect.any(Blob)
|
||||
)
|
||||
expect(events.emitEvent).toHaveBeenCalledWith('exportingRecording', null)
|
||||
expect(events.emitEvent).toHaveBeenCalledWith('recordingExported', null)
|
||||
})
|
||||
|
||||
it('uses the default filename when none is provided', async () => {
|
||||
await manager.startRecording()
|
||||
MockMediaRecorder.instances[0].pushChunk(new Blob(['x']))
|
||||
|
||||
manager.exportRecording()
|
||||
|
||||
expect(downloadBlobMock).toHaveBeenCalledWith(
|
||||
'scene-recording.mp4',
|
||||
expect.any(Blob)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearRecording', () => {
|
||||
it('drops all chunks, resets duration, and emits recordingCleared', async () => {
|
||||
await manager.startRecording()
|
||||
MockMediaRecorder.instances[0].pushChunk(new Blob(['x']))
|
||||
manager.stopRecording()
|
||||
|
||||
manager.clearRecording()
|
||||
|
||||
expect(manager.hasRecording()).toBe(false)
|
||||
expect(manager.getRecordingDuration()).toBe(0)
|
||||
expect(events.emitEvent).toHaveBeenCalledWith('recordingCleared', null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose', () => {
|
||||
it('removes the indicator sprite from the scene and disposes its material', async () => {
|
||||
const sprite = scene.children.find(
|
||||
(c) => c instanceof THREE.Sprite
|
||||
) as THREE.Sprite
|
||||
const disposeSpy = vi.spyOn(
|
||||
sprite.material as THREE.SpriteMaterial,
|
||||
'dispose'
|
||||
)
|
||||
|
||||
manager.dispose()
|
||||
|
||||
expect(scene.children).not.toContain(sprite)
|
||||
expect(disposeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops an in-flight recording before disposing', async () => {
|
||||
await manager.startRecording()
|
||||
|
||||
manager.dispose()
|
||||
|
||||
expect(MockMediaRecorder.instances[0].stop).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
546
src/extensions/core/load3d/SceneManager.test.ts
Normal file
546
src/extensions/core/load3d/SceneManager.test.ts
Normal file
@@ -0,0 +1,546 @@
|
||||
import * as THREE from 'three'
|
||||
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { EventManagerInterface } from './interfaces'
|
||||
import Load3dUtils from './Load3dUtils'
|
||||
import { SceneManager } from './SceneManager'
|
||||
|
||||
const { mockTextureLoad } = vi.hoisted(() => ({
|
||||
mockTextureLoad: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./Load3dUtils', () => ({
|
||||
default: {
|
||||
splitFilePath: vi.fn(),
|
||||
getResourceURL: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('three', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof THREE>()
|
||||
class StubTextureLoader {
|
||||
load = mockTextureLoad
|
||||
}
|
||||
return { ...actual, TextureLoader: StubTextureLoader }
|
||||
})
|
||||
|
||||
function makeMockEventManager() {
|
||||
return {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
emitEvent: vi.fn()
|
||||
} satisfies EventManagerInterface
|
||||
}
|
||||
|
||||
function makeRenderer() {
|
||||
const canvas = document.createElement('canvas')
|
||||
Object.defineProperty(canvas, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: 800
|
||||
})
|
||||
Object.defineProperty(canvas, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: 600
|
||||
})
|
||||
canvas.width = 800
|
||||
canvas.height = 600
|
||||
vi.spyOn(canvas, 'toDataURL').mockReturnValue('data:image/png;base64,FAKE')
|
||||
return {
|
||||
domElement: canvas,
|
||||
setClearColor: vi.fn(),
|
||||
setSize: vi.fn(),
|
||||
render: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
getClearColor: vi.fn().mockReturnValue(new THREE.Color(0xffffff)),
|
||||
getClearAlpha: vi.fn().mockReturnValue(1),
|
||||
toneMapping: THREE.NoToneMapping,
|
||||
toneMappingExposure: 1,
|
||||
outputColorSpace: THREE.SRGBColorSpace
|
||||
} as unknown as THREE.WebGLRenderer
|
||||
}
|
||||
|
||||
function makeImageTexture(width = 200, height = 100): THREE.Texture {
|
||||
const texture = new THREE.Texture()
|
||||
;(texture as unknown as { image: { width: number; height: number } }).image =
|
||||
{
|
||||
width,
|
||||
height
|
||||
}
|
||||
return texture
|
||||
}
|
||||
|
||||
describe('SceneManager', () => {
|
||||
let renderer: THREE.WebGLRenderer
|
||||
let camera: THREE.PerspectiveCamera
|
||||
let events: ReturnType<typeof makeMockEventManager>
|
||||
let manager: SceneManager
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
renderer = makeRenderer()
|
||||
camera = new THREE.PerspectiveCamera()
|
||||
events = makeMockEventManager()
|
||||
manager = new SceneManager(
|
||||
renderer,
|
||||
() => camera,
|
||||
() => ({}) as unknown as OrbitControls,
|
||||
events
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('construction', () => {
|
||||
it('builds the main scene with a grid helper at the origin', () => {
|
||||
expect(manager.scene).toBeInstanceOf(THREE.Scene)
|
||||
expect(manager.gridHelper).toBeInstanceOf(THREE.GridHelper)
|
||||
expect(manager.scene.children).toContain(manager.gridHelper)
|
||||
})
|
||||
|
||||
it('builds a separate background scene with a tiled mesh', () => {
|
||||
expect(manager.backgroundScene).toBeInstanceOf(THREE.Scene)
|
||||
expect(manager.backgroundMesh).toBeInstanceOf(THREE.Mesh)
|
||||
expect(manager.backgroundScene.children).toContain(manager.backgroundMesh)
|
||||
expect(manager.backgroundColorMaterial).toBeInstanceOf(
|
||||
THREE.MeshBasicMaterial
|
||||
)
|
||||
})
|
||||
|
||||
it('initializes the background to color mode at the default color', () => {
|
||||
expect(manager.currentBackgroundType).toBe('color')
|
||||
expect(manager.currentBackgroundColor).toBe('#282828')
|
||||
expect(manager.backgroundRenderMode).toBe('tiled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleGrid', () => {
|
||||
it('hides and shows the grid and emits showGridChange', () => {
|
||||
manager.toggleGrid(false)
|
||||
expect(manager.gridHelper.visible).toBe(false)
|
||||
expect(events.emitEvent).toHaveBeenCalledWith('showGridChange', false)
|
||||
|
||||
manager.toggleGrid(true)
|
||||
expect(manager.gridHelper.visible).toBe(true)
|
||||
expect(events.emitEvent).toHaveBeenCalledWith('showGridChange', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setBackgroundColor', () => {
|
||||
it('updates the material color and emits backgroundColorChange', () => {
|
||||
manager.setBackgroundColor('#ff0000')
|
||||
|
||||
expect(manager.currentBackgroundColor).toBe('#ff0000')
|
||||
expect(manager.currentBackgroundType).toBe('color')
|
||||
expect(events.emitEvent).toHaveBeenCalledWith(
|
||||
'backgroundColorChange',
|
||||
'#ff0000'
|
||||
)
|
||||
})
|
||||
|
||||
it('clears any prior texture-based scene background', () => {
|
||||
manager.scene.background = makeImageTexture()
|
||||
|
||||
manager.setBackgroundColor('#abcdef')
|
||||
|
||||
expect(manager.scene.background).toBeNull()
|
||||
})
|
||||
|
||||
it('demotes panorama mode back to tiled and emits the change', () => {
|
||||
manager.backgroundRenderMode = 'panorama'
|
||||
|
||||
manager.setBackgroundColor('#abcdef')
|
||||
|
||||
expect(manager.backgroundRenderMode).toBe('tiled')
|
||||
expect(events.emitEvent).toHaveBeenCalledWith(
|
||||
'backgroundRenderModeChange',
|
||||
'tiled'
|
||||
)
|
||||
})
|
||||
|
||||
it('disposes any prior background texture', () => {
|
||||
const texture = makeImageTexture()
|
||||
const dispose = vi.spyOn(texture, 'dispose')
|
||||
manager.backgroundTexture = texture
|
||||
|
||||
manager.setBackgroundColor('#000000')
|
||||
|
||||
expect(dispose).toHaveBeenCalled()
|
||||
expect(manager.backgroundTexture).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setBackgroundImage', () => {
|
||||
it('falls back to setBackgroundColor when given an empty path', async () => {
|
||||
const setBackgroundColor = vi.spyOn(manager, 'setBackgroundColor')
|
||||
|
||||
await manager.setBackgroundImage('')
|
||||
|
||||
expect(setBackgroundColor).toHaveBeenCalledWith(
|
||||
manager.currentBackgroundColor
|
||||
)
|
||||
})
|
||||
|
||||
it('emits a loading-start event before fetching', async () => {
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'bg.png'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/api/view?bg.png')
|
||||
mockTextureLoad.mockImplementation(
|
||||
(_url: string, resolve: (t: THREE.Texture) => void) =>
|
||||
resolve(makeImageTexture())
|
||||
)
|
||||
|
||||
const promise = manager.setBackgroundImage('bg.png')
|
||||
expect(events.emitEvent).toHaveBeenCalledWith(
|
||||
'backgroundImageLoadingStart',
|
||||
null
|
||||
)
|
||||
await promise
|
||||
})
|
||||
|
||||
it('rewrites temp/output subfolders to a flat path with the right type', async () => {
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['temp', 'out.png'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view?out.png')
|
||||
mockTextureLoad.mockImplementation(
|
||||
(_url: string, resolve: (t: THREE.Texture) => void) =>
|
||||
resolve(makeImageTexture())
|
||||
)
|
||||
|
||||
await manager.setBackgroundImage('temp/out.png')
|
||||
|
||||
expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
|
||||
'',
|
||||
'out.png',
|
||||
'temp'
|
||||
)
|
||||
})
|
||||
|
||||
it('prefixes /api when getResourceURL returns a non-/api URL', async () => {
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'bg.png'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view?bg.png')
|
||||
const captured: string[] = []
|
||||
mockTextureLoad.mockImplementation(
|
||||
(url: string, resolve: (t: THREE.Texture) => void) => {
|
||||
captured.push(url)
|
||||
resolve(makeImageTexture())
|
||||
}
|
||||
)
|
||||
|
||||
await manager.setBackgroundImage('bg.png')
|
||||
|
||||
expect(captured[0]).toBe('/api/view?bg.png')
|
||||
})
|
||||
|
||||
it('in tiled mode, swaps the background mesh material to use the new texture', async () => {
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'bg.png'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/api/bg')
|
||||
const texture = makeImageTexture()
|
||||
mockTextureLoad.mockImplementation(
|
||||
(_url: string, resolve: (t: THREE.Texture) => void) => resolve(texture)
|
||||
)
|
||||
|
||||
await manager.setBackgroundImage('bg.png')
|
||||
|
||||
expect(manager.currentBackgroundType).toBe('image')
|
||||
expect(manager.backgroundTexture).toBe(texture)
|
||||
expect(manager.backgroundMesh!.material).not.toBe(
|
||||
manager.backgroundColorMaterial
|
||||
)
|
||||
expect(events.emitEvent).toHaveBeenCalledWith(
|
||||
'backgroundImageChange',
|
||||
'bg.png'
|
||||
)
|
||||
expect(events.emitEvent).toHaveBeenCalledWith(
|
||||
'backgroundImageLoadingEnd',
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it('in panorama mode, assigns the texture as the scene background with equirectangular mapping', async () => {
|
||||
manager.backgroundRenderMode = 'panorama'
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'bg.png'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/api/bg')
|
||||
const texture = makeImageTexture()
|
||||
mockTextureLoad.mockImplementation(
|
||||
(_url: string, resolve: (t: THREE.Texture) => void) => resolve(texture)
|
||||
)
|
||||
|
||||
await manager.setBackgroundImage('bg.png')
|
||||
|
||||
expect(manager.scene.background).toBe(texture)
|
||||
expect(texture.mapping).toBe(THREE.EquirectangularReflectionMapping)
|
||||
})
|
||||
|
||||
it('disposes a previously loaded background texture before assigning the new one', async () => {
|
||||
const previous = makeImageTexture()
|
||||
const disposePrev = vi.spyOn(previous, 'dispose')
|
||||
manager.backgroundTexture = previous
|
||||
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'bg.png'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/api/bg')
|
||||
mockTextureLoad.mockImplementation(
|
||||
(_url: string, resolve: (t: THREE.Texture) => void) =>
|
||||
resolve(makeImageTexture())
|
||||
)
|
||||
|
||||
await manager.setBackgroundImage('bg.png')
|
||||
|
||||
expect(disposePrev).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('on load failure, emits loading-end and falls back to a color background', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'bg.png'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/api/bg')
|
||||
mockTextureLoad.mockImplementation(
|
||||
(
|
||||
_url: string,
|
||||
_resolve: unknown,
|
||||
_onProgress: unknown,
|
||||
reject: (e: Error) => void
|
||||
) => reject(new Error('load failed'))
|
||||
)
|
||||
const setBackgroundColor = vi.spyOn(manager, 'setBackgroundColor')
|
||||
|
||||
await manager.setBackgroundImage('bg.png')
|
||||
|
||||
expect(events.emitEvent).toHaveBeenCalledWith(
|
||||
'backgroundImageLoadingEnd',
|
||||
null
|
||||
)
|
||||
expect(setBackgroundColor).toHaveBeenCalledWith(
|
||||
manager.currentBackgroundColor
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeBackgroundImage', () => {
|
||||
it('reverts to the current color and emits loading-end', () => {
|
||||
const setBackgroundColor = vi.spyOn(manager, 'setBackgroundColor')
|
||||
|
||||
manager.removeBackgroundImage()
|
||||
|
||||
expect(setBackgroundColor).toHaveBeenCalledWith(
|
||||
manager.currentBackgroundColor
|
||||
)
|
||||
expect(events.emitEvent).toHaveBeenCalledWith(
|
||||
'backgroundImageLoadingEnd',
|
||||
null
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setBackgroundRenderMode', () => {
|
||||
it('is a no-op when the requested mode equals the current mode', () => {
|
||||
events.emitEvent.mockClear()
|
||||
|
||||
manager.setBackgroundRenderMode('tiled')
|
||||
|
||||
expect(events.emitEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('switches to panorama on a color background and just emits the change', () => {
|
||||
manager.setBackgroundRenderMode('panorama')
|
||||
|
||||
expect(manager.backgroundRenderMode).toBe('panorama')
|
||||
expect(events.emitEvent).toHaveBeenCalledWith(
|
||||
'backgroundRenderModeChange',
|
||||
'panorama'
|
||||
)
|
||||
})
|
||||
|
||||
it('promotes an image background to scene.background when switching to panorama', () => {
|
||||
manager.currentBackgroundType = 'image'
|
||||
const texture = makeImageTexture()
|
||||
manager.backgroundTexture = texture
|
||||
|
||||
manager.setBackgroundRenderMode('panorama')
|
||||
|
||||
expect(manager.scene.background).toBe(texture)
|
||||
expect(texture.mapping).toBe(THREE.EquirectangularReflectionMapping)
|
||||
})
|
||||
|
||||
it('demotes back to tiled by clearing scene.background and updating the mesh map', () => {
|
||||
manager.currentBackgroundType = 'image'
|
||||
const texture = makeImageTexture()
|
||||
manager.backgroundTexture = texture
|
||||
manager.scene.background = texture
|
||||
manager.backgroundRenderMode = 'panorama'
|
||||
|
||||
manager.setBackgroundRenderMode('tiled')
|
||||
|
||||
expect(manager.scene.background).toBeNull()
|
||||
const mat = manager.backgroundMesh!.material as THREE.MeshBasicMaterial
|
||||
expect(mat.map).toBe(texture)
|
||||
// THREE's `needsUpdate` is a write-only setter — reading is undefined.
|
||||
// Asserting the map swap is sufficient to validate the demote path.
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateBackgroundSize', () => {
|
||||
it('does nothing without a texture or mesh', () => {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(1, 1),
|
||||
new THREE.MeshBasicMaterial()
|
||||
)
|
||||
const before = mesh.scale.toArray()
|
||||
|
||||
manager.updateBackgroundSize(null, mesh, 100, 100)
|
||||
|
||||
expect(mesh.scale.toArray()).toEqual(before)
|
||||
})
|
||||
|
||||
it('does nothing without a mesh', () => {
|
||||
expect(() =>
|
||||
manager.updateBackgroundSize(makeImageTexture(), null, 100, 100)
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('does nothing when the mesh material has no map', () => {
|
||||
const texture = makeImageTexture(400, 100)
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(1, 1),
|
||||
new THREE.MeshBasicMaterial() // no map
|
||||
)
|
||||
const before = mesh.scale.toArray()
|
||||
|
||||
manager.updateBackgroundSize(texture, mesh, 200, 100)
|
||||
|
||||
expect(mesh.scale.toArray()).toEqual(before)
|
||||
})
|
||||
|
||||
it('scales horizontally when the image is wider than the target', () => {
|
||||
const texture = makeImageTexture(400, 100) // imageAspect = 4
|
||||
const mat = new THREE.MeshBasicMaterial({ map: texture })
|
||||
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), mat)
|
||||
|
||||
manager.updateBackgroundSize(texture, mesh, 200, 100) // targetAspect = 2
|
||||
|
||||
expect(mesh.scale.x).toBeCloseTo(2)
|
||||
expect(mesh.scale.y).toBe(1)
|
||||
})
|
||||
|
||||
it('scales vertically when the image is taller than the target', () => {
|
||||
const texture = makeImageTexture(100, 400) // imageAspect = 0.25
|
||||
const mat = new THREE.MeshBasicMaterial({ map: texture })
|
||||
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), mat)
|
||||
|
||||
manager.updateBackgroundSize(texture, mesh, 200, 100) // targetAspect = 2
|
||||
|
||||
expect(mesh.scale.x).toBe(1)
|
||||
expect(mesh.scale.y).toBeCloseTo(8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleResize', () => {
|
||||
it('updates background size when an image background is active', () => {
|
||||
const texture = makeImageTexture(400, 100)
|
||||
manager.backgroundTexture = texture
|
||||
manager.currentBackgroundType = 'image'
|
||||
;(manager.backgroundMesh!.material as THREE.MeshBasicMaterial).map =
|
||||
texture
|
||||
const update = vi.spyOn(manager, 'updateBackgroundSize')
|
||||
|
||||
manager.handleResize(800, 600)
|
||||
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
texture,
|
||||
manager.backgroundMesh,
|
||||
800,
|
||||
600
|
||||
)
|
||||
})
|
||||
|
||||
it('does nothing when only a color background is active', () => {
|
||||
const update = vi.spyOn(manager, 'updateBackgroundSize')
|
||||
|
||||
manager.handleResize(800, 600)
|
||||
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCurrentBackgroundInfo', () => {
|
||||
it('returns the color when in color mode', () => {
|
||||
manager.setBackgroundColor('#abc123')
|
||||
|
||||
expect(manager.getCurrentBackgroundInfo()).toEqual({
|
||||
type: 'color',
|
||||
value: '#abc123'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an empty value when in image mode', () => {
|
||||
manager.currentBackgroundType = 'image'
|
||||
|
||||
expect(manager.getCurrentBackgroundInfo()).toEqual({
|
||||
type: 'image',
|
||||
value: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureScene', () => {
|
||||
it('returns three data URLs and restores the renderer to its original state', async () => {
|
||||
const result = await manager.captureScene(400, 300)
|
||||
|
||||
expect(result.scene).toBe('data:image/png;base64,FAKE')
|
||||
expect(result.mask).toBe('data:image/png;base64,FAKE')
|
||||
expect(result.normal).toBe('data:image/png;base64,FAKE')
|
||||
// Renderer.setSize is called once with the capture size and once to restore.
|
||||
expect(renderer.setSize).toHaveBeenCalledWith(400, 300)
|
||||
expect(renderer.setSize).toHaveBeenLastCalledWith(800, 600)
|
||||
})
|
||||
|
||||
it('restores grid visibility after rendering the normal pass', async () => {
|
||||
manager.gridHelper.visible = true
|
||||
|
||||
await manager.captureScene(100, 100)
|
||||
|
||||
expect(manager.gridHelper.visible).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects when the renderer throws during capture', async () => {
|
||||
vi.mocked(renderer.render).mockImplementationOnce(() => {
|
||||
throw new Error('renderer fail')
|
||||
})
|
||||
|
||||
await expect(manager.captureScene(100, 100)).rejects.toThrow(
|
||||
'renderer fail'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose', () => {
|
||||
it('disposes the texture, color material, and mesh resources, then clears scenes', () => {
|
||||
const texture = makeImageTexture()
|
||||
const disposeTexture = vi.spyOn(texture, 'dispose')
|
||||
manager.backgroundTexture = texture
|
||||
const disposeColorMat = vi.spyOn(
|
||||
manager.backgroundColorMaterial!,
|
||||
'dispose'
|
||||
)
|
||||
const disposeGeometry = vi.spyOn(
|
||||
manager.backgroundMesh!.geometry,
|
||||
'dispose'
|
||||
)
|
||||
|
||||
manager.dispose()
|
||||
|
||||
expect(disposeTexture).toHaveBeenCalled()
|
||||
expect(disposeColorMat).toHaveBeenCalled()
|
||||
expect(disposeGeometry).toHaveBeenCalled()
|
||||
expect(manager.scene.children).toHaveLength(0)
|
||||
expect(manager.backgroundScene.children).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('clears the scene background when one was set', () => {
|
||||
manager.scene.background = makeImageTexture()
|
||||
|
||||
manager.dispose()
|
||||
|
||||
expect(manager.scene.background).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
258
src/extensions/core/load3d/ViewHelperManager.test.ts
Normal file
258
src/extensions/core/load3d/ViewHelperManager.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import * as THREE from 'three'
|
||||
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { EventManagerInterface } from './interfaces'
|
||||
import { ViewHelperManager } from './ViewHelperManager'
|
||||
|
||||
interface MockViewHelperInstance {
|
||||
camera: THREE.Camera
|
||||
domElement: HTMLElement
|
||||
animating: boolean
|
||||
visible: boolean
|
||||
center: THREE.Vector3 | null
|
||||
update: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
handleClick: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const { viewHelperInstances, mockHandleClick } = vi.hoisted(() => ({
|
||||
viewHelperInstances: [] as MockViewHelperInstance[],
|
||||
mockHandleClick: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/helpers/ViewHelper', () => {
|
||||
class ViewHelper {
|
||||
animating = false
|
||||
visible = true
|
||||
center: THREE.Vector3 | null = null
|
||||
update = vi.fn()
|
||||
dispose = vi.fn()
|
||||
handleClick = mockHandleClick
|
||||
constructor(
|
||||
public camera: THREE.Camera,
|
||||
public domElement: HTMLElement
|
||||
) {
|
||||
viewHelperInstances.push(this as unknown as MockViewHelperInstance)
|
||||
}
|
||||
}
|
||||
return { ViewHelper }
|
||||
})
|
||||
|
||||
function makeMockEventManager() {
|
||||
return {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
emitEvent: vi.fn()
|
||||
} satisfies EventManagerInterface
|
||||
}
|
||||
|
||||
function makeOrbitControls(target = new THREE.Vector3()) {
|
||||
return { target } as unknown as OrbitControls
|
||||
}
|
||||
|
||||
describe('ViewHelperManager', () => {
|
||||
let events: ReturnType<typeof makeMockEventManager>
|
||||
let camera: THREE.PerspectiveCamera
|
||||
let controls: OrbitControls
|
||||
let manager: ViewHelperManager
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
viewHelperInstances.length = 0
|
||||
events = makeMockEventManager()
|
||||
camera = new THREE.PerspectiveCamera()
|
||||
controls = makeOrbitControls()
|
||||
manager = new ViewHelperManager(
|
||||
{} as THREE.WebGLRenderer,
|
||||
() => camera,
|
||||
() => controls,
|
||||
events
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('createViewHelper', () => {
|
||||
it('appends a 128x128 absolutely-positioned container to the parent element', () => {
|
||||
const parent = document.createElement('div')
|
||||
|
||||
manager.createViewHelper(parent)
|
||||
|
||||
expect(manager.viewHelperContainer.parentNode).toBe(parent)
|
||||
expect(manager.viewHelperContainer.style.width).toBe('128px')
|
||||
expect(manager.viewHelperContainer.style.height).toBe('128px')
|
||||
expect(manager.viewHelperContainer.style.position).toBe('absolute')
|
||||
})
|
||||
|
||||
it('instantiates ViewHelper with the active camera and binds its center to the controls target', () => {
|
||||
const target = new THREE.Vector3(1, 2, 3)
|
||||
controls = makeOrbitControls(target)
|
||||
manager = new ViewHelperManager(
|
||||
{} as THREE.WebGLRenderer,
|
||||
() => camera,
|
||||
() => controls,
|
||||
events
|
||||
)
|
||||
|
||||
manager.createViewHelper(document.createElement('div'))
|
||||
|
||||
expect(viewHelperInstances).toHaveLength(1)
|
||||
expect(viewHelperInstances[0].camera).toBe(camera)
|
||||
expect(manager.viewHelper.center).toBe(target)
|
||||
})
|
||||
|
||||
it('routes pointerup events to ViewHelper.handleClick and stops propagation', () => {
|
||||
const parent = document.createElement('div')
|
||||
const propagated = vi.fn()
|
||||
parent.addEventListener('pointerup', propagated)
|
||||
manager.createViewHelper(parent)
|
||||
|
||||
const event = new PointerEvent('pointerup', { bubbles: true })
|
||||
manager.viewHelperContainer.dispatchEvent(event)
|
||||
|
||||
expect(mockHandleClick).toHaveBeenCalledWith(event)
|
||||
expect(propagated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops propagation of pointerdown events without forwarding them to ViewHelper', () => {
|
||||
const parent = document.createElement('div')
|
||||
const propagated = vi.fn()
|
||||
parent.addEventListener('pointerdown', propagated)
|
||||
manager.createViewHelper(parent)
|
||||
|
||||
manager.viewHelperContainer.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true })
|
||||
)
|
||||
|
||||
expect(propagated).not.toHaveBeenCalled()
|
||||
expect(mockHandleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('update', () => {
|
||||
it('does nothing when ViewHelper is not animating', () => {
|
||||
manager.createViewHelper(document.createElement('div'))
|
||||
manager.viewHelper.animating = false
|
||||
|
||||
manager.update(0.5)
|
||||
|
||||
expect(manager.viewHelper.update).not.toHaveBeenCalled()
|
||||
expect(events.emitEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drives the animation while it is in progress without emitting yet', () => {
|
||||
manager.createViewHelper(document.createElement('div'))
|
||||
manager.viewHelper.animating = true
|
||||
|
||||
manager.update(0.25)
|
||||
|
||||
expect(manager.viewHelper.update).toHaveBeenCalledWith(0.25)
|
||||
expect(events.emitEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits cameraChanged with a perspective state when the animation just finished', () => {
|
||||
manager.createViewHelper(document.createElement('div'))
|
||||
camera.position.set(1, 2, 3)
|
||||
camera.zoom = 1.5
|
||||
controls.target.set(4, 5, 6)
|
||||
manager.viewHelper.animating = true
|
||||
;(
|
||||
manager.viewHelper.update as unknown as {
|
||||
mockImplementation(fn: () => void): void
|
||||
}
|
||||
).mockImplementation(() => {
|
||||
manager.viewHelper.animating = false
|
||||
})
|
||||
|
||||
manager.update(0)
|
||||
|
||||
expect(events.emitEvent).toHaveBeenCalledWith('cameraChanged', {
|
||||
position: expect.objectContaining({ x: 1, y: 2, z: 3 }),
|
||||
target: expect.objectContaining({ x: 4, y: 5, z: 6 }),
|
||||
zoom: 1.5,
|
||||
cameraType: 'perspective'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports orthographic when the active camera is an OrthographicCamera', () => {
|
||||
const ortho = new THREE.OrthographicCamera()
|
||||
ortho.zoom = 0.5
|
||||
manager = new ViewHelperManager(
|
||||
{} as THREE.WebGLRenderer,
|
||||
() => ortho,
|
||||
() => controls,
|
||||
events
|
||||
)
|
||||
manager.createViewHelper(document.createElement('div'))
|
||||
manager.viewHelper.animating = true
|
||||
;(
|
||||
manager.viewHelper.update as unknown as {
|
||||
mockImplementation(fn: () => void): void
|
||||
}
|
||||
).mockImplementation(() => {
|
||||
manager.viewHelper.animating = false
|
||||
})
|
||||
|
||||
manager.update(0)
|
||||
|
||||
expect(events.emitEvent).toHaveBeenCalledWith(
|
||||
'cameraChanged',
|
||||
expect.objectContaining({ cameraType: 'orthographic', zoom: 0.5 })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('visibleViewHelper', () => {
|
||||
it('shows the helper and unhides the container when called with true', () => {
|
||||
manager.createViewHelper(document.createElement('div'))
|
||||
manager.viewHelper.visible = false
|
||||
manager.viewHelperContainer.style.display = 'none'
|
||||
|
||||
manager.visibleViewHelper(true)
|
||||
|
||||
expect(manager.viewHelper.visible).toBe(true)
|
||||
expect(manager.viewHelperContainer.style.display).toBe('block')
|
||||
})
|
||||
|
||||
it('hides the helper and the container when called with false', () => {
|
||||
manager.createViewHelper(document.createElement('div'))
|
||||
|
||||
manager.visibleViewHelper(false)
|
||||
|
||||
expect(manager.viewHelper.visible).toBe(false)
|
||||
expect(manager.viewHelperContainer.style.display).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('recreateViewHelper', () => {
|
||||
it('disposes the old helper and constructs a new one bound to the controls target', () => {
|
||||
manager.createViewHelper(document.createElement('div'))
|
||||
const oldHelper = manager.viewHelper
|
||||
const newTarget = new THREE.Vector3(9, 9, 9)
|
||||
controls.target.copy(newTarget)
|
||||
|
||||
manager.recreateViewHelper()
|
||||
|
||||
expect(oldHelper.dispose).toHaveBeenCalled()
|
||||
expect(manager.viewHelper).not.toBe(oldHelper)
|
||||
expect(viewHelperInstances).toHaveLength(2)
|
||||
expect(manager.viewHelper.center).toBe(controls.target)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose', () => {
|
||||
it('disposes the helper and removes the container from its parent', () => {
|
||||
const parent = document.createElement('div')
|
||||
manager.createViewHelper(parent)
|
||||
const helper = manager.viewHelper
|
||||
|
||||
manager.dispose()
|
||||
|
||||
expect(helper.dispose).toHaveBeenCalled()
|
||||
expect(manager.viewHelperContainer.parentNode).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
162
src/extensions/core/load3d/exportMenuHelper.test.ts
Normal file
162
src/extensions/core/load3d/exportMenuHelper.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type Load3d from './Load3d'
|
||||
import { createExportMenuItems } from './exportMenuHelper'
|
||||
|
||||
const { contextMenuMock, addToastMock, addAlertMock } = vi.hoisted(() => ({
|
||||
contextMenuMock: vi.fn(),
|
||||
addToastMock: vi.fn(),
|
||||
addAlertMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, vars?: Record<string, unknown>) =>
|
||||
vars ? `${key}:${JSON.stringify(vars)}` : key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: addToastMock, addAlert: addAlertMock })
|
||||
}))
|
||||
|
||||
vi.mock(import('@/lib/litegraph/src/litegraph'), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
class MockContextMenu {
|
||||
constructor(...args: unknown[]) {
|
||||
contextMenuMock(...args)
|
||||
}
|
||||
}
|
||||
// Replace ContextMenu in-place on the real LiteGraph singleton so consumers
|
||||
// that import other members keep getting the real implementations.
|
||||
;(actual.LiteGraph as unknown as { ContextMenu: unknown }).ContextMenu =
|
||||
MockContextMenu
|
||||
return actual
|
||||
})
|
||||
|
||||
function makeLoad3d(
|
||||
exportImpl: (format: string) => Promise<void> = vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined)
|
||||
): Load3d {
|
||||
return { exportModel: exportImpl } as unknown as Load3d
|
||||
}
|
||||
|
||||
describe('createExportMenuItems', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns a separator followed by a Save submenu', () => {
|
||||
const items = createExportMenuItems(makeLoad3d())
|
||||
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items[0]).toBeNull()
|
||||
expect(items[1]).toMatchObject({
|
||||
content: 'Save',
|
||||
has_submenu: true
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a submenu with GLB, OBJ, STL when the Save item is invoked', () => {
|
||||
const items = createExportMenuItems(makeLoad3d())
|
||||
const saveItem = items[1]!
|
||||
|
||||
;(saveItem.callback as (...args: unknown[]) => void)(
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(contextMenuMock).toHaveBeenCalledOnce()
|
||||
const submenuOptions = contextMenuMock.mock.calls[0][0]
|
||||
expect(submenuOptions.map((o: { content: string }) => o.content)).toEqual([
|
||||
'GLB',
|
||||
'OBJ',
|
||||
'STL'
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards the parent menu and event when opening the submenu', () => {
|
||||
const items = createExportMenuItems(makeLoad3d())
|
||||
const event = { x: 100 } as unknown as MouseEvent
|
||||
const parentMenu = { id: 'prev' }
|
||||
|
||||
;(items[1]!.callback as (...args: unknown[]) => void)(
|
||||
undefined,
|
||||
{},
|
||||
event,
|
||||
parentMenu
|
||||
)
|
||||
|
||||
expect(contextMenuMock).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ event, parentMenu })
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['GLB', 'glb'],
|
||||
['OBJ', 'obj'],
|
||||
['STL', 'stl']
|
||||
])(
|
||||
'invokes load3d.exportModel(%s) and shows a success toast when the %s submenu item is clicked',
|
||||
async (label, value) => {
|
||||
const exportModel = vi.fn().mockResolvedValue(undefined)
|
||||
const items = createExportMenuItems(makeLoad3d(exportModel))
|
||||
;(items[1]!.callback as (...args: unknown[]) => void)(
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
const submenuOptions = contextMenuMock.mock.calls[0][0]
|
||||
const item = submenuOptions.find(
|
||||
(o: { content: string }) => o.content === label
|
||||
)
|
||||
|
||||
item.callback()
|
||||
await vi.waitFor(() => expect(exportModel).toHaveBeenCalledWith(value))
|
||||
await vi.waitFor(() =>
|
||||
expect(addToastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'success',
|
||||
summary: `toastMessages.exportSuccess:${JSON.stringify({ format: label })}`
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(addAlertMock).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('shows an alert toast and logs when exportModel rejects', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const exportModel = vi.fn().mockRejectedValue(new Error('boom'))
|
||||
const items = createExportMenuItems(makeLoad3d(exportModel))
|
||||
;(items[1]!.callback as (...args: unknown[]) => void)(
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
const glb = contextMenuMock.mock.calls[0][0].find(
|
||||
(o: { content: string }) => o.content === 'GLB'
|
||||
)
|
||||
|
||||
glb.callback()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(addAlertMock).toHaveBeenCalledWith(
|
||||
`toastMessages.failedToExportModel:${JSON.stringify({ format: 'GLB' })}`
|
||||
)
|
||||
)
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Export failed:',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(addToastMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
180
src/extensions/core/load3dLazy.test.ts
Normal file
180
src/extensions/core/load3dLazy.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const { registerExtensionMock, enabledExtensionsGetter } = vi.hoisted(() => ({
|
||||
registerExtensionMock: vi.fn(),
|
||||
enabledExtensionsGetter: vi.fn(() => [] as ComfyExtension[])
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({ registerExtension: registerExtensionMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/extensionStore', () => ({
|
||||
useExtensionStore: () => ({
|
||||
get enabledExtensions() {
|
||||
return enabledExtensionsGetter()
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { __mockApp: true }
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d', () => ({}))
|
||||
vi.mock('@/extensions/core/saveMesh', () => ({}))
|
||||
|
||||
type Hook = (
|
||||
nodeType: typeof LGraphNode,
|
||||
nodeData: ComfyNodeDef,
|
||||
app?: unknown
|
||||
) => Promise<void> | void
|
||||
|
||||
async function loadLazyExtensionFresh(): Promise<{ hook: Hook }> {
|
||||
vi.resetModules()
|
||||
registerExtensionMock.mockClear()
|
||||
enabledExtensionsGetter.mockReset().mockReturnValue([])
|
||||
await import('@/extensions/core/load3dLazy')
|
||||
const ext = registerExtensionMock.mock.calls[0][0] as ComfyExtension
|
||||
return { hook: ext.beforeRegisterNodeDef as Hook }
|
||||
}
|
||||
|
||||
function makeNodeDef(
|
||||
name: string,
|
||||
extra: Partial<ComfyNodeDef> = {}
|
||||
): ComfyNodeDef {
|
||||
return {
|
||||
name,
|
||||
display_name: name,
|
||||
category: '',
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
python_module: '',
|
||||
description: '',
|
||||
...extra
|
||||
} as ComfyNodeDef
|
||||
}
|
||||
|
||||
describe('load3dLazy', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('registers a single Comfy.Load3DLazy extension on import', async () => {
|
||||
await loadLazyExtensionFresh()
|
||||
|
||||
expect(registerExtensionMock).toHaveBeenCalledOnce()
|
||||
const ext = registerExtensionMock.mock.calls[0][0] as ComfyExtension
|
||||
expect(ext.name).toBe('Comfy.Load3DLazy')
|
||||
expect(typeof ext.beforeRegisterNodeDef).toBe('function')
|
||||
})
|
||||
|
||||
it('skips loading and mutation for non-3D node defs', async () => {
|
||||
const { hook } = await loadLazyExtensionFresh()
|
||||
|
||||
await hook({} as typeof LGraphNode, makeNodeDef('PlainNode'))
|
||||
|
||||
// No diff was ever computed because the early-return branch was taken.
|
||||
expect(enabledExtensionsGetter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['Load3D', 'Preview3D', 'SaveGLB'])(
|
||||
'recognizes %s as a 3D node type and triggers the lazy-load path',
|
||||
async (nodeType) => {
|
||||
const { hook } = await loadLazyExtensionFresh()
|
||||
|
||||
await hook({} as typeof LGraphNode, makeNodeDef(nodeType))
|
||||
|
||||
// The lazy-load path always reads enabledExtensions once for the diff.
|
||||
expect(enabledExtensionsGetter).toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('injects mesh_upload spec flags into the model_file widget for Load3D nodes', async () => {
|
||||
const { hook } = await loadLazyExtensionFresh()
|
||||
const nodeData = makeNodeDef('Load3D', {
|
||||
input: {
|
||||
required: { model_file: ['STRING', {}] }
|
||||
}
|
||||
} as Partial<ComfyNodeDef>)
|
||||
|
||||
await hook({} as typeof LGraphNode, nodeData)
|
||||
|
||||
const spec = (
|
||||
nodeData.input!.required!.model_file as [string, Record<string, unknown>]
|
||||
)[1]
|
||||
expect(spec.mesh_upload).toBe(true)
|
||||
expect(spec.upload_subfolder).toBe('3d')
|
||||
})
|
||||
|
||||
it('does not throw when a Load3D node has no model_file widget spec', async () => {
|
||||
const { hook } = await loadLazyExtensionFresh()
|
||||
const nodeData = makeNodeDef('Load3D', {
|
||||
input: { required: {} }
|
||||
} as Partial<ComfyNodeDef>)
|
||||
|
||||
await expect(
|
||||
hook({} as typeof LGraphNode, nodeData)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not mutate model_file for non-Load3D 3D node types', async () => {
|
||||
const { hook } = await loadLazyExtensionFresh()
|
||||
const nodeData = makeNodeDef('Preview3D', {
|
||||
input: {
|
||||
required: { model_file: ['STRING', { existing: true }] }
|
||||
}
|
||||
} as Partial<ComfyNodeDef>)
|
||||
|
||||
await hook({} as typeof LGraphNode, nodeData)
|
||||
|
||||
const spec = (
|
||||
nodeData.input!.required!.model_file as [string, Record<string, unknown>]
|
||||
)[1]
|
||||
expect(spec.mesh_upload).toBeUndefined()
|
||||
})
|
||||
|
||||
it('replays beforeRegisterNodeDef of newly registered extensions, passing the app reference', async () => {
|
||||
const newExtension: ComfyExtension = {
|
||||
name: 'Inner',
|
||||
beforeRegisterNodeDef: vi.fn()
|
||||
}
|
||||
// First call (snapshotting `before`) sees an empty list; second call
|
||||
// (computing the diff after dynamic imports) sees the new extension.
|
||||
enabledExtensionsGetter
|
||||
.mockReturnValueOnce([])
|
||||
.mockReturnValueOnce([newExtension])
|
||||
const { hook } = await loadLazyExtensionFresh()
|
||||
enabledExtensionsGetter
|
||||
.mockReturnValueOnce([])
|
||||
.mockReturnValueOnce([newExtension])
|
||||
|
||||
const nodeData = makeNodeDef('Preview3D')
|
||||
await hook({ id: 1 } as unknown as typeof LGraphNode, nodeData)
|
||||
|
||||
expect(newExtension.beforeRegisterNodeDef).toHaveBeenCalledWith(
|
||||
{ id: 1 },
|
||||
nodeData,
|
||||
expect.objectContaining({ __mockApp: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not replay extensions that were already registered before lazy loading', async () => {
|
||||
const preexisting: ComfyExtension = {
|
||||
name: 'PreExisting',
|
||||
beforeRegisterNodeDef: vi.fn()
|
||||
}
|
||||
enabledExtensionsGetter.mockReturnValue([preexisting])
|
||||
const { hook } = await loadLazyExtensionFresh()
|
||||
enabledExtensionsGetter.mockReturnValue([preexisting])
|
||||
|
||||
await hook({} as typeof LGraphNode, makeNodeDef('Load3D'))
|
||||
|
||||
expect(preexisting.beforeRegisterNodeDef).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -230,6 +230,7 @@
|
||||
"warning": "Warning",
|
||||
"name": "Name",
|
||||
"category": "Category",
|
||||
"categories": "Categories",
|
||||
"sort": "Sort",
|
||||
"source": "Source",
|
||||
"filter": "Filter",
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
:context="{ type: assetType }"
|
||||
class="absolute inset-0"
|
||||
@view="handleZoomClick"
|
||||
@download="actions.downloadAsset()"
|
||||
@download="asset && actions.downloadAssets([asset])"
|
||||
@video-playing-state-changed="isVideoPlaying = $event"
|
||||
@video-controls-changed="showVideoControls = $event"
|
||||
@image-loaded="handleImageLoaded"
|
||||
|
||||
@@ -31,8 +31,7 @@ vi.mock('@/utils/loaderNodeUtil', () => ({
|
||||
|
||||
const mediaAssetActions = {
|
||||
addWorkflow: vi.fn(),
|
||||
downloadAsset: vi.fn(),
|
||||
downloadMultipleAssets: vi.fn(),
|
||||
downloadAssets: vi.fn(),
|
||||
openWorkflow: vi.fn(),
|
||||
exportWorkflow: vi.fn(),
|
||||
copyJobId: vi.fn(),
|
||||
@@ -185,7 +184,7 @@ describe('MediaAssetContextMenu', () => {
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('routes Download through downloadMultipleAssets so multi-output jobs zip', async () => {
|
||||
it('routes Download through downloadAssets so multi-output jobs zip', async () => {
|
||||
const { container, unmount } = mountComponent()
|
||||
await showMenu(container)
|
||||
|
||||
@@ -195,10 +194,7 @@ describe('MediaAssetContextMenu', () => {
|
||||
item: downloadItem
|
||||
})
|
||||
|
||||
expect(mediaAssetActions.downloadMultipleAssets).toHaveBeenCalledWith([
|
||||
asset
|
||||
])
|
||||
expect(mediaAssetActions.downloadAsset).not.toHaveBeenCalled()
|
||||
expect(mediaAssetActions.downloadAssets).toHaveBeenCalledWith([asset])
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
@@ -217,7 +217,7 @@ const contextMenuItems = computed<MenuItem[]>(() => {
|
||||
items.push({
|
||||
label: t('mediaAsset.actions.download'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
command: () => actions.downloadMultipleAssets([asset])
|
||||
command: () => actions.downloadAssets([asset])
|
||||
})
|
||||
|
||||
// Separator before workflow actions (only if there are workflow actions)
|
||||
|
||||
@@ -2,9 +2,12 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, provide, ref } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { MediaAssetKey } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { AssetMeta } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { useMediaAssetActions } from './useMediaAssetActions'
|
||||
|
||||
// Use vi.hoisted to create a mutable reference for isCloud
|
||||
@@ -13,6 +16,11 @@ const mockIsCloud = vi.hoisted(() => ({ value: false }))
|
||||
// Track the filename passed to createAnnotatedPath
|
||||
const capturedFilenames = vi.hoisted(() => ({ values: [] as string[] }))
|
||||
|
||||
const mockDownloadFile = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadFile: mockDownloadFile
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
@@ -168,13 +176,58 @@ function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
}
|
||||
}
|
||||
|
||||
function createMockMediaAsset(overrides: Partial<AssetMeta> = {}): AssetMeta {
|
||||
return {
|
||||
...createMockAsset(),
|
||||
kind: 'image',
|
||||
src: 'https://example.com/default-preview.png',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function mountMediaActions(asset?: AssetMeta) {
|
||||
let actions: ReturnType<typeof useMediaAssetActions> | undefined
|
||||
|
||||
const ChildComponent = defineComponent({
|
||||
setup() {
|
||||
actions = useMediaAssetActions()
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
|
||||
const HostComponent = defineComponent({
|
||||
setup() {
|
||||
provide(MediaAssetKey, {
|
||||
asset: ref(asset),
|
||||
context: ref({ type: 'input' as const }),
|
||||
isVideoPlaying: ref(false),
|
||||
showVideoControls: ref(false)
|
||||
})
|
||||
return () => h(ChildComponent)
|
||||
}
|
||||
})
|
||||
|
||||
const host = document.createElement('div')
|
||||
const app = createApp(HostComponent)
|
||||
app.mount(host)
|
||||
|
||||
if (!actions) throw new Error('media asset actions not initialized')
|
||||
|
||||
return {
|
||||
actions,
|
||||
unmount: () => app.unmount()
|
||||
}
|
||||
}
|
||||
|
||||
describe('useMediaAssetActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
capturedFilenames.values = []
|
||||
mockIsCloud.value = false
|
||||
mockGetOutputAssetMetadata.mockReset()
|
||||
mockGetOutputAssetMetadata.mockReturnValue(null)
|
||||
mockGetAssetType.mockReset()
|
||||
})
|
||||
|
||||
describe('addWorkflow', () => {
|
||||
@@ -275,7 +328,102 @@ describe('useMediaAssetActions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadMultipleAssets - job_asset_name_filters', () => {
|
||||
describe('downloadAssets', () => {
|
||||
it('downloads the injected media asset when called without explicit assets', () => {
|
||||
const mediaAsset = createMockMediaAsset({
|
||||
id: 'context-asset',
|
||||
name: 'context-name.png',
|
||||
display_name: 'Context image.png',
|
||||
preview_url: 'https://example.com/context-preview.png'
|
||||
})
|
||||
|
||||
const { actions, unmount } = mountMediaActions(mediaAsset)
|
||||
actions.downloadAssets()
|
||||
|
||||
expect(mockDownloadFile).toHaveBeenCalledOnce()
|
||||
expect(mockDownloadFile).toHaveBeenCalledWith(
|
||||
'https://example.com/context-preview.png',
|
||||
'Context image.png'
|
||||
)
|
||||
expect(mockCreateAssetExport).not.toHaveBeenCalled()
|
||||
expect(mockTrackExport).not.toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does nothing when called without explicit assets and no media context asset', () => {
|
||||
const { actions, unmount } = mountMediaActions()
|
||||
actions.downloadAssets()
|
||||
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled()
|
||||
expect(mockCreateAssetExport).not.toHaveBeenCalled()
|
||||
expect(mockTrackExport).not.toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('keeps single explicit assets on the direct download path in cloud', () => {
|
||||
mockIsCloud.value = true
|
||||
mockGetOutputAssetMetadata.mockReturnValue({
|
||||
jobId: 'job1',
|
||||
outputCount: 1
|
||||
})
|
||||
|
||||
const asset = createMockAsset({
|
||||
id: 'single-output',
|
||||
name: 'single-output.png',
|
||||
preview_url: 'https://example.com/single-output.png',
|
||||
tags: ['output'],
|
||||
user_metadata: { jobId: 'job1', outputCount: 1 }
|
||||
})
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadAssets([asset])
|
||||
|
||||
expect(mockDownloadFile).toHaveBeenCalledOnce()
|
||||
expect(mockDownloadFile).toHaveBeenCalledWith(
|
||||
'https://example.com/single-output.png',
|
||||
'single-output.png'
|
||||
)
|
||||
expect(mockCreateAssetExport).not.toHaveBeenCalled()
|
||||
expect(mockTrackExport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses ZIP export for an injected single multi-output asset in cloud', async () => {
|
||||
mockIsCloud.value = true
|
||||
mockGetAssetType.mockReturnValue('output')
|
||||
mockGetOutputAssetMetadata.mockReturnValue({
|
||||
jobId: 'job1',
|
||||
outputCount: 3
|
||||
})
|
||||
|
||||
const mediaAsset = createMockMediaAsset({
|
||||
id: 'multi-output',
|
||||
name: 'multi-output.png',
|
||||
preview_url: 'https://example.com/multi-output.png',
|
||||
tags: ['output'],
|
||||
user_metadata: { jobId: 'job1', outputCount: 3 }
|
||||
})
|
||||
|
||||
const { actions, unmount } = mountMediaActions(mediaAsset)
|
||||
actions.downloadAssets()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled()
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledWith({
|
||||
job_ids: ['job1'],
|
||||
naming_strategy: 'preserve'
|
||||
})
|
||||
expect(mockTrackExport).toHaveBeenCalledWith('test-task-id')
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadAssets - cloud zip filters', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = true
|
||||
mockCreateAssetExport.mockClear()
|
||||
@@ -305,7 +453,7 @@ describe('useMediaAssetActions', () => {
|
||||
const assets = [createOutputAsset('a1', 'img1.png', 'job1', 3)]
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadMultipleAssets(assets)
|
||||
actions.downloadAssets(assets)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
||||
@@ -323,7 +471,7 @@ describe('useMediaAssetActions', () => {
|
||||
const j2 = createOutputAsset('a3', 'out2.png', 'job2', 1)
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadMultipleAssets([j1a, j1b, j2])
|
||||
actions.downloadAssets([j1a, j1b, j2])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
||||
@@ -340,7 +488,7 @@ describe('useMediaAssetActions', () => {
|
||||
const asset2 = createOutputAsset('a2', 'img2.png', 'job2')
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadMultipleAssets([asset1, asset2])
|
||||
actions.downloadAssets([asset1, asset2])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
||||
@@ -360,7 +508,7 @@ describe('useMediaAssetActions', () => {
|
||||
const j2 = createOutputAsset('a3', 'img2.png', 'job2')
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadMultipleAssets([j1a, j1b, j2])
|
||||
actions.downloadAssets([j1a, j1b, j2])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
||||
@@ -379,7 +527,7 @@ describe('useMediaAssetActions', () => {
|
||||
const asset2 = createOutputAsset('a2', 'img2.png', 'job1')
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
actions.downloadMultipleAssets([asset1, asset2])
|
||||
actions.downloadAssets([asset1, asset2])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -64,52 +64,30 @@ export function useMediaAssetActions() {
|
||||
}
|
||||
}
|
||||
|
||||
const downloadAsset = (asset?: AssetItem) => {
|
||||
const targetAsset = asset ?? mediaContext?.asset.value
|
||||
if (!targetAsset) return
|
||||
|
||||
try {
|
||||
const filename = getAssetDisplayName(targetAsset)
|
||||
// Prefer preview_url (already includes subfolder) with getAssetUrl as fallback
|
||||
const downloadUrl = targetAsset.preview_url || getAssetUrl(targetAsset)
|
||||
|
||||
downloadFile(downloadUrl, filename)
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.selection.downloadsStarted', 1),
|
||||
life: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download multiple assets at once.
|
||||
* In cloud mode with 2+ assets, creates a ZIP export via the backend.
|
||||
* Falls back to individual downloads in OSS mode or for single assets.
|
||||
* Download one or more assets.
|
||||
* In cloud mode, creates a ZIP export via the backend when called with
|
||||
* 2+ assets or with any asset whose job has `outputCount > 1`.
|
||||
* Falls back to direct downloads in OSS mode and for single single-output
|
||||
* assets. With no argument, uses the asset from `MediaAssetKey` context.
|
||||
*/
|
||||
const downloadMultipleAssets = (assets: AssetItem[]) => {
|
||||
if (!assets || assets.length === 0) return
|
||||
const downloadAssets = (assets?: AssetItem[]) => {
|
||||
const targetAssets =
|
||||
assets ?? (mediaContext?.asset.value ? [mediaContext.asset.value] : [])
|
||||
if (targetAssets.length === 0) return
|
||||
|
||||
const hasMultiOutputJobs = assets.some((a) => {
|
||||
const hasMultiOutputJobs = targetAssets.some((a) => {
|
||||
const count = getOutputAssetMetadata(a.user_metadata)?.outputCount
|
||||
return typeof count === 'number' && count > 1
|
||||
})
|
||||
|
||||
if (isCloud && (assets.length > 1 || hasMultiOutputJobs)) {
|
||||
void downloadMultipleAssetsAsZip(assets)
|
||||
if (isCloud && (targetAssets.length > 1 || hasMultiOutputJobs)) {
|
||||
void downloadAssetsAsZip(targetAssets)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
assets.forEach((asset) => {
|
||||
targetAssets.forEach((asset) => {
|
||||
const filename = getAssetDisplayName(asset)
|
||||
const downloadUrl = asset.preview_url || getAssetUrl(asset)
|
||||
downloadFile(downloadUrl, filename)
|
||||
@@ -118,7 +96,7 @@ export function useMediaAssetActions() {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.selection.downloadsStarted', assets.length),
|
||||
detail: t('mediaAsset.selection.downloadsStarted', targetAssets.length),
|
||||
life: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -131,7 +109,7 @@ export function useMediaAssetActions() {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadMultipleAssetsAsZip(assets: AssetItem[]) {
|
||||
async function downloadAssetsAsZip(assets: AssetItem[]) {
|
||||
const assetExportStore = useAssetExportStore()
|
||||
|
||||
try {
|
||||
@@ -720,8 +698,7 @@ export function useMediaAssetActions() {
|
||||
}
|
||||
|
||||
return {
|
||||
downloadAsset,
|
||||
downloadMultipleAssets,
|
||||
downloadAssets,
|
||||
deleteAssets,
|
||||
copyJobId,
|
||||
addWorkflow,
|
||||
|
||||
@@ -92,6 +92,7 @@ function draw() {
|
||||
// @ts-expect-error canvasHeight is a custom property used by some extensions
|
||||
node.canvasHeight = height
|
||||
widgetInstance.y = 0
|
||||
widgetInstance.width = width
|
||||
canvasEl.value.height = (height + 2) * scaleFactor
|
||||
canvasEl.value.width = width * scaleFactor
|
||||
const ctx = canvasEl.value?.getContext('2d')
|
||||
|
||||
331
src/scripts/metadata/avif.test.ts
Normal file
331
src/scripts/metadata/avif.test.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getFromAvifFile } from './avif'
|
||||
|
||||
const setU32BE = (dv: DataView, off: number, val: number) =>
|
||||
dv.setUint32(off, val, false)
|
||||
const setU16BE = (dv: DataView, off: number, val: number) =>
|
||||
dv.setUint16(off, val, false)
|
||||
|
||||
const buildExifBlob = (
|
||||
asciiEntries: string[],
|
||||
endian: 'II' | 'MM' = 'II'
|
||||
): Uint8Array => {
|
||||
const isLE = endian === 'II'
|
||||
const headerSize = 8
|
||||
const ifdSize = 2 + asciiEntries.length * 12 + 4
|
||||
const entryDataSizes = asciiEntries.map((s) => s.length + 1)
|
||||
const entryDataTotal = entryDataSizes.reduce((a, b) => a + b, 0)
|
||||
|
||||
const buf = new Uint8Array(headerSize + ifdSize + entryDataTotal)
|
||||
const dv = new DataView(buf.buffer)
|
||||
|
||||
buf[0] = endian === 'II' ? 0x49 : 0x4d
|
||||
buf[1] = buf[0]
|
||||
dv.setUint16(2, 0x002a, isLE)
|
||||
dv.setUint32(4, 8, isLE)
|
||||
|
||||
let p = 8
|
||||
dv.setUint16(p, asciiEntries.length, isLE)
|
||||
p += 2
|
||||
|
||||
let dataOffset = headerSize + ifdSize
|
||||
for (let i = 0; i < asciiEntries.length; i++) {
|
||||
const dataLen = entryDataSizes[i]
|
||||
const tag = 0x9286 + i
|
||||
dv.setUint16(p, tag, isLE)
|
||||
p += 2
|
||||
dv.setUint16(p, 2, isLE)
|
||||
p += 2
|
||||
dv.setUint32(p, dataLen, isLE)
|
||||
p += 4
|
||||
dv.setUint32(p, dataOffset, isLE)
|
||||
p += 4
|
||||
const enc = new TextEncoder().encode(asciiEntries[i])
|
||||
buf.set(enc, dataOffset)
|
||||
buf[dataOffset + enc.length] = 0
|
||||
dataOffset += dataLen
|
||||
}
|
||||
dv.setUint32(p, 0, isLE)
|
||||
return buf
|
||||
}
|
||||
|
||||
const buildInfeBox = (
|
||||
itemId: number,
|
||||
itemType: string,
|
||||
version = 2
|
||||
): Uint8Array => {
|
||||
const bodySize = 4 + 2 + 2 + 4 + 1 + 1
|
||||
const totalSize = 8 + bodySize
|
||||
const buf = new Uint8Array(totalSize)
|
||||
const dv = new DataView(buf.buffer)
|
||||
setU32BE(dv, 0, totalSize)
|
||||
buf.set(new TextEncoder().encode('infe'), 4)
|
||||
buf[8] = version
|
||||
if (version >= 2) {
|
||||
setU16BE(dv, 12, itemId)
|
||||
setU16BE(dv, 14, 0)
|
||||
buf.set(new TextEncoder().encode(itemType.padEnd(4).slice(0, 4)), 16)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
const buildIinfBox = (infeBoxes: Uint8Array[]): Uint8Array => {
|
||||
const bodySize = 4 + 2 + infeBoxes.reduce((s, b) => s + b.length, 0)
|
||||
const totalSize = 8 + bodySize
|
||||
const buf = new Uint8Array(totalSize)
|
||||
const dv = new DataView(buf.buffer)
|
||||
setU32BE(dv, 0, totalSize)
|
||||
buf.set(new TextEncoder().encode('iinf'), 4)
|
||||
setU16BE(dv, 12, infeBoxes.length)
|
||||
let off = 14
|
||||
for (const ib of infeBoxes) {
|
||||
buf.set(ib, off)
|
||||
off += ib.length
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
const buildIlocBox = (
|
||||
items: { itemId: number; extentOffset: number; extentLength: number }[]
|
||||
): Uint8Array => {
|
||||
const perItemSize = 2 + 2 + 0 + 2 + (4 + 4)
|
||||
const bodySize = 4 + 1 + 1 + 2 + items.length * perItemSize
|
||||
const totalSize = 8 + bodySize
|
||||
const buf = new Uint8Array(totalSize)
|
||||
const dv = new DataView(buf.buffer)
|
||||
setU32BE(dv, 0, totalSize)
|
||||
buf.set(new TextEncoder().encode('iloc'), 4)
|
||||
buf[12] = 0x44
|
||||
buf[13] = 0x00
|
||||
setU16BE(dv, 14, items.length)
|
||||
let p = 16
|
||||
for (const it of items) {
|
||||
setU16BE(dv, p, it.itemId)
|
||||
p += 2
|
||||
setU16BE(dv, p, 0)
|
||||
p += 2
|
||||
setU16BE(dv, p, 1)
|
||||
p += 2
|
||||
setU32BE(dv, p, it.extentOffset)
|
||||
p += 4
|
||||
setU32BE(dv, p, it.extentLength)
|
||||
p += 4
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
const buildMetaBox = (boxes: Uint8Array[]): Uint8Array => {
|
||||
const bodySize = 4 + boxes.reduce((s, b) => s + b.length, 0)
|
||||
const totalSize = 8 + bodySize
|
||||
const buf = new Uint8Array(totalSize)
|
||||
const dv = new DataView(buf.buffer)
|
||||
setU32BE(dv, 0, totalSize)
|
||||
buf.set(new TextEncoder().encode('meta'), 4)
|
||||
let p = 12
|
||||
for (const b of boxes) {
|
||||
buf.set(b, p)
|
||||
p += b.length
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
const buildFtypBox = (majorBrand = 'avif'): Uint8Array => {
|
||||
const buf = new Uint8Array(16)
|
||||
const dv = new DataView(buf.buffer)
|
||||
setU32BE(dv, 0, 16)
|
||||
buf.set(new TextEncoder().encode('ftyp'), 4)
|
||||
buf.set(new TextEncoder().encode(majorBrand.padEnd(4).slice(0, 4)), 8)
|
||||
setU32BE(dv, 12, 0)
|
||||
return buf
|
||||
}
|
||||
|
||||
interface BuildAvifOpts {
|
||||
exifEntries?: string[]
|
||||
endian?: 'II' | 'MM'
|
||||
itemType?: string
|
||||
ftypBrand?: string
|
||||
omitMeta?: boolean
|
||||
omitIloc?: boolean
|
||||
infeVersion?: number
|
||||
}
|
||||
|
||||
const buildAvifFile = (opts: BuildAvifOpts = {}): ArrayBuffer => {
|
||||
const {
|
||||
exifEntries = [],
|
||||
endian = 'II',
|
||||
itemType = 'Exif',
|
||||
ftypBrand = 'avif',
|
||||
omitMeta = false,
|
||||
omitIloc = false,
|
||||
infeVersion = 2
|
||||
} = opts
|
||||
|
||||
const ftyp = buildFtypBox(ftypBrand)
|
||||
if (omitMeta) {
|
||||
return ftyp.slice().buffer as ArrayBuffer
|
||||
}
|
||||
|
||||
const exifData = buildExifBlob(exifEntries, endian)
|
||||
const infe = buildInfeBox(1, itemType, infeVersion)
|
||||
const iinf = buildIinfBox([infe])
|
||||
|
||||
const realIloc = buildIlocBox([
|
||||
{ itemId: 1, extentOffset: 0, extentLength: exifData.length }
|
||||
])
|
||||
const metaSize = 8 + 4 + iinf.length + (omitIloc ? 0 : realIloc.length)
|
||||
const exifOffset = ftyp.length + metaSize
|
||||
|
||||
const finalIloc = buildIlocBox([
|
||||
{ itemId: 1, extentOffset: exifOffset, extentLength: exifData.length }
|
||||
])
|
||||
const finalInner = omitIloc ? [iinf] : [iinf, finalIloc]
|
||||
const meta = buildMetaBox(finalInner)
|
||||
|
||||
const total = ftyp.length + meta.length + exifData.length
|
||||
const buf = new Uint8Array(total)
|
||||
let p = 0
|
||||
buf.set(ftyp, p)
|
||||
p += ftyp.length
|
||||
buf.set(meta, p)
|
||||
p += meta.length
|
||||
buf.set(exifData, p)
|
||||
return buf.slice().buffer as ArrayBuffer
|
||||
}
|
||||
|
||||
const fileFromBuffer = (buffer: ArrayBuffer, name = 'test.avif'): File =>
|
||||
new File([buffer], name, { type: 'image/avif' })
|
||||
|
||||
describe('getFromAvifFile', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
it('extracts workflow JSON from EXIF when AVIF has an Exif item', async () => {
|
||||
const workflow = '{"nodes":[],"version":1}'
|
||||
const file = fileFromBuffer(
|
||||
buildAvifFile({ exifEntries: [`workflow:${workflow}`] })
|
||||
)
|
||||
|
||||
const result = await getFromAvifFile(file)
|
||||
|
||||
expect(result.workflow).toBe(JSON.stringify(JSON.parse(workflow)))
|
||||
})
|
||||
|
||||
it('extracts prompt JSON from EXIF', async () => {
|
||||
const prompt = '{"1":{"class_type":"KSampler"}}'
|
||||
const file = fileFromBuffer(
|
||||
buildAvifFile({ exifEntries: [`prompt:${prompt}`] })
|
||||
)
|
||||
|
||||
const result = await getFromAvifFile(file)
|
||||
|
||||
expect(result.prompt).toBe(JSON.stringify(JSON.parse(prompt)))
|
||||
})
|
||||
|
||||
it('parses big-endian (MM) EXIF data', async () => {
|
||||
const workflow = '{"endian":"big"}'
|
||||
const file = fileFromBuffer(
|
||||
buildAvifFile({ exifEntries: [`workflow:${workflow}`], endian: 'MM' })
|
||||
)
|
||||
|
||||
const result = await getFromAvifFile(file)
|
||||
|
||||
expect(result.workflow).toBe(JSON.stringify(JSON.parse(workflow)))
|
||||
})
|
||||
|
||||
it('returns {} when AVIF major brand is not "avif"', async () => {
|
||||
const file = fileFromBuffer(
|
||||
buildAvifFile({ exifEntries: ['workflow:{}'], ftypBrand: 'heic' })
|
||||
)
|
||||
|
||||
const result = await getFromAvifFile(file)
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('returns {} when meta box is missing', async () => {
|
||||
const file = fileFromBuffer(buildAvifFile({ omitMeta: true }))
|
||||
|
||||
const result = await getFromAvifFile(file)
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('returns {} when iinf has no Exif item', async () => {
|
||||
const file = fileFromBuffer(
|
||||
buildAvifFile({
|
||||
exifEntries: ['workflow:{}'],
|
||||
itemType: 'mime'
|
||||
})
|
||||
)
|
||||
|
||||
const result = await getFromAvifFile(file)
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('returns {} when EXIF entry uses an unrecognized key', async () => {
|
||||
const file = fileFromBuffer(
|
||||
buildAvifFile({ exifEntries: ['random:thing'] })
|
||||
)
|
||||
|
||||
const result = await getFromAvifFile(file)
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('returns {} when EXIF entry has malformed JSON', async () => {
|
||||
const file = fileFromBuffer(
|
||||
buildAvifFile({ exifEntries: ['workflow:{notjson'] })
|
||||
)
|
||||
|
||||
const result = await getFromAvifFile(file)
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('returns {} (and does not throw) when infe version is unsupported', async () => {
|
||||
const file = fileFromBuffer(
|
||||
buildAvifFile({ exifEntries: ['workflow:{}'], infeVersion: 1 })
|
||||
)
|
||||
|
||||
const result = await getFromAvifFile(file)
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('returns {} when iloc box is missing while iinf has an Exif item', async () => {
|
||||
const file = fileFromBuffer(
|
||||
buildAvifFile({ exifEntries: ['workflow:{}'], omitIloc: true })
|
||||
)
|
||||
|
||||
const result = await getFromAvifFile(file)
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('returns {} when buffer is too short to contain a valid header', async () => {
|
||||
const file = fileFromBuffer(new Uint8Array(4).buffer)
|
||||
|
||||
const result = await getFromAvifFile(file)
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('extracts both prompt and workflow when present in separate EXIF entries', async () => {
|
||||
const prompt = '{"node":1}'
|
||||
const workflow = '{"nodes":[1]}'
|
||||
const file = fileFromBuffer(
|
||||
buildAvifFile({
|
||||
exifEntries: [`prompt:${prompt}`, `workflow:${workflow}`]
|
||||
})
|
||||
)
|
||||
|
||||
const result = await getFromAvifFile(file)
|
||||
|
||||
expect(result.prompt).toBe(JSON.stringify(JSON.parse(prompt)))
|
||||
expect(result.workflow).toBe(JSON.stringify(JSON.parse(workflow)))
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getWebpMetadata } from './pnginfo'
|
||||
import { getFromAvifFile } from './metadata/avif'
|
||||
import { getFromFlacFile } from './metadata/flac'
|
||||
import { getFromPngFile } from './metadata/png'
|
||||
import {
|
||||
getAvifMetadata,
|
||||
getFlacMetadata,
|
||||
getLatentMetadata,
|
||||
getPngMetadata,
|
||||
getWebpMetadata
|
||||
} from './pnginfo'
|
||||
|
||||
vi.mock('./metadata/png', () => ({
|
||||
getFromPngFile: vi.fn()
|
||||
}))
|
||||
vi.mock('./metadata/flac', () => ({
|
||||
getFromFlacFile: vi.fn()
|
||||
}))
|
||||
vi.mock('./metadata/avif', () => ({
|
||||
getFromAvifFile: vi.fn()
|
||||
}))
|
||||
|
||||
function buildExifPayload(workflowJson: string): Uint8Array {
|
||||
const fullStr = `workflow:${workflowJson}\0`
|
||||
@@ -65,3 +84,69 @@ describe('getWebpMetadata', () => {
|
||||
expect(metadata.workflow).toBe(workflow)
|
||||
})
|
||||
})
|
||||
|
||||
describe('format-specific metadata wrappers', () => {
|
||||
it('getPngMetadata delegates to getFromPngFile', async () => {
|
||||
const file = new File([], 'a.png', { type: 'image/png' })
|
||||
vi.mocked(getFromPngFile).mockResolvedValue({ workflow: '{"png":1}' })
|
||||
|
||||
const result = await getPngMetadata(file)
|
||||
|
||||
expect(getFromPngFile).toHaveBeenCalledWith(file)
|
||||
expect(result).toEqual({ workflow: '{"png":1}' })
|
||||
})
|
||||
|
||||
it('getFlacMetadata delegates to getFromFlacFile', async () => {
|
||||
const file = new File([], 'a.flac', { type: 'audio/flac' })
|
||||
vi.mocked(getFromFlacFile).mockResolvedValue({ workflow: '{"flac":1}' })
|
||||
|
||||
const result = await getFlacMetadata(file)
|
||||
|
||||
expect(getFromFlacFile).toHaveBeenCalledWith(file)
|
||||
expect(result).toEqual({ workflow: '{"flac":1}' })
|
||||
})
|
||||
|
||||
it('getAvifMetadata delegates to getFromAvifFile', async () => {
|
||||
const file = new File([], 'a.avif', { type: 'image/avif' })
|
||||
vi.mocked(getFromAvifFile).mockResolvedValue({ workflow: '{"avif":1}' })
|
||||
|
||||
const result = await getAvifMetadata(file)
|
||||
|
||||
expect(getFromAvifFile).toHaveBeenCalledWith(file)
|
||||
expect(result).toEqual({ workflow: '{"avif":1}' })
|
||||
})
|
||||
})
|
||||
|
||||
const buildSafetensors = (header: Record<string, unknown>): File => {
|
||||
const headerJson = JSON.stringify(header)
|
||||
const headerBytes = new TextEncoder().encode(headerJson)
|
||||
const buf = new ArrayBuffer(8 + headerBytes.length)
|
||||
const dv = new DataView(buf)
|
||||
dv.setUint32(0, headerBytes.length, true)
|
||||
dv.setUint32(4, 0, true)
|
||||
new Uint8Array(buf, 8).set(headerBytes)
|
||||
return new File([buf], 'x.safetensors')
|
||||
}
|
||||
|
||||
describe('getLatentMetadata', () => {
|
||||
it('returns the __metadata__ object from a safetensors header', async () => {
|
||||
const file = buildSafetensors({
|
||||
__metadata__: { workflow: '{"nodes":[]}', extra: 'value' },
|
||||
'tensor.weight': { dtype: 'F32', shape: [1], data_offsets: [0, 4] }
|
||||
})
|
||||
|
||||
const result = await getLatentMetadata(file)
|
||||
|
||||
expect(result).toEqual({ workflow: '{"nodes":[]}', extra: 'value' })
|
||||
})
|
||||
|
||||
it('resolves undefined when header has no __metadata__ entry', async () => {
|
||||
const file = buildSafetensors({
|
||||
'tensor.weight': { dtype: 'F32', shape: [1], data_offsets: [0, 4] }
|
||||
})
|
||||
|
||||
const result = await getLatentMetadata(file)
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
743
src/services/load3dService.test.ts
Normal file
743
src/services/load3dService.test.ts
Normal file
@@ -0,0 +1,743 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
const { nodeMap, useLoad3dViewerMock, skeletonCloneMock } = vi.hoisted(() => ({
|
||||
nodeMap: new Map<LGraphNode, Load3d>(),
|
||||
useLoad3dViewerMock: vi.fn(),
|
||||
skeletonCloneMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3d', () => ({
|
||||
nodeToLoad3dMap: nodeMap
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3dViewer', () => ({
|
||||
useLoad3dViewer: useLoad3dViewerMock
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/utils/SkeletonUtils', () => ({
|
||||
clone: skeletonCloneMock
|
||||
}))
|
||||
|
||||
// Track every node a test creates so the load3dService singleton's
|
||||
// internal viewerInstances map can be drained in beforeEach without
|
||||
// reaching into the module's private state.
|
||||
const createdNodes = new Set<LGraphNode>()
|
||||
|
||||
function makeNode(id: number | string): LGraphNode {
|
||||
const node = createMockLGraphNode({ id })
|
||||
createdNodes.add(node)
|
||||
return node
|
||||
}
|
||||
|
||||
function makeLoad3d(): Load3d {
|
||||
return {
|
||||
remove: vi.fn()
|
||||
} as unknown as Load3d
|
||||
}
|
||||
|
||||
function makeViewer(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
needApplyChanges: { value: false },
|
||||
applyChanges: vi.fn().mockResolvedValue(true),
|
||||
cleanup: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('load3dService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
nodeMap.clear()
|
||||
const svc = useLoad3dService()
|
||||
for (const node of createdNodes) svc.removeViewer(node)
|
||||
createdNodes.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('singleton', () => {
|
||||
it('returns the same instance from useLoad3dService()', () => {
|
||||
expect(useLoad3dService()).toBe(useLoad3dService())
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLoad3d (sync)', () => {
|
||||
it('returns null when the load3d module has not been loaded yet', () => {
|
||||
// Before any async accessor has been called, the cache is empty.
|
||||
// We can't easily simulate "module never loaded" because vi.mock makes
|
||||
// it eagerly available, so this test verifies the behavior via missing
|
||||
// entries instead.
|
||||
const node = makeNode('missing')
|
||||
expect(useLoad3dService().getLoad3d(node)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null after async load when the node has no entry in the map', async () => {
|
||||
const svc = useLoad3dService()
|
||||
// Trigger the async loader so the sync path has a populated cache.
|
||||
await svc.getLoad3dAsync(makeNode('anything'))
|
||||
|
||||
expect(svc.getLoad3d(makeNode('still-missing'))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the registered Load3d instance once the map has been populated', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const node = makeNode('a')
|
||||
const load3d = makeLoad3d()
|
||||
nodeMap.set(node, load3d)
|
||||
await svc.getLoad3dAsync(node)
|
||||
|
||||
expect(svc.getLoad3d(node)).toBe(load3d)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLoad3dAsync', () => {
|
||||
it('returns the Load3d for a registered node', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const node = makeNode('async-a')
|
||||
const load3d = makeLoad3d()
|
||||
nodeMap.set(node, load3d)
|
||||
|
||||
await expect(svc.getLoad3dAsync(node)).resolves.toBe(load3d)
|
||||
})
|
||||
|
||||
it('returns null for an unregistered node', async () => {
|
||||
const svc = useLoad3dService()
|
||||
await expect(
|
||||
svc.getLoad3dAsync(makeNode('async-missing'))
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeByLoad3d', () => {
|
||||
it('finds the node owning a given Load3d instance', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const node = makeNode('owner')
|
||||
const load3d = makeLoad3d()
|
||||
nodeMap.set(node, load3d)
|
||||
await svc.getLoad3dAsync(node)
|
||||
|
||||
expect(svc.getNodeByLoad3d(load3d)).toBe(node)
|
||||
})
|
||||
|
||||
it('returns null when the Load3d instance is not in the map', async () => {
|
||||
const svc = useLoad3dService()
|
||||
await svc.getLoad3dAsync(makeNode('warmup'))
|
||||
|
||||
expect(svc.getNodeByLoad3d(makeLoad3d())).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeLoad3d', () => {
|
||||
it('calls remove() on the instance and drops it from the map', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const node = makeNode('to-remove')
|
||||
const load3d = makeLoad3d()
|
||||
nodeMap.set(node, load3d)
|
||||
await svc.getLoad3dAsync(node)
|
||||
|
||||
svc.removeLoad3d(node)
|
||||
|
||||
expect(load3d.remove).toHaveBeenCalled()
|
||||
expect(nodeMap.has(node)).toBe(false)
|
||||
})
|
||||
|
||||
it('is a no-op when the node has no registered Load3d', async () => {
|
||||
const svc = useLoad3dService()
|
||||
await svc.getLoad3dAsync(makeNode('warmup'))
|
||||
|
||||
expect(() => svc.removeLoad3d(makeNode('not-there'))).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear', () => {
|
||||
it('removes every registered Load3d', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const a = makeNode('a')
|
||||
const b = makeNode('b')
|
||||
const ld1 = makeLoad3d()
|
||||
const ld2 = makeLoad3d()
|
||||
nodeMap.set(a, ld1)
|
||||
nodeMap.set(b, ld2)
|
||||
await svc.getLoad3dAsync(a)
|
||||
|
||||
svc.clear()
|
||||
|
||||
expect(nodeMap.size).toBe(0)
|
||||
expect(ld1.remove).toHaveBeenCalled()
|
||||
expect(ld2.remove).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewer lifecycle', () => {
|
||||
it('getOrCreateViewer creates a viewer on first call and reuses it on subsequent calls', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const node = makeNode('v1')
|
||||
const viewer = makeViewer()
|
||||
useLoad3dViewerMock.mockReturnValue(viewer)
|
||||
|
||||
const first = await svc.getOrCreateViewer(node)
|
||||
const second = await svc.getOrCreateViewer(node)
|
||||
|
||||
expect(first).toBe(viewer)
|
||||
expect(second).toBe(viewer)
|
||||
expect(useLoad3dViewerMock).toHaveBeenCalledTimes(1)
|
||||
expect(useLoad3dViewerMock).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('getOrCreateViewerSync uses the supplied factory once and caches the result', () => {
|
||||
const svc = useLoad3dService()
|
||||
const node = makeNode('v-sync')
|
||||
const viewer = makeViewer()
|
||||
const factory = vi.fn().mockReturnValue(viewer)
|
||||
|
||||
const first = svc.getOrCreateViewerSync(
|
||||
node,
|
||||
factory as unknown as typeof useLoad3dViewerMock
|
||||
)
|
||||
const second = svc.getOrCreateViewerSync(
|
||||
node,
|
||||
factory as unknown as typeof useLoad3dViewerMock
|
||||
)
|
||||
|
||||
expect(first).toBe(viewer)
|
||||
expect(second).toBe(viewer)
|
||||
expect(factory).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('removeViewer calls cleanup and forgets the viewer', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const node = makeNode('v2')
|
||||
const viewer = makeViewer()
|
||||
useLoad3dViewerMock.mockReturnValue(viewer)
|
||||
await svc.getOrCreateViewer(node)
|
||||
|
||||
svc.removeViewer(node)
|
||||
|
||||
expect(viewer.cleanup).toHaveBeenCalled()
|
||||
useLoad3dViewerMock.mockClear()
|
||||
const fresh = makeViewer()
|
||||
useLoad3dViewerMock.mockReturnValue(fresh)
|
||||
const result = await svc.getOrCreateViewer(node)
|
||||
expect(useLoad3dViewerMock).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(fresh)
|
||||
})
|
||||
|
||||
it('removeViewer is safe when no viewer has been created for the node', () => {
|
||||
const svc = useLoad3dService()
|
||||
expect(() => svc.removeViewer(makeNode('never'))).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleViewerClose', () => {
|
||||
it('removes the viewer without applying changes when none are pending', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const node = makeNode('close-clean')
|
||||
const viewer = makeViewer({ needApplyChanges: { value: false } })
|
||||
useLoad3dViewerMock.mockReturnValue(viewer)
|
||||
await svc.getOrCreateViewer(node)
|
||||
|
||||
await svc.handleViewerClose(node)
|
||||
|
||||
expect(viewer.applyChanges).not.toHaveBeenCalled()
|
||||
expect(viewer.cleanup).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies changes and syncs the node config when changes are pending', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const syncLoad3dConfig = vi.fn()
|
||||
const node = Object.assign(makeNode('close-dirty'), {
|
||||
syncLoad3dConfig
|
||||
}) as LGraphNode
|
||||
const viewer = makeViewer({ needApplyChanges: { value: true } })
|
||||
useLoad3dViewerMock.mockReturnValue(viewer)
|
||||
await svc.getOrCreateViewer(node)
|
||||
|
||||
await svc.handleViewerClose(node)
|
||||
|
||||
expect(viewer.applyChanges).toHaveBeenCalled()
|
||||
expect(syncLoad3dConfig).toHaveBeenCalled()
|
||||
expect(viewer.cleanup).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips syncLoad3dConfig when the node does not define it', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const node = makeNode('close-no-sync')
|
||||
const viewer = makeViewer({ needApplyChanges: { value: true } })
|
||||
useLoad3dViewerMock.mockReturnValue(viewer)
|
||||
await svc.getOrCreateViewer(node)
|
||||
|
||||
await expect(svc.handleViewerClose(node)).resolves.toBeUndefined()
|
||||
expect(viewer.applyChanges).toHaveBeenCalled()
|
||||
expect(viewer.cleanup).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleViewportRefresh', () => {
|
||||
it('returns silently when the load3d is null', () => {
|
||||
expect(() => useLoad3dService().handleViewportRefresh(null)).not.toThrow()
|
||||
})
|
||||
|
||||
it('toggles the camera through the opposite type and back, then updates controls', () => {
|
||||
const controls = { update: vi.fn() }
|
||||
const load3d = {
|
||||
handleResize: vi.fn(),
|
||||
getCurrentCameraType: vi.fn().mockReturnValue('perspective'),
|
||||
toggleCamera: vi.fn(),
|
||||
getControlsManager: vi.fn().mockReturnValue({ controls })
|
||||
} as unknown as Load3d
|
||||
|
||||
useLoad3dService().handleViewportRefresh(load3d)
|
||||
|
||||
expect(load3d.handleResize).toHaveBeenCalled()
|
||||
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(1, 'orthographic')
|
||||
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(2, 'perspective')
|
||||
expect(controls.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles in the reverse direction when starting from orthographic', () => {
|
||||
const controls = { update: vi.fn() }
|
||||
const load3d = {
|
||||
handleResize: vi.fn(),
|
||||
getCurrentCameraType: vi.fn().mockReturnValue('orthographic'),
|
||||
toggleCamera: vi.fn(),
|
||||
getControlsManager: vi.fn().mockReturnValue({ controls })
|
||||
} as unknown as Load3d
|
||||
|
||||
useLoad3dService().handleViewportRefresh(load3d)
|
||||
|
||||
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(1, 'perspective')
|
||||
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(2, 'orthographic')
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyLoad3dState', () => {
|
||||
type SourceOverrides = Partial<{
|
||||
currentModel: THREE.Object3D | null
|
||||
isSplat: boolean
|
||||
originalURL: string | null
|
||||
originalModel: unknown
|
||||
materialMode: string
|
||||
currentUpDirection: string
|
||||
appliedTexture: unknown
|
||||
gizmoEnabled: boolean
|
||||
hasAnimations: boolean
|
||||
cameraType: 'perspective' | 'orthographic'
|
||||
backgroundInfo: { type: 'image' | 'color' }
|
||||
lightsIntensity: number | undefined
|
||||
fov: number
|
||||
}>
|
||||
|
||||
function makeSource(overrides: SourceOverrides = {}): Load3d {
|
||||
const {
|
||||
currentModel = null,
|
||||
isSplat = false,
|
||||
originalURL = null,
|
||||
originalModel = null,
|
||||
materialMode = 'original',
|
||||
currentUpDirection = 'original',
|
||||
appliedTexture = null,
|
||||
gizmoEnabled = false,
|
||||
hasAnimations = false,
|
||||
cameraType = 'perspective',
|
||||
backgroundInfo = { type: 'color' },
|
||||
lightsIntensity = 0.8,
|
||||
fov = 35
|
||||
} = overrides
|
||||
const ambient = { intensity: 0.5 }
|
||||
const main = { intensity: lightsIntensity }
|
||||
return {
|
||||
modelManager: { currentModel, originalURL },
|
||||
getGizmoManager: () => ({
|
||||
isEnabled: () => gizmoEnabled,
|
||||
getInitialTransform: () => ({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
rotation: { x: 0.1, y: 0.2, z: 0.3 },
|
||||
scale: { x: 4, y: 5, z: 6 }
|
||||
})
|
||||
}),
|
||||
isSplatModel: () => isSplat,
|
||||
getModelManager: () => ({
|
||||
originalModel,
|
||||
materialMode,
|
||||
currentUpDirection,
|
||||
appliedTexture
|
||||
}),
|
||||
getGizmoTransform: () => ({
|
||||
position: { x: 7, y: 8, z: 9 },
|
||||
rotation: { x: 0.4, y: 0.5, z: 0.6 },
|
||||
scale: { x: 10, y: 11, z: 12 }
|
||||
}),
|
||||
hasAnimations: () => hasAnimations,
|
||||
getCurrentCameraType: () => cameraType,
|
||||
getCameraState: () => ({ snapshot: true }),
|
||||
getSceneManager: () => ({
|
||||
scene: new THREE.Scene(),
|
||||
currentBackgroundColor: '#abcdef',
|
||||
gridHelper: { visible: true },
|
||||
getCurrentBackgroundInfo: () => backgroundInfo
|
||||
}),
|
||||
getLightingManager: () => ({ lights: [ambient, main] }),
|
||||
getCameraManager: () => ({ perspectiveCamera: { fov } })
|
||||
} as unknown as Load3d
|
||||
}
|
||||
|
||||
type TargetState = {
|
||||
modelManager: {
|
||||
currentModel: THREE.Object3D | null
|
||||
originalModel: unknown
|
||||
materialMode: string
|
||||
currentUpDirection: string
|
||||
appliedTexture: unknown
|
||||
}
|
||||
gizmoManager: {
|
||||
isEnabled: () => boolean
|
||||
detach: ReturnType<typeof vi.fn>
|
||||
setupForModel: ReturnType<typeof vi.fn>
|
||||
}
|
||||
animationManager: {
|
||||
setupModelAnimations: ReturnType<typeof vi.fn>
|
||||
}
|
||||
sceneRemoved: THREE.Object3D[]
|
||||
sceneAdded: THREE.Object3D[]
|
||||
}
|
||||
|
||||
function makeTarget(
|
||||
opts: {
|
||||
gizmoEnabled?: boolean
|
||||
existingModel?: THREE.Object3D | null
|
||||
} = {}
|
||||
) {
|
||||
const { gizmoEnabled = false, existingModel = null } = opts
|
||||
const scene = new THREE.Scene()
|
||||
const sceneRemoved: THREE.Object3D[] = []
|
||||
const sceneAdded: THREE.Object3D[] = []
|
||||
const sceneRemove = vi.fn((o: THREE.Object3D) => {
|
||||
sceneRemoved.push(o)
|
||||
scene.remove(o)
|
||||
})
|
||||
const sceneAdd = vi.fn((o: THREE.Object3D) => {
|
||||
sceneAdded.push(o)
|
||||
scene.add(o)
|
||||
})
|
||||
const modelManager = {
|
||||
currentModel: existingModel as THREE.Object3D | null,
|
||||
originalModel: null as unknown,
|
||||
materialMode: 'original',
|
||||
currentUpDirection: 'original',
|
||||
appliedTexture: null as unknown
|
||||
}
|
||||
const animationManager = {
|
||||
setupModelAnimations: vi.fn()
|
||||
}
|
||||
// Memoize the gizmo manager so production code's repeated
|
||||
// `target.getGizmoManager()` calls reach the same vi.fn instances.
|
||||
const gizmoManager = {
|
||||
isEnabled: () => gizmoEnabled,
|
||||
detach: vi.fn(),
|
||||
setupForModel: vi.fn()
|
||||
}
|
||||
const target = {
|
||||
getGizmoManager: () => gizmoManager,
|
||||
getModelManager: () => modelManager,
|
||||
getSceneManager: () => ({
|
||||
scene: {
|
||||
add: sceneAdd,
|
||||
remove: sceneRemove
|
||||
} as unknown as THREE.Scene
|
||||
}),
|
||||
loadModel: vi.fn().mockResolvedValue(undefined),
|
||||
setMaterialMode: vi.fn(),
|
||||
setUpDirection: vi.fn(),
|
||||
applyGizmoTransform: vi.fn(),
|
||||
setGizmoEnabled: vi.fn(),
|
||||
animationManager,
|
||||
toggleCamera: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
setBackgroundColor: vi.fn(),
|
||||
toggleGrid: vi.fn(),
|
||||
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
|
||||
setLightIntensity: vi.fn(),
|
||||
setFOV: vi.fn()
|
||||
} as unknown as Load3d
|
||||
const state: TargetState = {
|
||||
modelManager,
|
||||
gizmoManager,
|
||||
animationManager,
|
||||
sceneRemoved,
|
||||
sceneAdded
|
||||
}
|
||||
return { target, state }
|
||||
}
|
||||
|
||||
function makeModel(): THREE.Object3D {
|
||||
return new THREE.Object3D()
|
||||
}
|
||||
|
||||
it('copies camera/scene/lighting/FOV even when there is no source model', async () => {
|
||||
const source = makeSource({ currentModel: null, lightsIntensity: 2 })
|
||||
const { target } = makeTarget()
|
||||
skeletonCloneMock.mockReturnValue(makeModel())
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.toggleCamera).toHaveBeenCalledWith('perspective')
|
||||
expect(target.setCameraState).toHaveBeenCalledWith({ snapshot: true })
|
||||
expect(target.setBackgroundColor).toHaveBeenCalledWith('#abcdef')
|
||||
expect(target.toggleGrid).toHaveBeenCalledWith(true)
|
||||
expect(target.setLightIntensity).toHaveBeenCalledWith(2)
|
||||
expect(target.setFOV).toHaveBeenCalledWith(35)
|
||||
expect(skeletonCloneMock).not.toHaveBeenCalled()
|
||||
expect(target.loadModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses target.loadModel(originalURL) for splat models, never invoking SkeletonUtils.clone', async () => {
|
||||
const source = makeSource({
|
||||
currentModel: makeModel(),
|
||||
isSplat: true,
|
||||
originalURL: 'http://example.com/scan.splat'
|
||||
})
|
||||
const { target } = makeTarget()
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.loadModel).toHaveBeenCalledWith(
|
||||
'http://example.com/scan.splat'
|
||||
)
|
||||
expect(skeletonCloneMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips loadModel for splat models when originalURL is null', async () => {
|
||||
const source = makeSource({
|
||||
currentModel: makeModel(),
|
||||
isSplat: true,
|
||||
originalURL: null
|
||||
})
|
||||
const { target } = makeTarget()
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.loadModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes the target existing model from the scene before adding the clone', async () => {
|
||||
const existing = makeModel()
|
||||
existing.name = 'existing'
|
||||
const source = makeSource({ currentModel: makeModel() })
|
||||
const { target, state } = makeTarget({ existingModel: existing })
|
||||
const clone = makeModel()
|
||||
skeletonCloneMock.mockReturnValue(clone)
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(state.sceneRemoved).toContain(existing)
|
||||
expect(state.sceneAdded).toContain(clone)
|
||||
})
|
||||
|
||||
it('clones the source model via SkeletonUtils and assigns it as the target current model', async () => {
|
||||
const sourceModel = makeModel()
|
||||
const clone = makeModel()
|
||||
const source = makeSource({ currentModel: sourceModel })
|
||||
const { target, state } = makeTarget()
|
||||
skeletonCloneMock.mockReturnValue(clone)
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(skeletonCloneMock).toHaveBeenCalledWith(sourceModel)
|
||||
expect(state.modelManager.currentModel).toBe(clone)
|
||||
})
|
||||
|
||||
it('copies originalModel, material mode, up direction, and applied texture from source to target', async () => {
|
||||
const sourceOriginal = { kind: 'gltf' }
|
||||
const texture = { id: 'tex1' }
|
||||
const source = makeSource({
|
||||
currentModel: makeModel(),
|
||||
originalModel: sourceOriginal,
|
||||
materialMode: 'wireframe',
|
||||
currentUpDirection: '+y',
|
||||
appliedTexture: texture
|
||||
})
|
||||
const { target, state } = makeTarget()
|
||||
skeletonCloneMock.mockReturnValue(makeModel())
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(state.modelManager.originalModel).toBe(sourceOriginal)
|
||||
expect(state.modelManager.materialMode).toBe('wireframe')
|
||||
expect(state.modelManager.currentUpDirection).toBe('+y')
|
||||
expect(state.modelManager.appliedTexture).toBe(texture)
|
||||
expect(target.setMaterialMode).toHaveBeenCalledWith('wireframe')
|
||||
expect(target.setUpDirection).toHaveBeenCalledWith('+y')
|
||||
})
|
||||
|
||||
it('positions the clone at the source initial transform', async () => {
|
||||
const clone = makeModel()
|
||||
const source = makeSource({ currentModel: makeModel() })
|
||||
const { target } = makeTarget()
|
||||
skeletonCloneMock.mockReturnValue(clone)
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(clone.position.toArray()).toEqual([1, 2, 3])
|
||||
expect(clone.rotation.toArray().slice(0, 3)).toEqual([0.1, 0.2, 0.3])
|
||||
expect(clone.scale.toArray()).toEqual([4, 5, 6])
|
||||
})
|
||||
|
||||
it('applies the source gizmo transform to the target', async () => {
|
||||
const source = makeSource({ currentModel: makeModel() })
|
||||
const { target } = makeTarget()
|
||||
skeletonCloneMock.mockReturnValue(makeModel())
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.applyGizmoTransform).toHaveBeenCalledWith(
|
||||
{ x: 7, y: 8, z: 9 },
|
||||
{ x: 0.4, y: 0.5, z: 0.6 },
|
||||
{ x: 10, y: 11, z: 12 }
|
||||
)
|
||||
})
|
||||
|
||||
it('enables the gizmo on target when the source had it enabled', async () => {
|
||||
const source = makeSource({
|
||||
currentModel: makeModel(),
|
||||
gizmoEnabled: true
|
||||
})
|
||||
const { target } = makeTarget({ gizmoEnabled: false })
|
||||
skeletonCloneMock.mockReturnValue(makeModel())
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.setGizmoEnabled).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('enables the gizmo on target when the target previously had it enabled, even if source did not', async () => {
|
||||
const source = makeSource({
|
||||
currentModel: makeModel(),
|
||||
gizmoEnabled: false
|
||||
})
|
||||
const { target } = makeTarget({ gizmoEnabled: true })
|
||||
skeletonCloneMock.mockReturnValue(makeModel())
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.setGizmoEnabled).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('does not enable the gizmo when neither side had it', async () => {
|
||||
const source = makeSource({
|
||||
currentModel: makeModel(),
|
||||
gizmoEnabled: false
|
||||
})
|
||||
const { target } = makeTarget({ gizmoEnabled: false })
|
||||
skeletonCloneMock.mockReturnValue(makeModel())
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.setGizmoEnabled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards animation setup when the source has animations', async () => {
|
||||
const sourceOriginal = { kind: 'gltf' }
|
||||
const clone = makeModel()
|
||||
const source = makeSource({
|
||||
currentModel: makeModel(),
|
||||
originalModel: sourceOriginal,
|
||||
hasAnimations: true
|
||||
})
|
||||
const { target, state } = makeTarget()
|
||||
skeletonCloneMock.mockReturnValue(clone)
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(state.animationManager.setupModelAnimations).toHaveBeenCalledWith(
|
||||
clone,
|
||||
sourceOriginal
|
||||
)
|
||||
})
|
||||
|
||||
it('does not forward animation setup when the source has none', async () => {
|
||||
const source = makeSource({
|
||||
currentModel: makeModel(),
|
||||
hasAnimations: false
|
||||
})
|
||||
const { target, state } = makeTarget()
|
||||
skeletonCloneMock.mockReturnValue(makeModel())
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(state.animationManager.setupModelAnimations).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards an image background to setBackgroundImage when the source node has a configured path', async () => {
|
||||
const node = createMockLGraphNode({
|
||||
id: 'bg-source',
|
||||
properties: { 'Scene Config': { backgroundImage: '3d/bg.png' } }
|
||||
})
|
||||
createdNodes.add(node)
|
||||
const source = makeSource({ backgroundInfo: { type: 'image' } })
|
||||
nodeMap.set(node, source)
|
||||
// Warm the cache so `getNodeByLoad3d` finds the source.
|
||||
await useLoad3dService().getLoad3dAsync(node)
|
||||
const { target } = makeTarget()
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.setBackgroundImage).toHaveBeenCalledWith('3d/bg.png')
|
||||
})
|
||||
|
||||
it('clears the background when the source background type is not image', async () => {
|
||||
const source = makeSource({ backgroundInfo: { type: 'color' } })
|
||||
const { target } = makeTarget()
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.setBackgroundImage).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('falls back to setLightIntensity(1) when the second light intensity is falsy', async () => {
|
||||
const source = makeSource({ lightsIntensity: 0 })
|
||||
const { target } = makeTarget()
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.setLightIntensity).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('skips setFOV when the source camera is orthographic', async () => {
|
||||
const source = makeSource({ cameraType: 'orthographic' })
|
||||
const { target } = makeTarget()
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.setFOV).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('always detaches the target gizmo at the start of the copy', async () => {
|
||||
const source = makeSource({ currentModel: makeModel() })
|
||||
const { target, state } = makeTarget()
|
||||
skeletonCloneMock.mockReturnValue(makeModel())
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(state.gizmoManager.detach).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls setupForModel on the target gizmo with the freshly cloned model', async () => {
|
||||
const clone = makeModel()
|
||||
const source = makeSource({ currentModel: makeModel() })
|
||||
const { target, state } = makeTarget()
|
||||
skeletonCloneMock.mockReturnValue(clone)
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(state.gizmoManager.setupForModel).toHaveBeenCalledWith(clone)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -754,3 +754,301 @@ describe('useMissingNodesErrorStore - setMissingNodeTypes', () => {
|
||||
expect(store.missingNodesError?.nodeTypes).toEqual(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - WebSocket event handlers', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
function fire<T>(event: string, detail: T) {
|
||||
const handler = apiEventHandlers.get(event)
|
||||
if (!handler) throw new Error(`${event} handler not bound`)
|
||||
handler(new CustomEvent(event, { detail }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
apiEventHandlers.clear()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
})
|
||||
|
||||
describe('execution_start', () => {
|
||||
it('sets activeJobId and seeds an empty queued job entry', () => {
|
||||
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
|
||||
|
||||
expect(store.activeJobId).toBe('job-1')
|
||||
expect(store.queuedJobs['job-1']).toEqual({ nodes: {} })
|
||||
})
|
||||
|
||||
it('clears initializing state for the starting job', () => {
|
||||
store.initializingJobIds = new Set([
|
||||
'job-1',
|
||||
'job-2'
|
||||
]) as unknown as Set<string>
|
||||
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
|
||||
|
||||
expect(store.initializingJobIds.has('job-1')).toBe(false)
|
||||
expect(store.initializingJobIds.has('job-2')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('execution_cached', () => {
|
||||
it('marks the listed nodes as cached on the active job', () => {
|
||||
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
|
||||
|
||||
fire('execution_cached', {
|
||||
prompt_id: 'job-1',
|
||||
nodes: ['nodeA', 'nodeB'],
|
||||
timestamp: 0
|
||||
})
|
||||
|
||||
expect(store.activeJob?.nodes).toEqual({ nodeA: true, nodeB: true })
|
||||
})
|
||||
|
||||
it('is a no-op when no active job exists', () => {
|
||||
fire('execution_cached', {
|
||||
prompt_id: 'job-1',
|
||||
nodes: ['nodeA'],
|
||||
timestamp: 0
|
||||
})
|
||||
|
||||
expect(store.activeJob).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('execution_interrupted', () => {
|
||||
it('clears active job state on interrupt', () => {
|
||||
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
|
||||
expect(store.activeJobId).toBe('job-1')
|
||||
|
||||
fire('execution_interrupted', {
|
||||
prompt_id: 'job-1',
|
||||
node_id: 'n1',
|
||||
node_type: 't',
|
||||
executed: [],
|
||||
timestamp: 0
|
||||
})
|
||||
|
||||
expect(store.activeJobId).toBeNull()
|
||||
expect(store.queuedJobs['job-1']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('executed', () => {
|
||||
it('marks the executed node as done on the active job', () => {
|
||||
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
|
||||
fire('execution_cached', {
|
||||
prompt_id: 'job-1',
|
||||
nodes: ['n1'],
|
||||
timestamp: 0
|
||||
})
|
||||
|
||||
fire('executed', {
|
||||
node: 'n1',
|
||||
display_node: 'n1',
|
||||
prompt_id: 'job-1',
|
||||
output: {}
|
||||
})
|
||||
|
||||
expect(store.activeJob?.nodes['n1']).toBe(true)
|
||||
})
|
||||
|
||||
it('is a no-op when no active job exists', () => {
|
||||
expect(() =>
|
||||
fire('executed', {
|
||||
node: 'n1',
|
||||
display_node: 'n1',
|
||||
prompt_id: 'orphan',
|
||||
output: {}
|
||||
})
|
||||
).not.toThrow()
|
||||
expect(store.activeJob).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('execution_success', () => {
|
||||
it('clears active job and progress state', () => {
|
||||
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
|
||||
|
||||
fire('execution_success', { prompt_id: 'job-1', timestamp: 0 })
|
||||
|
||||
expect(store.activeJobId).toBeNull()
|
||||
expect(store.queuedJobs['job-1']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('executing', () => {
|
||||
it('clears _executingNodeProgress and activeJobId when detail is null', () => {
|
||||
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
|
||||
store._executingNodeProgress = {
|
||||
value: 1,
|
||||
max: 2,
|
||||
prompt_id: 'job-1',
|
||||
node: '1'
|
||||
}
|
||||
|
||||
fire('executing', null)
|
||||
|
||||
expect(store._executingNodeProgress).toBeNull()
|
||||
expect(store.activeJobId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('progress', () => {
|
||||
it('sets _executingNodeProgress from the event payload', () => {
|
||||
const payload = { value: 3, max: 10, prompt_id: 'job-1', node: 'n1' }
|
||||
|
||||
fire('progress', payload)
|
||||
|
||||
expect(store._executingNodeProgress).toEqual(payload)
|
||||
})
|
||||
})
|
||||
|
||||
describe('status', () => {
|
||||
it('reads clientId from api once and stops listening', async () => {
|
||||
const apiModule = await import('@/scripts/api')
|
||||
const removeSpy = vi.mocked(apiModule.api.removeEventListener)
|
||||
|
||||
fire('status', { exec_info: { queue_remaining: 0 } })
|
||||
|
||||
expect(store.clientId).toBe('test-client')
|
||||
expect(removeSpy).toHaveBeenCalledWith('status', expect.any(Function))
|
||||
})
|
||||
})
|
||||
|
||||
describe('execution_error', () => {
|
||||
it('routes a service-level error (no node_id) to the prompt error store', () => {
|
||||
const errorStore = useExecutionErrorStore()
|
||||
|
||||
fire('execution_error', {
|
||||
prompt_id: 'job-1',
|
||||
node_id: null,
|
||||
exception_type: 'StagnationError',
|
||||
exception_message: 'Job has stagnated',
|
||||
traceback: ['line 1', 'line 2']
|
||||
})
|
||||
|
||||
expect(errorStore.lastPromptError).toMatchObject({
|
||||
type: 'StagnationError',
|
||||
message: 'StagnationError: Job has stagnated',
|
||||
details: 'line 1\nline 2'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes a runtime error (with node_id) to lastExecutionError', () => {
|
||||
const errorStore = useExecutionErrorStore()
|
||||
|
||||
fire('execution_error', {
|
||||
prompt_id: 'job-1',
|
||||
node_id: 'n1',
|
||||
node_type: 'KSampler',
|
||||
exception_type: 'RuntimeError',
|
||||
exception_message: 'CUDA OOM',
|
||||
traceback: []
|
||||
})
|
||||
|
||||
expect(errorStore.lastExecutionError).toMatchObject({
|
||||
prompt_id: 'job-1',
|
||||
node_id: 'n1',
|
||||
exception_message: 'CUDA OOM'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('notification', () => {
|
||||
it('marks a job as initializing when text indicates waiting for a machine', () => {
|
||||
fire('notification', {
|
||||
id: 'job-9',
|
||||
value: 'Waiting for a machine to become available'
|
||||
})
|
||||
|
||||
expect(store.initializingJobIds.has('job-9')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores notifications without an id', () => {
|
||||
fire('notification', {
|
||||
id: '',
|
||||
value: 'Waiting for a machine'
|
||||
})
|
||||
|
||||
expect(store.initializingJobIds.size).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores notifications without the waiting-for-machine sentinel', () => {
|
||||
fire('notification', { id: 'job-9', value: 'Hello' })
|
||||
|
||||
expect(store.initializingJobIds.has('job-9')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unbindExecutionEvents', () => {
|
||||
it('removes every listener registered by bindExecutionEvents', async () => {
|
||||
const apiModule = await import('@/scripts/api')
|
||||
const removeSpy = vi.mocked(apiModule.api.removeEventListener)
|
||||
const events = [
|
||||
'notification',
|
||||
'execution_start',
|
||||
'execution_cached',
|
||||
'execution_interrupted',
|
||||
'execution_success',
|
||||
'executed',
|
||||
'executing',
|
||||
'progress',
|
||||
'progress_state',
|
||||
'execution_error',
|
||||
'progress_text'
|
||||
]
|
||||
|
||||
store.unbindExecutionEvents()
|
||||
|
||||
for (const event of events) {
|
||||
expect(removeSpy).toHaveBeenCalledWith(event, expect.any(Function))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - storeJob and workflow path tracking', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
apiEventHandlers.clear()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
})
|
||||
|
||||
it('storeJob populates queuedJobs and tracks the workflow path', () => {
|
||||
const workflow = {
|
||||
activeState: { id: 'wf-1' },
|
||||
initialState: { id: 'wf-1' },
|
||||
path: '/workflows/foo.json'
|
||||
} as unknown as Parameters<typeof store.storeJob>[0]['workflow']
|
||||
|
||||
store.storeJob({ nodes: ['a', 'b'], id: 'job-1', workflow })
|
||||
|
||||
expect(store.queuedJobs['job-1']?.nodes).toEqual({ a: false, b: false })
|
||||
expect(store.queuedJobs['job-1']?.workflow).toStrictEqual(workflow)
|
||||
expect(store.jobIdToWorkflowId.get('job-1')).toBe('wf-1')
|
||||
expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe(
|
||||
'/workflows/foo.json'
|
||||
)
|
||||
})
|
||||
|
||||
it('registerJobWorkflowIdMapping ignores empty inputs', () => {
|
||||
store.registerJobWorkflowIdMapping('job-1', 'wf-1')
|
||||
store.registerJobWorkflowIdMapping('', 'wf-2')
|
||||
store.registerJobWorkflowIdMapping('job-2', '')
|
||||
|
||||
expect(store.jobIdToWorkflowId.get('job-1')).toBe('wf-1')
|
||||
expect(store.jobIdToWorkflowId.size).toBe(1)
|
||||
})
|
||||
|
||||
it('ensureSessionWorkflowPath is idempotent and updates on change', () => {
|
||||
store.ensureSessionWorkflowPath('job-1', '/a.json')
|
||||
store.ensureSessionWorkflowPath('job-1', '/a.json')
|
||||
store.ensureSessionWorkflowPath('job-1', '/b.json')
|
||||
|
||||
expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe('/b.json')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isStaleChunkError, parsePreloadError } from './preloadErrorUtil'
|
||||
import { parsePreloadError } from './preloadErrorUtil'
|
||||
|
||||
describe('parsePreloadError', () => {
|
||||
it('parses CSS preload error', () => {
|
||||
@@ -90,74 +90,3 @@ describe('parsePreloadError', () => {
|
||||
expect(result.chunkName).toBe('index')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isStaleChunkError', () => {
|
||||
it('returns true for hashed JS chunk under /assets/', () => {
|
||||
const info = parsePreloadError(
|
||||
new Error(
|
||||
'Failed to fetch dynamically imported module: /assets/vendor-vue-core-abc123.js'
|
||||
)
|
||||
)
|
||||
expect(isStaleChunkError(info)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for hashed CSS chunk under /assets/', () => {
|
||||
const info = parsePreloadError(
|
||||
new Error('Unable to preload CSS for /assets/style-9f8e7d.css')
|
||||
)
|
||||
expect(isStaleChunkError(info)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for hashed mjs chunk under /assets/', () => {
|
||||
const info = parsePreloadError(
|
||||
new Error(
|
||||
'Failed to fetch dynamically imported module: /assets/chunk-abc123.mjs'
|
||||
)
|
||||
)
|
||||
expect(isStaleChunkError(info)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for non-asset URLs like /api/i18n', () => {
|
||||
const info = parsePreloadError(
|
||||
new Error(
|
||||
'Failed to fetch dynamically imported module: https://cloud.comfy.org/api/i18n'
|
||||
)
|
||||
)
|
||||
expect(isStaleChunkError(info)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for unhashed asset files', () => {
|
||||
const info = parsePreloadError(
|
||||
new Error('Failed to fetch dynamically imported module: /assets/index.js')
|
||||
)
|
||||
expect(isStaleChunkError(info)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when no URL can be extracted', () => {
|
||||
const info = parsePreloadError(new Error('Something failed'))
|
||||
expect(isStaleChunkError(info)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for font files', () => {
|
||||
const info = parsePreloadError(
|
||||
new Error('Unable to preload CSS for /assets/inter-abc123.woff2')
|
||||
)
|
||||
expect(isStaleChunkError(info)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for image files', () => {
|
||||
const info = parsePreloadError(
|
||||
new Error('Unable to preload CSS for /assets/logo-abc123.png')
|
||||
)
|
||||
expect(isStaleChunkError(info)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for full URL with hashed asset path', () => {
|
||||
const info = parsePreloadError(
|
||||
new Error(
|
||||
'Failed to fetch dynamically imported module: https://cloud.comfy.org/assets/vendor-three-def456.js'
|
||||
)
|
||||
)
|
||||
expect(isStaleChunkError(info)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,22 +64,6 @@ function extractChunkName(url: string): string | null {
|
||||
return withoutHash || null
|
||||
}
|
||||
|
||||
const HASHED_ASSET_RE = /\/assets\/.+-[a-f0-9]{6,}\.(js|mjs|css)$/
|
||||
|
||||
/**
|
||||
* Determines if a preload error is a genuine stale chunk error — i.e. a hashed
|
||||
* JS/CSS asset under /assets/ that 404'd, typically after a new deployment
|
||||
* changed chunk hashes. Returns false for non-asset URLs (e.g. /api/i18n),
|
||||
* unknown file types, and errors with no extractable URL.
|
||||
*/
|
||||
export function isStaleChunkError(info: PreloadErrorInfo): boolean {
|
||||
if (!info.url) return false
|
||||
if (info.fileType !== 'js' && info.fileType !== 'css') return false
|
||||
|
||||
const pathname = new URL(info.url, 'https://cloud.comfy.org').pathname
|
||||
return HASHED_ASSET_RE.test(pathname)
|
||||
}
|
||||
|
||||
export function parsePreloadError(error: Error): PreloadErrorInfo {
|
||||
const message = error.message || String(error)
|
||||
const url = extractUrl(message)
|
||||
|
||||
@@ -113,4 +113,5 @@ async def delete_file(request: Request):
|
||||
return web.Response(status=500, text=f"Error: {str(e)}")
|
||||
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
WEB_DIRECTORY = "./web"
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
@@ -302,6 +302,21 @@ class NodeWithV2ComboInput:
|
||||
def node_with_v2_combo_input(self, combo_input: str):
|
||||
return (combo_input,)
|
||||
|
||||
class NodeWithLegacyWidget:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": { "legacy_widget": ("INT", { "widgetType": "DEVTOOLSLEGACYWIDGET" }) }
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "node_with_legacy_widget"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = ("A node with a legacy widget")
|
||||
|
||||
def node_with_legacy_widget(self):
|
||||
return ()
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"DevToolsLongComboDropdown": LongComboDropdown,
|
||||
@@ -318,6 +333,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
"DevToolsNodeWithSeedInput": NodeWithSeedInput,
|
||||
"DevToolsNodeWithValidation": NodeWithValidation,
|
||||
"DevToolsNodeWithV2ComboInput": NodeWithV2ComboInput,
|
||||
"DevToolsNodeWithLegacyWidget": NodeWithLegacyWidget,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -335,6 +351,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"DevToolsNodeWithSeedInput": "Node With Seed Input",
|
||||
"DevToolsNodeWithValidation": "Node With Validation",
|
||||
"DevToolsNodeWithV2ComboInput": "Node With V2 Combo Input",
|
||||
"DevToolsNodeWithLegacyWidget": "Node With Legacy Widget",
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
|
||||
35
tools/devtools/web/legacyWidget.js
Normal file
35
tools/devtools/web/legacyWidget.js
Normal file
@@ -0,0 +1,35 @@
|
||||
//es
|
||||
// eslint-disable-next-line import-x/no-unresolved -- import is correct at time of test execution
|
||||
import { app } from '../../scripts/app.js'
|
||||
|
||||
function legacyWidget(node, inputName, inputData) {
|
||||
if (!node.widgets) node.widgets = []
|
||||
node.widgets.push({
|
||||
draw: function (ctx, node, widget_width, y, H) {
|
||||
ctx.save()
|
||||
ctx.fillStyle = '#7F7'
|
||||
ctx.fillRect(15, y, widget_width - 15 * 2, H)
|
||||
ctx.restore()
|
||||
},
|
||||
mouse: function mouseAnnotated(event, [x, y], node) {
|
||||
const widget_width = this.width || node.size[0]
|
||||
if (x < 30) {
|
||||
this.value--
|
||||
} else if (x > widget_width - 30 && x < widget_width) {
|
||||
this.value++
|
||||
}
|
||||
},
|
||||
name: inputName,
|
||||
options: {},
|
||||
type: 'DEVTOOLS.LEGACYWIDGET',
|
||||
value: 0,
|
||||
y: 0
|
||||
})
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: 'DevTools.LegacyWidget',
|
||||
async getCustomWidgets() {
|
||||
return { DEVTOOLSLEGACYWIDGET: legacyWidget }
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user