diff --git a/src/components/load3d/Load3DScene.test.ts b/src/components/load3d/Load3DScene.test.ts new file mode 100644 index 0000000000..a45c9d3fe0 --- /dev/null +++ b/src/components/load3d/Load3DScene.test.ts @@ -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 | null, + dragMessage: null as Ref | null, + handleDragOver: vi.fn(), + handleDragLeave: vi.fn(), + handleDrop: vi.fn(), + capturedOptions: null as { + onModelDrop?: (file: File) => Promise + 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: ` +
+ {{ loadingMessage }} +
+ ` + } +})) + +type RenderOpts = { + loading?: boolean + loadingMessage?: string + isPreview?: boolean + onModelDrop?: (file: File) => void | Promise + initializeLoad3d?: (container: HTMLElement) => Promise + 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() + }) +}) diff --git a/src/components/load3d/Load3dViewerContent.test.ts b/src/components/load3d/Load3dViewerContent.test.ts index 9ea2a6402d..b5bf6b5fb9 100644 --- a/src/components/load3d/Load3dViewerContent.test.ts +++ b/src/components/load3d/Load3dViewerContent.test.ts @@ -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> } -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() diff --git a/src/components/load3d/controls/AnimationControls.test.ts b/src/components/load3d/controls/AnimationControls.test.ts new file mode 100644 index 0000000000..ae1dbbb9b4 --- /dev/null +++ b/src/components/load3d/controls/AnimationControls.test.ts @@ -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: ` + + ` + } +})) + +vi.mock('@/components/ui/slider/Slider.vue', () => ({ + default: { + name: 'UiSlider', + props: ['modelValue', 'min', 'max', 'step'], + emits: ['update:modelValue'], + template: ` + + ` + } +})) + +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(opts.animations ?? []) + const playing = ref(opts.playing ?? false) + const selectedSpeed = ref(opts.selectedSpeed ?? 1) + const selectedAnimation = ref(opts.selectedAnimation ?? 0) + const animationProgress = ref(opts.animationProgress ?? 0) + const animationDuration = ref(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() + }) +}) diff --git a/src/components/load3d/controls/CameraControls.test.ts b/src/components/load3d/controls/CameraControls.test.ts new file mode 100644 index 0000000000..964663f6e0 --- /dev/null +++ b/src/components/load3d/controls/CameraControls.test.ts @@ -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: '
{{ tooltipText }}
' + } +})) + +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(initial.type ?? 'perspective') + const fov = ref(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') + }) +}) diff --git a/src/components/load3d/controls/ExportControls.test.ts b/src/components/load3d/controls/ExportControls.test.ts new file mode 100644 index 0000000000..b1296a09e4 --- /dev/null +++ b/src/components/load3d/controls/ExportControls.test.ts @@ -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() + }) +}) diff --git a/src/components/load3d/controls/HDRIControls.test.ts b/src/components/load3d/controls/HDRIControls.test.ts new file mode 100644 index 0000000000..1729b0ceb4 --- /dev/null +++ b/src/components/load3d/controls/HDRIControls.test.ts @@ -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(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') + }) + }) +}) diff --git a/src/components/load3d/controls/LightControls.test.ts b/src/components/load3d/controls/LightControls.test.ts new file mode 100644 index 0000000000..bf1368656b --- /dev/null +++ b/src/components/load3d/controls/LightControls.test.ts @@ -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 = { + '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: ` + + ` + } +})) + +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(opts.lightIntensity ?? 5) + const materialMode = ref(opts.materialMode ?? 'original') + const hdriConfig = ref(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() + }) + }) +}) diff --git a/src/components/load3d/controls/ModelControls.test.ts b/src/components/load3d/controls/ModelControls.test.ts new file mode 100644 index 0000000000..2ee0336fd5 --- /dev/null +++ b/src/components/load3d/controls/ModelControls.test.ts @@ -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(opts.upDirection ?? 'original') + const materialMode = ref(opts.materialMode ?? 'original') + const showSkeleton = ref(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() + }) + }) +}) diff --git a/src/components/load3d/controls/PopupSlider.test.ts b/src/components/load3d/controls/PopupSlider.test.ts new file mode 100644 index 0000000000..560895d33a --- /dev/null +++ b/src/components/load3d/controls/PopupSlider.test.ts @@ -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: ` + + ` + } +})) + +function renderComponent( + props: { + tooltipText?: string + icon?: string + min?: number + max?: number + step?: number + initial?: number + } = {} +) { + const value = ref(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) + }) +}) diff --git a/src/components/load3d/controls/RecordingControls.test.ts b/src/components/load3d/controls/RecordingControls.test.ts new file mode 100644 index 0000000000..4d93ac1ccc --- /dev/null +++ b/src/components/load3d/controls/RecordingControls.test.ts @@ -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(opts.hasRecording ?? false) + const isRecording = ref(opts.isRecording ?? false) + const recordingDuration = ref(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() + }) +}) diff --git a/src/components/load3d/controls/SceneControls.test.ts b/src/components/load3d/controls/SceneControls.test.ts new file mode 100644 index 0000000000..7de6f008f9 --- /dev/null +++ b/src/components/load3d/controls/SceneControls.test.ts @@ -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: '
{{ tooltipText }}
' + } +})) + +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(opts.showGrid ?? true) + const backgroundColor = ref(opts.backgroundColor ?? '#000000') + const backgroundImage = ref(opts.backgroundImage ?? '') + const backgroundRenderMode = ref<'tiled' | 'panorama'>( + opts.backgroundRenderMode ?? 'tiled' + ) + const fov = ref(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) + }) + }) +}) diff --git a/src/components/load3d/controls/ViewerControls.test.ts b/src/components/load3d/controls/ViewerControls.test.ts new file mode 100644 index 0000000000..2656a14b45 --- /dev/null +++ b/src/components/load3d/controls/ViewerControls.test.ts @@ -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: '
' } +})) + +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) + }) +}) diff --git a/src/components/load3d/controls/viewer/ViewerCameraControls.test.ts b/src/components/load3d/controls/viewer/ViewerCameraControls.test.ts new file mode 100644 index 0000000000..fe668a91b2 --- /dev/null +++ b/src/components/load3d/controls/viewer/ViewerCameraControls.test.ts @@ -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: ` + + ` + } +})) + +vi.mock('primevue/slider', () => ({ + default: { + name: 'Slider', + props: ['modelValue', 'min', 'max', 'step', 'ariaLabel'], + emits: ['update:modelValue'], + template: ` + + ` + } +})) + +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(initial.type ?? 'perspective') + const fov = ref(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') + }) +}) diff --git a/src/components/load3d/controls/viewer/ViewerExportControls.test.ts b/src/components/load3d/controls/viewer/ViewerExportControls.test.ts new file mode 100644 index 0000000000..800fd3d70b --- /dev/null +++ b/src/components/load3d/controls/viewer/ViewerExportControls.test.ts @@ -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: ` + + ` + } +})) + +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') + }) +}) diff --git a/src/components/load3d/controls/viewer/ViewerLightControls.test.ts b/src/components/load3d/controls/viewer/ViewerLightControls.test.ts new file mode 100644 index 0000000000..4f48f19c68 --- /dev/null +++ b/src/components/load3d/controls/viewer/ViewerLightControls.test.ts @@ -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 = { + '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: ` + + ` + } +})) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { load3d: { lightIntensity: 'Light intensity' } } + } +}) + +function renderComponent(initial = 5) { + const intensity = ref(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) + }) +}) diff --git a/src/components/load3d/controls/viewer/ViewerSceneControls.test.ts b/src/components/load3d/controls/viewer/ViewerSceneControls.test.ts new file mode 100644 index 0000000000..cb106fe475 --- /dev/null +++ b/src/components/load3d/controls/viewer/ViewerSceneControls.test.ts @@ -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: ` + + ` + } +})) + +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(overrides.backgroundColor ?? '#282828') + const showGrid = ref(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) + }) + }) +}) diff --git a/src/renderer/extensions/linearMode/Preview3d.test.ts b/src/renderer/extensions/linearMode/Preview3d.test.ts index 6629942be6..21d484dca8 100644 --- a/src/renderer/extensions/linearMode/Preview3d.test.ts +++ b/src/renderer/extensions/linearMode/Preview3d.test.ts @@ -1,11 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' -import { render } from '@testing-library/vue' +import { render, screen } from '@testing-library/vue' const initializeStandaloneViewer = vi.fn() const cleanup = vi.fn() +const viewerOverrides: Record = {} + vi.mock('@/composables/useLoad3dViewer', () => ({ useLoad3dViewer: () => ({ initializeStandaloneViewer, @@ -16,20 +18,45 @@ vi.mock('@/composables/useLoad3dViewer', () => ({ handleBackgroundImageUpdate: vi.fn(), exportModel: vi.fn(), handleSeek: vi.fn(), - isSplatModel: false, - isPlyModel: false, + canUseGizmo: true, + canUseLighting: true, + canExport: true, + materialModes: ['original', 'normal', 'wireframe'], hasSkeleton: false, animations: [], playing: false, selectedSpeed: 1, selectedAnimation: 0, animationProgress: 0, - animationDuration: 0 + animationDuration: 0, + ...viewerOverrides }) })) vi.mock('@/components/load3d/Load3DControls.vue', () => ({ - default: { template: '
' } + default: { + name: 'Load3DControlsStub', + props: [ + 'sceneConfig', + 'modelConfig', + 'cameraConfig', + 'lightConfig', + 'canUseGizmo', + 'canUseLighting', + 'canExport', + 'materialModes', + 'hasSkeleton' + ], + template: ` +
+ ` + } })) vi.mock('@/components/load3d/controls/AnimationControls.vue', () => ({ @@ -39,6 +66,7 @@ vi.mock('@/components/load3d/controls/AnimationControls.vue', () => ({ describe('Preview3d', () => { beforeEach(() => { vi.clearAllMocks() + for (const k of Object.keys(viewerOverrides)) delete viewerOverrides[k] }) afterEach(() => { @@ -102,6 +130,27 @@ describe('Preview3d', () => { expect(cleanup).toHaveBeenCalledOnce() }) + it('forwards the viewer capability flags to Load3DControls', async () => { + Object.assign(viewerOverrides, { + canUseGizmo: false, + canUseLighting: false, + canExport: false, + materialModes: [], + hasSkeleton: true + }) + + const { unmount } = await renderPreview3d() + + const controls = await screen.findByTestId('load3d-controls') + expect(controls.getAttribute('data-can-use-gizmo')).toBe('false') + expect(controls.getAttribute('data-can-use-lighting')).toBe('false') + expect(controls.getAttribute('data-can-export')).toBe('false') + expect(controls.getAttribute('data-has-skeleton')).toBe('true') + expect(controls.getAttribute('data-material-modes')).toBe('[]') + + unmount() + }) + it('reinitializes when modelUrl changes on existing instance', async () => { const result = await renderPreview3d( 'http://localhost/view?filename=model-a.glb'