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:
Terry Jia
2026-04-28 19:23:09 -04:00
committed by GitHub
parent e7640d414b
commit bb74ec94de
17 changed files with 2310 additions and 6 deletions

View 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()
})
})

View File

@@ -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()

View 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()
})
})

View 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')
})
})

View 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()
})
})

View 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')
})
})
})

View 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()
})
})
})

View 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()
})
})
})

View 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)
})
})

View 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()
})
})

View 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)
})
})
})

View 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)
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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)
})
})

View 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 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)
})
})
})