mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-13 09:16:20 +00:00
test(load3d): add unit tests for 9 previously-untested controls (#11730)
## Summary Mirror the maskeditor coverage approach for load3d sub-components. Each component gets behavior tests covering rendering, conditional branches, v-model bidirectional sync, and emitted events. - ViewerLightControls: setting-store min/max/step + v-model - ViewerExportControls: format dropdown + click-to-export - ViewerCameraControls: type select, FOV slider visibility - ViewerSceneControls: with/without bg image branches, render mode - PopupSlider: trigger toggle, click-outside dismissal, defaults - CameraControls: switch button, FOV PopupSlider visibility - ExportControls: trigger popup, format selection, click-outside - AnimationControls: empty-list bypass, controls, time formatting - ViewerControls: dialog open routing + onClose wiring ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11730-test-load3d-add-unit-tests-for-9-previously-untested-controls-3506d73d365081eaa9e7c5d0b922fc14) by [Unito](https://www.unito.io)
This commit is contained in:
153
src/components/load3d/Load3DScene.test.ts
Normal file
153
src/components/load3d/Load3DScene.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Load3DScene from '@/components/load3d/Load3DScene.vue'
|
||||
|
||||
const dragState = vi.hoisted(() => ({
|
||||
isDragging: null as Ref<boolean> | null,
|
||||
dragMessage: null as Ref<string> | null,
|
||||
handleDragOver: vi.fn(),
|
||||
handleDragLeave: vi.fn(),
|
||||
handleDrop: vi.fn(),
|
||||
capturedOptions: null as {
|
||||
onModelDrop?: (file: File) => Promise<void>
|
||||
disabled?: { value?: boolean } | boolean
|
||||
} | null
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3dDrag', () => ({
|
||||
useLoad3dDrag: (options: unknown) => {
|
||||
dragState.capturedOptions = options as typeof dragState.capturedOptions
|
||||
return {
|
||||
isDragging: dragState.isDragging!,
|
||||
dragMessage: dragState.dragMessage!,
|
||||
handleDragOver: dragState.handleDragOver,
|
||||
handleDragLeave: dragState.handleDragLeave,
|
||||
handleDrop: dragState.handleDrop
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/LoadingOverlay.vue', () => ({
|
||||
default: {
|
||||
name: 'LoadingOverlayStub',
|
||||
props: ['loading', 'loadingMessage'],
|
||||
template: `
|
||||
<div data-testid="loading-overlay">
|
||||
<span v-if="loading">{{ loadingMessage }}</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
type RenderOpts = {
|
||||
loading?: boolean
|
||||
loadingMessage?: string
|
||||
isPreview?: boolean
|
||||
onModelDrop?: (file: File) => void | Promise<void>
|
||||
initializeLoad3d?: (container: HTMLElement) => Promise<void>
|
||||
cleanup?: () => void
|
||||
}
|
||||
|
||||
function renderComponent(opts: RenderOpts = {}) {
|
||||
const initializeLoad3d =
|
||||
opts.initializeLoad3d ?? vi.fn().mockResolvedValue(undefined)
|
||||
const cleanup = opts.cleanup ?? vi.fn()
|
||||
|
||||
const utils = render(Load3DScene, {
|
||||
props: {
|
||||
initializeLoad3d,
|
||||
cleanup,
|
||||
loading: opts.loading ?? false,
|
||||
loadingMessage: opts.loadingMessage ?? '',
|
||||
onModelDrop: opts.onModelDrop,
|
||||
isPreview: opts.isPreview ?? false
|
||||
}
|
||||
})
|
||||
|
||||
return { ...utils, initializeLoad3d, cleanup }
|
||||
}
|
||||
|
||||
describe('Load3DScene', () => {
|
||||
beforeEach(() => {
|
||||
dragState.isDragging = ref(false)
|
||||
dragState.dragMessage = ref('')
|
||||
dragState.handleDragOver.mockReset()
|
||||
dragState.handleDragLeave.mockReset()
|
||||
dragState.handleDrop.mockReset()
|
||||
dragState.capturedOptions = null
|
||||
})
|
||||
|
||||
it('renders the loading overlay child', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('loading-overlay')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('forwards loading + loadingMessage props to the overlay', () => {
|
||||
renderComponent({ loading: true, loadingMessage: 'Loading model…' })
|
||||
|
||||
expect(screen.getByText('Loading model…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls initializeLoad3d with the container element on mount', async () => {
|
||||
const initializeLoad3d = vi.fn().mockResolvedValue(undefined)
|
||||
renderComponent({ initializeLoad3d })
|
||||
|
||||
expect(initializeLoad3d).toHaveBeenCalledOnce()
|
||||
expect(initializeLoad3d.mock.calls[0][0]).toBeInstanceOf(HTMLElement)
|
||||
})
|
||||
|
||||
it('calls cleanup when unmounted', () => {
|
||||
const cleanup = vi.fn()
|
||||
const { unmount } = renderComponent({ cleanup })
|
||||
|
||||
unmount()
|
||||
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not render the drag overlay when not dragging', () => {
|
||||
dragState.isDragging!.value = false
|
||||
dragState.dragMessage!.value = 'Drop'
|
||||
renderComponent()
|
||||
|
||||
expect(screen.queryByText('Drop')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the drag overlay with the drag message while dragging in non-preview mode', () => {
|
||||
dragState.isDragging!.value = true
|
||||
dragState.dragMessage!.value = 'Drop to load model'
|
||||
renderComponent({ isPreview: false })
|
||||
|
||||
expect(screen.getByText('Drop to load model')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the drag overlay even while dragging when in preview mode', () => {
|
||||
dragState.isDragging!.value = true
|
||||
dragState.dragMessage!.value = 'Drop to load model'
|
||||
renderComponent({ isPreview: true })
|
||||
|
||||
expect(screen.queryByText('Drop to load model')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('forwards a dropped file through useLoad3dDrag to the onModelDrop prop', async () => {
|
||||
const onModelDrop = vi.fn()
|
||||
renderComponent({ onModelDrop })
|
||||
|
||||
const file = new File(['m'], 'model.glb')
|
||||
await dragState.capturedOptions!.onModelDrop!(file)
|
||||
|
||||
expect(onModelDrop).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('does not throw when a file is dropped without an onModelDrop handler', async () => {
|
||||
renderComponent({ onModelDrop: undefined })
|
||||
|
||||
const file = new File(['m'], 'model.glb')
|
||||
await expect(
|
||||
dragState.capturedOptions!.onModelDrop!(file)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
class NoopMutationObserver {
|
||||
observe() {}
|
||||
@@ -129,7 +130,7 @@ type RenderOptions = {
|
||||
dragOverrides?: Partial<ReturnType<typeof buildDragStub>>
|
||||
}
|
||||
|
||||
const MOCK_NODE = { id: 'node-1', type: 'Load3D' } as unknown as LGraphNode
|
||||
const MOCK_NODE = createMockLGraphNode({ id: 'node-1', type: 'Load3D' })
|
||||
|
||||
async function renderViewerContent(options: RenderOptions = {}) {
|
||||
const viewerStub = buildViewerStub()
|
||||
|
||||
205
src/components/load3d/controls/AnimationControls.test.ts
Normal file
205
src/components/load3d/controls/AnimationControls.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', isNaN(Number($event.target.value)) ? $event.target.value : Number($event.target.value))"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
|
||||
{{ opt[optionLabel] }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/slider/Slider.vue', () => ({
|
||||
default: {
|
||||
name: 'UiSlider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
role="slider"
|
||||
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', [Number($event.target.value)])"
|
||||
/>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { playPause: 'Play / pause' } } }
|
||||
})
|
||||
|
||||
type Animation = { name: string; index: number }
|
||||
|
||||
type RenderOpts = {
|
||||
animations?: Animation[]
|
||||
playing?: boolean
|
||||
selectedSpeed?: number
|
||||
selectedAnimation?: number
|
||||
animationProgress?: number
|
||||
animationDuration?: number
|
||||
onSeek?: (progress: number) => void
|
||||
}
|
||||
|
||||
function renderComponent(opts: RenderOpts = {}) {
|
||||
const animations = ref<Animation[]>(opts.animations ?? [])
|
||||
const playing = ref<boolean>(opts.playing ?? false)
|
||||
const selectedSpeed = ref<number>(opts.selectedSpeed ?? 1)
|
||||
const selectedAnimation = ref<number>(opts.selectedAnimation ?? 0)
|
||||
const animationProgress = ref<number>(opts.animationProgress ?? 0)
|
||||
const animationDuration = ref<number>(opts.animationDuration ?? 10)
|
||||
|
||||
const utils = render(AnimationControls, {
|
||||
props: {
|
||||
animations: animations.value,
|
||||
'onUpdate:animations': (v: Animation[] | undefined) => {
|
||||
if (v) animations.value = v
|
||||
},
|
||||
playing: playing.value,
|
||||
'onUpdate:playing': (v: boolean | undefined) => {
|
||||
if (v !== undefined) playing.value = v
|
||||
},
|
||||
selectedSpeed: selectedSpeed.value,
|
||||
'onUpdate:selectedSpeed': (v: number | undefined) => {
|
||||
if (v !== undefined) selectedSpeed.value = v
|
||||
},
|
||||
selectedAnimation: selectedAnimation.value,
|
||||
'onUpdate:selectedAnimation': (v: number | undefined) => {
|
||||
if (v !== undefined) selectedAnimation.value = v
|
||||
},
|
||||
animationProgress: animationProgress.value,
|
||||
'onUpdate:animationProgress': (v: number | undefined) => {
|
||||
if (v !== undefined) animationProgress.value = v
|
||||
},
|
||||
animationDuration: animationDuration.value,
|
||||
'onUpdate:animationDuration': (v: number | undefined) => {
|
||||
if (v !== undefined) animationDuration.value = v
|
||||
},
|
||||
onSeek: opts.onSeek
|
||||
},
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
return {
|
||||
...utils,
|
||||
animations,
|
||||
playing,
|
||||
selectedSpeed,
|
||||
selectedAnimation,
|
||||
animationProgress,
|
||||
user: userEvent.setup()
|
||||
}
|
||||
}
|
||||
|
||||
describe('AnimationControls', () => {
|
||||
it('renders nothing when the animation list is empty', () => {
|
||||
renderComponent({ animations: [] })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Play / pause' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the play / speed / track / progress widgets when animations are present', () => {
|
||||
renderComponent({
|
||||
animations: [
|
||||
{ name: 'idle', index: 0 },
|
||||
{ name: 'walk', index: 1 }
|
||||
]
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Play / pause' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(2)
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('flips playing to true via v-model when starting from a paused state', async () => {
|
||||
const { user, playing } = renderComponent({
|
||||
animations: [{ name: 'idle', index: 0 }],
|
||||
playing: false
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Play / pause' }))
|
||||
|
||||
expect(playing.value).toBe(true)
|
||||
})
|
||||
|
||||
it('flips playing to false via v-model when starting from a playing state', async () => {
|
||||
const { user, playing } = renderComponent({
|
||||
animations: [{ name: 'idle', index: 0 }],
|
||||
playing: true
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Play / pause' }))
|
||||
|
||||
expect(playing.value).toBe(false)
|
||||
})
|
||||
|
||||
it('updates animationProgress and emits seek with the new progress when the slider moves', () => {
|
||||
const onSeek = vi.fn()
|
||||
const { animationProgress } = renderComponent({
|
||||
animations: [{ name: 'idle', index: 0 }],
|
||||
animationProgress: 0,
|
||||
onSeek
|
||||
})
|
||||
|
||||
const slider = screen.getByRole('slider') as HTMLInputElement
|
||||
slider.value = '37.5'
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
expect(animationProgress.value).toBe(37.5)
|
||||
expect(onSeek).toHaveBeenCalledWith(37.5)
|
||||
})
|
||||
|
||||
it('formats the time display under one minute as Ns', () => {
|
||||
renderComponent({
|
||||
animations: [{ name: 'idle', index: 0 }],
|
||||
animationDuration: 30,
|
||||
animationProgress: 50 // half of 30s = 15s
|
||||
})
|
||||
|
||||
expect(screen.getByText('15.0s / 30.0s')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('formats the time display over one minute as M:SS.S', () => {
|
||||
renderComponent({
|
||||
animations: [{ name: 'idle', index: 0 }],
|
||||
animationDuration: 90,
|
||||
animationProgress: 50 // half of 90s = 45s, total 1:30.0
|
||||
})
|
||||
|
||||
expect(screen.getByText('45.0s / 1:30.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows 0s for currentTime when animationDuration is 0', () => {
|
||||
renderComponent({
|
||||
animations: [{ name: 'idle', index: 0 }],
|
||||
animationDuration: 0,
|
||||
animationProgress: 50
|
||||
})
|
||||
|
||||
expect(screen.getByText('0.0s / 0.0s')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
84
src/components/load3d/controls/CameraControls.test.ts
Normal file
84
src/components/load3d/controls/CameraControls.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
vi.mock('@/components/load3d/controls/PopupSlider.vue', () => ({
|
||||
default: {
|
||||
name: 'PopupSlider',
|
||||
props: ['tooltipText', 'modelValue'],
|
||||
template: '<div data-testid="popup-slider">{{ tooltipText }}</div>'
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: { load3d: { switchCamera: 'Switch camera', fov: 'FOV' } }
|
||||
}
|
||||
})
|
||||
|
||||
function renderComponent(initial: { type?: CameraType; fov?: number } = {}) {
|
||||
const cameraType = ref<CameraType>(initial.type ?? 'perspective')
|
||||
const fov = ref<number>(initial.fov ?? 75)
|
||||
|
||||
const utils = render(CameraControls, {
|
||||
props: {
|
||||
cameraType: cameraType.value,
|
||||
'onUpdate:cameraType': (v: CameraType | undefined) => {
|
||||
if (v) cameraType.value = v
|
||||
},
|
||||
fov: fov.value,
|
||||
'onUpdate:fov': (v: number | undefined) => {
|
||||
if (v !== undefined) fov.value = v
|
||||
}
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
return { ...utils, cameraType, fov, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('CameraControls', () => {
|
||||
it('renders the switch-camera button', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Switch camera' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the FOV PopupSlider only for the perspective camera', () => {
|
||||
renderComponent({ type: 'perspective' })
|
||||
expect(screen.getByTestId('popup-slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the FOV PopupSlider for the orthographic camera', () => {
|
||||
renderComponent({ type: 'orthographic' })
|
||||
expect(screen.queryByTestId('popup-slider')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles cameraType from perspective to orthographic when the button is clicked', async () => {
|
||||
const { user, cameraType } = renderComponent({ type: 'perspective' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Switch camera' }))
|
||||
|
||||
expect(cameraType.value).toBe('orthographic')
|
||||
})
|
||||
|
||||
it('toggles cameraType from orthographic to perspective when the button is clicked', async () => {
|
||||
const { user, cameraType } = renderComponent({ type: 'orthographic' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Switch camera' }))
|
||||
|
||||
expect(cameraType.value).toBe('perspective')
|
||||
})
|
||||
})
|
||||
78
src/components/load3d/controls/ExportControls.test.ts
Normal file
78
src/components/load3d/controls/ExportControls.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: { load3d: { exportModel: 'Export model' } }
|
||||
}
|
||||
})
|
||||
|
||||
function renderComponent(onExportModel?: (format: string) => void) {
|
||||
const utils = render(ExportControls, {
|
||||
props: { onExportModel },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
return { ...utils, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('ExportControls', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('renders the trigger button without exposing the format list initially', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Export model' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'GLB' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reveals all three export format buttons when the trigger is clicked', async () => {
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export model' }))
|
||||
|
||||
for (const label of ['GLB', 'OBJ', 'STL']) {
|
||||
expect(screen.getByRole('button', { name: label })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
it('emits exportModel with the chosen format and hides the popup', async () => {
|
||||
const onExportModel = vi.fn()
|
||||
const { user } = renderComponent(onExportModel)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export model' }))
|
||||
await user.click(screen.getByRole('button', { name: 'OBJ' }))
|
||||
|
||||
expect(onExportModel).toHaveBeenCalledWith('obj')
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'GLB' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the popup when a click happens outside the trigger', async () => {
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export model' }))
|
||||
expect(screen.getByRole('button', { name: 'GLB' })).toBeVisible()
|
||||
|
||||
await user.click(document.body)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'GLB' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
197
src/components/load3d/controls/HDRIControls.test.ts
Normal file
197
src/components/load3d/controls/HDRIControls.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- hidden file input has no role/label, queried by selector */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import HDRIControls from '@/components/load3d/controls/HDRIControls.vue'
|
||||
import type { HDRIConfig } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const addAlert = vi.fn()
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ addAlert })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
load3d: {
|
||||
hdri: {
|
||||
label: 'HDRI',
|
||||
uploadFile: 'Upload HDRI',
|
||||
changeFile: 'Change HDRI',
|
||||
showAsBackground: 'Show as background',
|
||||
removeFile: 'Remove HDRI'
|
||||
}
|
||||
},
|
||||
toastMessages: { unsupportedHDRIFormat: 'Unsupported HDRI format' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const defaultConfig: HDRIConfig = {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
|
||||
type RenderOpts = {
|
||||
config?: HDRIConfig
|
||||
hasBackgroundImage?: boolean
|
||||
onUpdateHdriFile?: (file: File | null) => void
|
||||
}
|
||||
|
||||
function renderComponent(opts: RenderOpts = {}) {
|
||||
const config = ref<HDRIConfig>(opts.config ?? { ...defaultConfig })
|
||||
|
||||
const utils = render(HDRIControls, {
|
||||
props: {
|
||||
hdriConfig: config.value,
|
||||
'onUpdate:hdriConfig': (v: HDRIConfig | undefined) => {
|
||||
if (v) config.value = v
|
||||
},
|
||||
hasBackgroundImage: opts.hasBackgroundImage ?? false,
|
||||
onUpdateHdriFile: opts.onUpdateHdriFile
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
return { ...utils, config, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('HDRIControls', () => {
|
||||
beforeEach(() => {
|
||||
addAlert.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('initial render', () => {
|
||||
it('renders the upload button when no HDRI is loaded', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Upload HDRI' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'HDRI' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the change / toggle / show-as-bg / remove buttons when an HDRI is loaded', () => {
|
||||
renderComponent({
|
||||
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' }
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Change HDRI' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'HDRI' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show as background' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Remove HDRI' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the entire control when a background image is set and no HDRI is loaded', () => {
|
||||
const { container } = renderComponent({
|
||||
hasBackgroundImage: true,
|
||||
config: { ...defaultConfig, hdriPath: '' }
|
||||
})
|
||||
|
||||
expect(container.querySelector('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('still renders when a background image is set but an HDRI is loaded', () => {
|
||||
renderComponent({
|
||||
hasBackgroundImage: true,
|
||||
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' }
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Change HDRI' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggle buttons', () => {
|
||||
it('flips enabled in the v-model when the HDRI button is clicked', async () => {
|
||||
const { user, config } = renderComponent({
|
||||
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' }
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'HDRI' }))
|
||||
|
||||
expect(config.value.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('flips showAsBackground in the v-model when the show-as-background button is clicked', async () => {
|
||||
const { user, config } = renderComponent({
|
||||
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' }
|
||||
})
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Show as background' })
|
||||
)
|
||||
|
||||
expect(config.value.showAsBackground).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('file events', () => {
|
||||
it('emits updateHdriFile(null) when the remove button is clicked', async () => {
|
||||
const onUpdateHdriFile = vi.fn()
|
||||
const { user } = renderComponent({
|
||||
config: { ...defaultConfig, hdriPath: '/api/hdri/test.hdr' },
|
||||
onUpdateHdriFile
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Remove HDRI' }))
|
||||
|
||||
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('emits updateHdriFile with the picked file when its extension is supported', async () => {
|
||||
const onUpdateHdriFile = vi.fn()
|
||||
const { container } = renderComponent({ onUpdateHdriFile })
|
||||
|
||||
const fileInput = container.querySelector(
|
||||
'input[type="file"]'
|
||||
) as HTMLInputElement
|
||||
const file = new File(['hdri-data'], 'sky.hdr', {
|
||||
type: 'application/octet-stream'
|
||||
})
|
||||
Object.defineProperty(fileInput, 'files', { value: [file] })
|
||||
fileInput.dispatchEvent(new Event('change'))
|
||||
|
||||
expect(onUpdateHdriFile).toHaveBeenCalledWith(file)
|
||||
expect(addAlert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects unsupported file extensions with a toast and no emit', async () => {
|
||||
const onUpdateHdriFile = vi.fn()
|
||||
const { container } = renderComponent({ onUpdateHdriFile })
|
||||
|
||||
const fileInput = container.querySelector(
|
||||
'input[type="file"]'
|
||||
) as HTMLInputElement
|
||||
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' })
|
||||
Object.defineProperty(fileInput, 'files', { value: [file] })
|
||||
fileInput.dispatchEvent(new Event('change'))
|
||||
|
||||
expect(onUpdateHdriFile).not.toHaveBeenCalled()
|
||||
expect(addAlert).toHaveBeenCalledWith('Unsupported HDRI format')
|
||||
})
|
||||
})
|
||||
})
|
||||
193
src/components/load3d/controls/LightControls.test.ts
Normal file
193
src/components/load3d/controls/LightControls.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import LightControls from '@/components/load3d/controls/LightControls.vue'
|
||||
import type {
|
||||
HDRIConfig,
|
||||
MaterialMode
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const settingValues: Record<string, unknown> = {
|
||||
'Comfy.Load3D.LightIntensityMaximum': 10,
|
||||
'Comfy.Load3D.LightIntensityMinimum': 1,
|
||||
'Comfy.Load3D.LightAdjustmentIncrement': 0.5
|
||||
}
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => settingValues[key]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useDismissableOverlay', () => ({
|
||||
useDismissableOverlay: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/slider/Slider.vue', () => ({
|
||||
default: {
|
||||
name: 'UiSlider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
role="slider"
|
||||
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', [Number($event.target.value)])"
|
||||
/>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { load3d: { lightIntensity: 'Light intensity' } } }
|
||||
})
|
||||
|
||||
type RenderOpts = {
|
||||
lightIntensity?: number
|
||||
materialMode?: MaterialMode
|
||||
hdriConfig?: HDRIConfig
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
function renderComponent(opts: RenderOpts = {}) {
|
||||
const lightIntensity = ref<number>(opts.lightIntensity ?? 5)
|
||||
const materialMode = ref<MaterialMode>(opts.materialMode ?? 'original')
|
||||
const hdriConfig = ref<HDRIConfig | undefined>(opts.hdriConfig)
|
||||
|
||||
const utils = render(LightControls, {
|
||||
props: {
|
||||
lightIntensity: lightIntensity.value,
|
||||
'onUpdate:lightIntensity': (v: number | undefined) => {
|
||||
if (v !== undefined) lightIntensity.value = v
|
||||
},
|
||||
materialMode: materialMode.value,
|
||||
'onUpdate:materialMode': (v: MaterialMode | undefined) => {
|
||||
if (v) materialMode.value = v
|
||||
},
|
||||
hdriConfig: hdriConfig.value,
|
||||
'onUpdate:hdriConfig': (v: HDRIConfig | undefined) => {
|
||||
hdriConfig.value = v
|
||||
},
|
||||
embedded: opts.embedded ?? false
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...utils,
|
||||
lightIntensity,
|
||||
hdriConfig,
|
||||
user: userEvent.setup()
|
||||
}
|
||||
}
|
||||
|
||||
describe('LightControls', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('material mode gating', () => {
|
||||
it('renders the intensity control when materialMode is original', () => {
|
||||
renderComponent({ materialMode: 'original' })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Light intensity' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['normal', 'wireframe'] as const)(
|
||||
'hides the intensity control when materialMode is %s',
|
||||
(mode) => {
|
||||
renderComponent({ materialMode: mode })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Light intensity' })
|
||||
).not.toBeInTheDocument()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('default (non-HDRI) mode', () => {
|
||||
it('feeds the slider with the setting-store min / max / step', async () => {
|
||||
const { user } = renderComponent({ lightIntensity: 5 })
|
||||
await user.click(screen.getByRole('button', { name: 'Light intensity' }))
|
||||
|
||||
const slider = screen.getByRole('slider') as HTMLInputElement
|
||||
expect(slider.min).toBe('1')
|
||||
expect(slider.max).toBe('10')
|
||||
expect(slider.step).toBe('0.5')
|
||||
})
|
||||
|
||||
it('updates lightIntensity v-model when the slider changes', async () => {
|
||||
const { user, lightIntensity } = renderComponent({ lightIntensity: 5 })
|
||||
await user.click(screen.getByRole('button', { name: 'Light intensity' }))
|
||||
|
||||
const slider = screen.getByRole('slider') as HTMLInputElement
|
||||
slider.value = '7.5'
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
expect(lightIntensity.value).toBe(7.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HDRI active mode', () => {
|
||||
const hdriConfig: HDRIConfig = {
|
||||
enabled: true,
|
||||
hdriPath: '/api/hdri/test.hdr',
|
||||
showAsBackground: false,
|
||||
intensity: 2
|
||||
}
|
||||
|
||||
it('reads the slider min / max / step from the HDRI range (0..5 step 0.1)', async () => {
|
||||
const { user } = renderComponent({ hdriConfig })
|
||||
await user.click(screen.getByRole('button', { name: 'Light intensity' }))
|
||||
|
||||
const slider = screen.getByRole('slider') as HTMLInputElement
|
||||
expect(slider.min).toBe('0')
|
||||
expect(slider.max).toBe('5')
|
||||
expect(slider.step).toBe('0.1')
|
||||
})
|
||||
|
||||
it('writes back to hdriConfig.intensity instead of lightIntensity when the slider changes', async () => {
|
||||
const {
|
||||
user,
|
||||
lightIntensity,
|
||||
hdriConfig: cfg
|
||||
} = renderComponent({
|
||||
lightIntensity: 5,
|
||||
hdriConfig
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: 'Light intensity' }))
|
||||
|
||||
const slider = screen.getByRole('slider') as HTMLInputElement
|
||||
slider.value = '3.5'
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
expect(cfg.value?.intensity).toBe(3.5)
|
||||
expect(lightIntensity.value).toBe(5) // unchanged
|
||||
})
|
||||
})
|
||||
|
||||
describe('embedded mode', () => {
|
||||
it('renders the slider inline without the trigger button when embedded is true', () => {
|
||||
renderComponent({ embedded: true })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Light intensity' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
185
src/components/load3d/controls/ModelControls.test.ts
Normal file
185
src/components/load3d/controls/ModelControls.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
|
||||
import type {
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
load3d: {
|
||||
upDirection: 'Up direction',
|
||||
materialMode: 'Material mode',
|
||||
showSkeleton: 'Show skeleton',
|
||||
materialModes: {
|
||||
original: 'Original',
|
||||
normal: 'Normal',
|
||||
wireframe: 'Wireframe',
|
||||
pointCloud: 'Point cloud',
|
||||
depth: 'Depth'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type RenderOpts = {
|
||||
upDirection?: UpDirection
|
||||
materialMode?: MaterialMode
|
||||
showSkeleton?: boolean
|
||||
materialModes?: readonly MaterialMode[]
|
||||
hasSkeleton?: boolean
|
||||
}
|
||||
|
||||
function renderComponent(opts: RenderOpts = {}) {
|
||||
const upDirection = ref<UpDirection>(opts.upDirection ?? 'original')
|
||||
const materialMode = ref<MaterialMode>(opts.materialMode ?? 'original')
|
||||
const showSkeleton = ref<boolean>(opts.showSkeleton ?? false)
|
||||
|
||||
const utils = render(ModelControls, {
|
||||
props: {
|
||||
upDirection: upDirection.value,
|
||||
'onUpdate:upDirection': (v: UpDirection | undefined) => {
|
||||
if (v) upDirection.value = v
|
||||
},
|
||||
materialMode: materialMode.value,
|
||||
'onUpdate:materialMode': (v: MaterialMode | undefined) => {
|
||||
if (v) materialMode.value = v
|
||||
},
|
||||
showSkeleton: showSkeleton.value,
|
||||
'onUpdate:showSkeleton': (v: boolean | undefined) => {
|
||||
if (v !== undefined) showSkeleton.value = v
|
||||
},
|
||||
materialModes: opts.materialModes ?? ['original', 'normal', 'wireframe'],
|
||||
hasSkeleton: opts.hasSkeleton ?? false
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...utils,
|
||||
upDirection,
|
||||
materialMode,
|
||||
showSkeleton,
|
||||
user: userEvent.setup()
|
||||
}
|
||||
}
|
||||
|
||||
describe('ModelControls', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('up direction', () => {
|
||||
it('renders the up-direction trigger and opens the popup with all 7 directions', async () => {
|
||||
const { user } = renderComponent()
|
||||
await user.click(screen.getByRole('button', { name: 'Up direction' }))
|
||||
|
||||
for (const label of ['ORIGINAL', '-X', '+X', '-Y', '+Y', '-Z', '+Z']) {
|
||||
expect(screen.getByRole('button', { name: label })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
it('updates upDirection v-model when a direction is selected', async () => {
|
||||
const { user, upDirection } = renderComponent()
|
||||
await user.click(screen.getByRole('button', { name: 'Up direction' }))
|
||||
await user.click(screen.getByRole('button', { name: '+X' }))
|
||||
|
||||
expect(upDirection.value).toBe('+x')
|
||||
})
|
||||
})
|
||||
|
||||
describe('material mode', () => {
|
||||
it('renders the material-mode trigger when materialModes is non-empty', () => {
|
||||
renderComponent({ materialModes: ['original', 'normal'] })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Material mode' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the material-mode trigger when materialModes is empty', () => {
|
||||
renderComponent({ materialModes: [] })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Material mode' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders one popup option per entry in materialModes', async () => {
|
||||
const { user } = renderComponent({
|
||||
materialModes: ['original', 'pointCloud', 'normal', 'wireframe']
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: 'Material mode' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Original' })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: 'Point cloud' })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: 'Normal' })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: 'Wireframe' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('updates materialMode v-model when a mode is selected', async () => {
|
||||
const { user, materialMode } = renderComponent({
|
||||
materialModes: ['original', 'normal']
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: 'Material mode' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Normal' }))
|
||||
|
||||
expect(materialMode.value).toBe('normal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('skeleton', () => {
|
||||
it('hides the skeleton button when hasSkeleton is false', () => {
|
||||
renderComponent({ hasSkeleton: false })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show skeleton' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the skeleton button when hasSkeleton is true', () => {
|
||||
renderComponent({ hasSkeleton: true })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show skeleton' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('flips showSkeleton v-model when the skeleton button is clicked', async () => {
|
||||
const { user, showSkeleton } = renderComponent({
|
||||
hasSkeleton: true,
|
||||
showSkeleton: false
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: 'Show skeleton' }))
|
||||
|
||||
expect(showSkeleton.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('popup mutual exclusion', () => {
|
||||
it('closes the up-direction popup when the material-mode trigger is clicked', async () => {
|
||||
const { user } = renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Up direction' }))
|
||||
expect(screen.getByRole('button', { name: 'ORIGINAL' })).toBeVisible()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Material mode' }))
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'ORIGINAL' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
126
src/components/load3d/controls/PopupSlider.test.ts
Normal file
126
src/components/load3d/controls/PopupSlider.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
|
||||
|
||||
vi.mock('primevue/slider', () => ({
|
||||
default: {
|
||||
name: 'Slider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
role="slider"
|
||||
:value="modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', Number($event.target.value))"
|
||||
/>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
function renderComponent(
|
||||
props: {
|
||||
tooltipText?: string
|
||||
icon?: string
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
initial?: number
|
||||
} = {}
|
||||
) {
|
||||
const value = ref<number>(props.initial ?? 50)
|
||||
const utils = render(PopupSlider, {
|
||||
props: {
|
||||
tooltipText: props.tooltipText ?? 'FOV',
|
||||
icon: props.icon,
|
||||
min: props.min,
|
||||
max: props.max,
|
||||
step: props.step,
|
||||
modelValue: value.value,
|
||||
'onUpdate:modelValue': (v: number | undefined) => {
|
||||
if (v !== undefined) value.value = v
|
||||
}
|
||||
},
|
||||
global: {
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
return { ...utils, value, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('PopupSlider', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('keeps the slider hidden from the accessibility tree until the trigger is clicked', () => {
|
||||
renderComponent({ tooltipText: 'FOV' })
|
||||
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reveals the slider when the trigger is clicked and hides it again on a second click', async () => {
|
||||
const { user } = renderComponent({ tooltipText: 'FOV' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'FOV' }))
|
||||
expect(screen.getByRole('slider')).toBeVisible()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'FOV' }))
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the slider when the user clicks outside the popup', async () => {
|
||||
const { user } = renderComponent({ tooltipText: 'FOV' })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'FOV' }))
|
||||
expect(screen.getByRole('slider')).toBeVisible()
|
||||
|
||||
await user.click(document.body)
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('forwards default min / max / step (10 / 150 / 1) when none are provided', async () => {
|
||||
const { user } = renderComponent({ tooltipText: 'FOV' })
|
||||
await user.click(screen.getByRole('button', { name: 'FOV' }))
|
||||
const slider = screen.getByRole('slider') as HTMLInputElement
|
||||
|
||||
expect(slider.min).toBe('10')
|
||||
expect(slider.max).toBe('150')
|
||||
expect(slider.step).toBe('1')
|
||||
})
|
||||
|
||||
it('uses caller-provided min / max / step over the defaults', async () => {
|
||||
const { user } = renderComponent({
|
||||
tooltipText: 'Light',
|
||||
min: 0,
|
||||
max: 5,
|
||||
step: 0.25
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: 'Light' }))
|
||||
const slider = screen.getByRole('slider') as HTMLInputElement
|
||||
|
||||
expect(slider.min).toBe('0')
|
||||
expect(slider.max).toBe('5')
|
||||
expect(slider.step).toBe('0.25')
|
||||
})
|
||||
|
||||
it('updates the v-model when the slider value changes', async () => {
|
||||
const { user, value } = renderComponent({
|
||||
tooltipText: 'FOV',
|
||||
initial: 50
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: 'FOV' }))
|
||||
const slider = screen.getByRole('slider') as HTMLInputElement
|
||||
|
||||
slider.value = '120'
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
expect(value.value).toBe(120)
|
||||
})
|
||||
})
|
||||
205
src/components/load3d/controls/RecordingControls.test.ts
Normal file
205
src/components/load3d/controls/RecordingControls.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
load3d: {
|
||||
startRecording: 'Start recording',
|
||||
stopRecording: 'Stop recording',
|
||||
exportRecording: 'Export recording',
|
||||
clearRecording: 'Clear recording'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type RenderOpts = {
|
||||
hasRecording?: boolean
|
||||
isRecording?: boolean
|
||||
recordingDuration?: number
|
||||
onStartRecording?: () => void
|
||||
onStopRecording?: () => void
|
||||
onExportRecording?: () => void
|
||||
onClearRecording?: () => void
|
||||
}
|
||||
|
||||
function renderComponent(opts: RenderOpts = {}) {
|
||||
const hasRecording = ref<boolean>(opts.hasRecording ?? false)
|
||||
const isRecording = ref<boolean>(opts.isRecording ?? false)
|
||||
const recordingDuration = ref<number>(opts.recordingDuration ?? 0)
|
||||
|
||||
const utils = render(RecordingControls, {
|
||||
props: {
|
||||
hasRecording: hasRecording.value,
|
||||
'onUpdate:hasRecording': (v: boolean | undefined) => {
|
||||
if (v !== undefined) hasRecording.value = v
|
||||
},
|
||||
isRecording: isRecording.value,
|
||||
'onUpdate:isRecording': (v: boolean | undefined) => {
|
||||
if (v !== undefined) isRecording.value = v
|
||||
},
|
||||
recordingDuration: recordingDuration.value,
|
||||
'onUpdate:recordingDuration': (v: number | undefined) => {
|
||||
if (v !== undefined) recordingDuration.value = v
|
||||
},
|
||||
onStartRecording: opts.onStartRecording,
|
||||
onStopRecording: opts.onStopRecording,
|
||||
onExportRecording: opts.onExportRecording,
|
||||
onClearRecording: opts.onClearRecording
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
return { ...utils, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('RecordingControls', () => {
|
||||
it('shows the start-recording button initially', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Start recording' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Stop recording' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the stop-recording button while recording is in progress', () => {
|
||||
renderComponent({ isRecording: true })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Stop recording' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Start recording' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits startRecording when the button is clicked from a stopped state', async () => {
|
||||
const onStartRecording = vi.fn()
|
||||
const onStopRecording = vi.fn()
|
||||
const { user } = renderComponent({
|
||||
isRecording: false,
|
||||
onStartRecording,
|
||||
onStopRecording
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Start recording' }))
|
||||
|
||||
expect(onStartRecording).toHaveBeenCalledOnce()
|
||||
expect(onStopRecording).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits stopRecording when the button is clicked from a recording state', async () => {
|
||||
const onStartRecording = vi.fn()
|
||||
const onStopRecording = vi.fn()
|
||||
const { user } = renderComponent({
|
||||
isRecording: true,
|
||||
onStartRecording,
|
||||
onStopRecording
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Stop recording' }))
|
||||
|
||||
expect(onStopRecording).toHaveBeenCalledOnce()
|
||||
expect(onStartRecording).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the export and clear buttons when there is no recording', () => {
|
||||
renderComponent({ hasRecording: false, isRecording: false })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Export recording' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Clear recording' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the export and clear buttons once a recording exists', () => {
|
||||
renderComponent({ hasRecording: true, isRecording: false })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Export recording' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Clear recording' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the export and clear buttons during a new recording even if a previous one exists', () => {
|
||||
renderComponent({ hasRecording: true, isRecording: true })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Export recording' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Clear recording' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits exportRecording and clearRecording from their respective buttons', async () => {
|
||||
const onExportRecording = vi.fn()
|
||||
const onClearRecording = vi.fn()
|
||||
const { user } = renderComponent({
|
||||
hasRecording: true,
|
||||
isRecording: false,
|
||||
onExportRecording,
|
||||
onClearRecording
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export recording' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Clear recording' }))
|
||||
|
||||
expect(onExportRecording).toHaveBeenCalledOnce()
|
||||
expect(onClearRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders the formatted duration as MM:SS once a recording exists', () => {
|
||||
renderComponent({
|
||||
hasRecording: true,
|
||||
isRecording: false,
|
||||
recordingDuration: 75
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('load3d-recording-duration')).toHaveTextContent(
|
||||
'01:15'
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the duration display while a recording is in progress', () => {
|
||||
renderComponent({
|
||||
hasRecording: true,
|
||||
isRecording: true,
|
||||
recordingDuration: 30
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('load3d-recording-duration')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the duration display when recordingDuration is zero', () => {
|
||||
renderComponent({
|
||||
hasRecording: true,
|
||||
isRecording: false,
|
||||
recordingDuration: 0
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('load3d-recording-duration')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
231
src/components/load3d/controls/SceneControls.test.ts
Normal file
231
src/components/load3d/controls/SceneControls.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- hidden color/file inputs have no role/label, queried by selector */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SceneControls from '@/components/load3d/controls/SceneControls.vue'
|
||||
|
||||
vi.mock('@/components/load3d/controls/PopupSlider.vue', () => ({
|
||||
default: {
|
||||
name: 'PopupSliderStub',
|
||||
props: ['tooltipText', 'modelValue'],
|
||||
template: '<div data-testid="fov-popup-slider">{{ tooltipText }}</div>'
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
load3d: {
|
||||
showGrid: 'Show grid',
|
||||
backgroundColor: 'Background color',
|
||||
uploadBackgroundImage: 'Upload background image',
|
||||
panoramaMode: 'Panorama mode',
|
||||
removeBackgroundImage: 'Remove background image',
|
||||
fov: 'FOV'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type RenderOpts = {
|
||||
showGrid?: boolean
|
||||
backgroundColor?: string
|
||||
backgroundImage?: string
|
||||
backgroundRenderMode?: 'tiled' | 'panorama'
|
||||
fov?: number
|
||||
hdriActive?: boolean
|
||||
onUpdateBackgroundImage?: (file: File | null) => void
|
||||
}
|
||||
|
||||
function renderComponent(opts: RenderOpts = {}) {
|
||||
const showGrid = ref<boolean>(opts.showGrid ?? true)
|
||||
const backgroundColor = ref<string>(opts.backgroundColor ?? '#000000')
|
||||
const backgroundImage = ref<string>(opts.backgroundImage ?? '')
|
||||
const backgroundRenderMode = ref<'tiled' | 'panorama'>(
|
||||
opts.backgroundRenderMode ?? 'tiled'
|
||||
)
|
||||
const fov = ref<number>(opts.fov ?? 75)
|
||||
|
||||
const utils = render(SceneControls, {
|
||||
props: {
|
||||
showGrid: showGrid.value,
|
||||
'onUpdate:showGrid': (v: boolean | undefined) => {
|
||||
if (v !== undefined) showGrid.value = v
|
||||
},
|
||||
backgroundColor: backgroundColor.value,
|
||||
'onUpdate:backgroundColor': (v: string | undefined) => {
|
||||
if (v !== undefined) backgroundColor.value = v
|
||||
},
|
||||
backgroundImage: backgroundImage.value,
|
||||
'onUpdate:backgroundImage': (v: string | undefined) => {
|
||||
if (v !== undefined) backgroundImage.value = v
|
||||
},
|
||||
backgroundRenderMode: backgroundRenderMode.value,
|
||||
'onUpdate:backgroundRenderMode': (
|
||||
v: 'tiled' | 'panorama' | undefined
|
||||
) => {
|
||||
if (v) backgroundRenderMode.value = v
|
||||
},
|
||||
fov: fov.value,
|
||||
'onUpdate:fov': (v: number | undefined) => {
|
||||
if (v !== undefined) fov.value = v
|
||||
},
|
||||
hdriActive: opts.hdriActive ?? false,
|
||||
onUpdateBackgroundImage: opts.onUpdateBackgroundImage
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...utils,
|
||||
showGrid,
|
||||
backgroundColor,
|
||||
backgroundRenderMode,
|
||||
user: userEvent.setup()
|
||||
}
|
||||
}
|
||||
|
||||
describe('SceneControls', () => {
|
||||
describe('grid', () => {
|
||||
it('flips showGrid via v-model when the grid button is clicked', async () => {
|
||||
const { user, showGrid } = renderComponent({ showGrid: false })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show grid' }))
|
||||
|
||||
expect(showGrid.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hdriActive=true', () => {
|
||||
it('hides the background-color and upload buttons when HDRI is active', () => {
|
||||
renderComponent({ hdriActive: true, backgroundImage: '' })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Background color' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Upload background image' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('without a background image', () => {
|
||||
it('renders the background-color and upload buttons', () => {
|
||||
renderComponent({ backgroundImage: '' })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Background color' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Upload background image' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render the panorama / remove / FOV controls', () => {
|
||||
renderComponent({ backgroundImage: '' })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Panorama mode' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Remove background image' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('fov-popup-slider')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates backgroundColor v-model from the hidden color picker', async () => {
|
||||
const { backgroundColor, container } = renderComponent({
|
||||
backgroundImage: '',
|
||||
backgroundColor: '#000000'
|
||||
})
|
||||
|
||||
const colorInput = container.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
colorInput.value = '#ff0000'
|
||||
colorInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
expect(backgroundColor.value).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('emits updateBackgroundImage with the picked file', async () => {
|
||||
const onUpdateBackgroundImage = vi.fn()
|
||||
const { container } = renderComponent({
|
||||
backgroundImage: '',
|
||||
onUpdateBackgroundImage
|
||||
})
|
||||
|
||||
const fileInput = container.querySelector(
|
||||
'input[type="file"]'
|
||||
) as HTMLInputElement
|
||||
const file = new File(['data'], 'bg.png', { type: 'image/png' })
|
||||
Object.defineProperty(fileInput, 'files', { value: [file] })
|
||||
fileInput.dispatchEvent(new Event('change'))
|
||||
|
||||
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(file)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a background image', () => {
|
||||
it('renders the panorama and remove buttons', () => {
|
||||
renderComponent({ backgroundImage: 'bg.png' })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Panorama mode' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Remove background image' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles backgroundRenderMode between tiled and panorama on the panorama button', async () => {
|
||||
const { user, backgroundRenderMode } = renderComponent({
|
||||
backgroundImage: 'bg.png',
|
||||
backgroundRenderMode: 'tiled'
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Panorama mode' }))
|
||||
expect(backgroundRenderMode.value).toBe('panorama')
|
||||
})
|
||||
|
||||
it('hides the FOV PopupSlider in tiled mode', () => {
|
||||
renderComponent({
|
||||
backgroundImage: 'bg.png',
|
||||
backgroundRenderMode: 'tiled'
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('fov-popup-slider')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the FOV PopupSlider in panorama mode', () => {
|
||||
renderComponent({
|
||||
backgroundImage: 'bg.png',
|
||||
backgroundRenderMode: 'panorama'
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('fov-popup-slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits updateBackgroundImage(null) when the remove button is clicked', async () => {
|
||||
const onUpdateBackgroundImage = vi.fn()
|
||||
const { user } = renderComponent({
|
||||
backgroundImage: 'bg.png',
|
||||
onUpdateBackgroundImage
|
||||
})
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Remove background image' })
|
||||
)
|
||||
|
||||
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
99
src/components/load3d/controls/ViewerControls.test.ts
Normal file
99
src/components/load3d/controls/ViewerControls.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
const showDialog = vi.fn()
|
||||
const handleViewerClose = vi.fn()
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ showDialog })
|
||||
}))
|
||||
|
||||
vi.mock('@/services/load3dService', () => ({
|
||||
useLoad3dService: () => ({ handleViewerClose })
|
||||
}))
|
||||
|
||||
vi.mock('@/components/load3d/Load3dViewerContent.vue', () => ({
|
||||
default: { name: 'Load3DViewerContentStub', template: '<div />' }
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
load3d: {
|
||||
openIn3DViewer: 'Open in 3D viewer',
|
||||
viewer: { title: '3D viewer' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mockNode = createMockLGraphNode({ id: 'node-1' })
|
||||
|
||||
describe('ViewerControls', () => {
|
||||
beforeEach(() => {
|
||||
showDialog.mockClear()
|
||||
handleViewerClose.mockClear()
|
||||
})
|
||||
|
||||
it('renders the open-in-viewer button labeled by the localized aria-label', () => {
|
||||
render(ViewerControls, {
|
||||
props: { node: mockNode },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Open in 3D viewer' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the dialog with the provided node and viewer component when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(ViewerControls, {
|
||||
props: { node: mockNode },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Open in 3D viewer' }))
|
||||
|
||||
expect(showDialog).toHaveBeenCalledOnce()
|
||||
const callArgs = showDialog.mock.calls[0][0]
|
||||
expect(callArgs.key).toBe('global-load3d-viewer')
|
||||
expect(callArgs.title).toBe('3D viewer')
|
||||
expect(callArgs.component).toMatchObject({
|
||||
name: 'Load3DViewerContentStub'
|
||||
})
|
||||
expect(callArgs.props).toEqual({ node: mockNode })
|
||||
expect(callArgs.dialogComponentProps.maximizable).toBe(true)
|
||||
})
|
||||
|
||||
it('routes the dialog onClose handler through useLoad3dService.handleViewerClose with the node', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(ViewerControls, {
|
||||
props: { node: mockNode },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Open in 3D viewer' }))
|
||||
|
||||
const onClose = showDialog.mock.calls[0][0].dialogComponentProps.onClose
|
||||
await onClose()
|
||||
|
||||
expect(handleViewerClose).toHaveBeenCalledWith(mockNode)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ViewerCameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
|
||||
{{ opt[optionLabel] }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/slider', () => ({
|
||||
default: {
|
||||
name: 'Slider',
|
||||
props: ['modelValue', 'min', 'max', 'step', 'ariaLabel'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
:value="modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:aria-label="ariaLabel"
|
||||
@input="$emit('update:modelValue', Number($event.target.value))"
|
||||
/>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
load3d: {
|
||||
fov: 'FOV',
|
||||
viewer: { cameraType: 'Camera type' },
|
||||
cameraType: {
|
||||
perspective: 'Perspective',
|
||||
orthographic: 'Orthographic'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderComponent(initial: { type?: CameraType; fov?: number } = {}) {
|
||||
const cameraType = ref<CameraType>(initial.type ?? 'perspective')
|
||||
const fov = ref<number>(initial.fov ?? 75)
|
||||
|
||||
const utils = render(ViewerCameraControls, {
|
||||
props: {
|
||||
cameraType: cameraType.value,
|
||||
'onUpdate:cameraType': (v: CameraType | undefined) => {
|
||||
if (v) cameraType.value = v
|
||||
},
|
||||
fov: fov.value,
|
||||
'onUpdate:fov': (v: number | undefined) => {
|
||||
if (v !== undefined) fov.value = v
|
||||
}
|
||||
},
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
return { ...utils, cameraType, fov, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('ViewerCameraControls', () => {
|
||||
it('exposes both camera types in the dropdown', () => {
|
||||
renderComponent()
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement
|
||||
const options = Array.from(select.options).map((o) => o.value)
|
||||
|
||||
expect(options).toEqual(['perspective', 'orthographic'])
|
||||
})
|
||||
|
||||
it('shows the FOV slider when the camera is perspective', () => {
|
||||
renderComponent({ type: 'perspective' })
|
||||
|
||||
expect(screen.getByLabelText('FOV')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the FOV slider when the camera is orthographic', () => {
|
||||
renderComponent({ type: 'orthographic' })
|
||||
|
||||
expect(screen.queryByLabelText('FOV')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reveals the FOV slider when the camera type prop changes back to perspective', async () => {
|
||||
const { rerender } = renderComponent({ type: 'orthographic' })
|
||||
expect(screen.queryByLabelText('FOV')).not.toBeInTheDocument()
|
||||
|
||||
await rerender({ cameraType: 'perspective' })
|
||||
|
||||
expect(screen.getByLabelText('FOV')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates fov via v-model when the slider changes', () => {
|
||||
const { fov } = renderComponent({ type: 'perspective', fov: 60 })
|
||||
const slider = screen.getByLabelText('FOV') as HTMLInputElement
|
||||
|
||||
slider.value = '90'
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
expect(fov.value).toBe(90)
|
||||
})
|
||||
|
||||
it('updates cameraType via v-model when the dropdown changes', async () => {
|
||||
const { user, cameraType } = renderComponent({ type: 'perspective' })
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox'), 'orthographic')
|
||||
|
||||
expect(cameraType.value).toBe('orthographic')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ViewerExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
|
||||
|
||||
vi.mock('primevue/select', () => ({
|
||||
default: {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options', 'optionLabel', 'optionValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-for="opt in options" :key="opt[optionValue]" :value="opt[optionValue]">
|
||||
{{ opt[optionLabel] }}
|
||||
</option>
|
||||
</select>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { load3d: { export: 'Export' } } }
|
||||
})
|
||||
|
||||
function renderComponent(onExportModel?: (format: string) => void) {
|
||||
const utils = render(ViewerExportControls, {
|
||||
props: { onExportModel },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
return { ...utils, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('ViewerExportControls', () => {
|
||||
it('renders all three export format options', () => {
|
||||
renderComponent()
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement
|
||||
const optionValues = Array.from(select.options).map((o) => o.value)
|
||||
|
||||
expect(optionValues).toEqual(['glb', 'obj', 'stl'])
|
||||
})
|
||||
|
||||
it('defaults the export format to obj', () => {
|
||||
renderComponent()
|
||||
expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe(
|
||||
'obj'
|
||||
)
|
||||
})
|
||||
|
||||
it('emits exportModel with the currently selected format when the button is clicked', async () => {
|
||||
const onExportModel = vi.fn()
|
||||
const { user } = renderComponent(onExportModel)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
|
||||
expect(onExportModel).toHaveBeenCalledWith('obj')
|
||||
})
|
||||
|
||||
it('emits the newly chosen format after the user changes the dropdown', async () => {
|
||||
const onExportModel = vi.fn()
|
||||
const { user } = renderComponent(onExportModel)
|
||||
|
||||
await user.selectOptions(screen.getByRole('combobox'), 'glb')
|
||||
await user.click(screen.getByRole('button', { name: 'Export' }))
|
||||
|
||||
expect(onExportModel).toHaveBeenCalledWith('glb')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ViewerLightControls from '@/components/load3d/controls/viewer/ViewerLightControls.vue'
|
||||
|
||||
const settingValues: Record<string, unknown> = {
|
||||
'Comfy.Load3D.LightIntensityMaximum': 10,
|
||||
'Comfy.Load3D.LightIntensityMinimum': 1,
|
||||
'Comfy.Load3D.LightAdjustmentIncrement': 0.5
|
||||
}
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => settingValues[key]
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/slider', () => ({
|
||||
default: {
|
||||
name: 'Slider',
|
||||
props: ['modelValue', 'min', 'max', 'step'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
:value="modelValue"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@input="$emit('update:modelValue', Number($event.target.value))"
|
||||
/>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: { load3d: { lightIntensity: 'Light intensity' } }
|
||||
}
|
||||
})
|
||||
|
||||
function renderComponent(initial = 5) {
|
||||
const intensity = ref<number>(initial)
|
||||
const utils = render(ViewerLightControls, {
|
||||
props: {
|
||||
lightIntensity: intensity.value,
|
||||
'onUpdate:lightIntensity': (v: number | undefined) => {
|
||||
if (v !== undefined) intensity.value = v
|
||||
}
|
||||
},
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
return { ...utils, intensity }
|
||||
}
|
||||
|
||||
describe('ViewerLightControls', () => {
|
||||
it('renders the localized label and a slider bound to lightIntensity', () => {
|
||||
renderComponent(7)
|
||||
|
||||
expect(screen.getByText('Light intensity')).toBeInTheDocument()
|
||||
const slider = screen.getByRole('slider') as HTMLInputElement
|
||||
expect(slider.value).toBe('7')
|
||||
})
|
||||
|
||||
it('forwards the min / max / step settings from the setting store onto the slider', () => {
|
||||
renderComponent()
|
||||
const slider = screen.getByRole('slider') as HTMLInputElement
|
||||
|
||||
expect(slider.min).toBe('1')
|
||||
expect(slider.max).toBe('10')
|
||||
expect(slider.step).toBe('0.5')
|
||||
})
|
||||
|
||||
it('updates the v-model when the slider value changes', async () => {
|
||||
const { intensity } = renderComponent(5)
|
||||
const slider = screen.getByRole('slider') as HTMLInputElement
|
||||
|
||||
slider.value = '8'
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
|
||||
expect(intensity.value).toBe(8)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ViewerSceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue'
|
||||
|
||||
vi.mock('primevue/checkbox', () => ({
|
||||
default: {
|
||||
name: 'Checkbox',
|
||||
props: ['modelValue', 'inputId', 'binary', 'name'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="inputId"
|
||||
:name="name"
|
||||
:checked="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.checked)"
|
||||
/>
|
||||
`
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
load3d: {
|
||||
backgroundColor: 'Background color',
|
||||
showGrid: 'Show grid',
|
||||
uploadBackgroundImage: 'Upload background image',
|
||||
tiledMode: 'Tiled',
|
||||
panoramaMode: 'Panorama',
|
||||
removeBackgroundImage: 'Remove background image'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type RenderProps = {
|
||||
backgroundColor?: string
|
||||
showGrid?: boolean
|
||||
backgroundRenderMode?: 'tiled' | 'panorama'
|
||||
hasBackgroundImage?: boolean
|
||||
disableBackgroundUpload?: boolean
|
||||
onUpdateBackgroundImage?: (file: File | null) => void
|
||||
}
|
||||
|
||||
function renderComponent(overrides: RenderProps = {}) {
|
||||
const backgroundColor = ref<string>(overrides.backgroundColor ?? '#282828')
|
||||
const showGrid = ref<boolean>(overrides.showGrid ?? true)
|
||||
const backgroundRenderMode = ref<'tiled' | 'panorama'>(
|
||||
overrides.backgroundRenderMode ?? 'tiled'
|
||||
)
|
||||
|
||||
const utils = render(ViewerSceneControls, {
|
||||
props: {
|
||||
backgroundColor: backgroundColor.value,
|
||||
'onUpdate:backgroundColor': (v: string | undefined) => {
|
||||
if (v !== undefined) backgroundColor.value = v
|
||||
},
|
||||
showGrid: showGrid.value,
|
||||
'onUpdate:showGrid': (v: boolean | undefined) => {
|
||||
if (v !== undefined) showGrid.value = v
|
||||
},
|
||||
backgroundRenderMode: backgroundRenderMode.value,
|
||||
'onUpdate:backgroundRenderMode': (
|
||||
v: 'tiled' | 'panorama' | undefined
|
||||
) => {
|
||||
if (v) backgroundRenderMode.value = v
|
||||
},
|
||||
hasBackgroundImage: overrides.hasBackgroundImage ?? false,
|
||||
disableBackgroundUpload: overrides.disableBackgroundUpload ?? false,
|
||||
onUpdateBackgroundImage: overrides.onUpdateBackgroundImage
|
||||
},
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
return {
|
||||
...utils,
|
||||
backgroundColor,
|
||||
showGrid,
|
||||
backgroundRenderMode,
|
||||
user: userEvent.setup()
|
||||
}
|
||||
}
|
||||
|
||||
describe('ViewerSceneControls', () => {
|
||||
describe('without a background image', () => {
|
||||
it('renders the color picker', () => {
|
||||
renderComponent({ hasBackgroundImage: false })
|
||||
|
||||
expect(screen.getByText('Background color')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the upload button when uploads are not disabled', () => {
|
||||
renderComponent({
|
||||
hasBackgroundImage: false,
|
||||
disableBackgroundUpload: false
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /upload background image/i })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the upload button when uploads are disabled', () => {
|
||||
renderComponent({
|
||||
hasBackgroundImage: false,
|
||||
disableBackgroundUpload: true
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /upload background image/i })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render the tiled / panorama / remove buttons', () => {
|
||||
renderComponent({ hasBackgroundImage: false })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Tiled' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Panorama' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /remove background image/i })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a background image', () => {
|
||||
it('hides the color picker and upload button', () => {
|
||||
renderComponent({ hasBackgroundImage: true })
|
||||
|
||||
expect(screen.queryByText('Background color')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /upload background image/i })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the tiled / panorama / remove buttons', () => {
|
||||
renderComponent({ hasBackgroundImage: true })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Tiled' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Panorama' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /remove background image/i })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates backgroundRenderMode v-model to tiled when the tiled button is clicked', async () => {
|
||||
const { user, backgroundRenderMode } = renderComponent({
|
||||
hasBackgroundImage: true,
|
||||
backgroundRenderMode: 'panorama'
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Tiled' }))
|
||||
|
||||
expect(backgroundRenderMode.value).toBe('tiled')
|
||||
})
|
||||
|
||||
it('updates backgroundRenderMode v-model to panorama when the panorama button is clicked', async () => {
|
||||
const { user, backgroundRenderMode } = renderComponent({
|
||||
hasBackgroundImage: true,
|
||||
backgroundRenderMode: 'tiled'
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Panorama' }))
|
||||
|
||||
expect(backgroundRenderMode.value).toBe('panorama')
|
||||
})
|
||||
|
||||
it('emits updateBackgroundImage(null) when the remove button is clicked', async () => {
|
||||
const onUpdateBackgroundImage = vi.fn()
|
||||
const { user } = renderComponent({
|
||||
hasBackgroundImage: true,
|
||||
onUpdateBackgroundImage
|
||||
})
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /remove background image/i })
|
||||
)
|
||||
|
||||
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('show grid', () => {
|
||||
it('emits the toggled value via v-model', async () => {
|
||||
const { user, showGrid } = renderComponent({ showGrid: true })
|
||||
const checkbox = screen.getByRole('checkbox')
|
||||
|
||||
await user.click(checkbox)
|
||||
|
||||
expect(showGrid.value).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user