Files
ComfyUI_frontend/src/components/load3d/Load3DControls.test.ts
Terry Jia fc2a4e82cf feat(load3d): bind UI capability gating to ModelAdapterCapabilities (#11711)
> Final piece of the PLY / 3D Gaussian Splatting series. Previous PR
made `ModelAdapterCapabilities` load-bearing on the engine side; the UI
was still gating off `isSplatModel` / `isPlyModel` proxies. This PR
routes the viewer and the viewer-mode dialog through the capability
fields directly, so the same source of truth that drives `Load3d`
behavior also drives what the user sees. Eighth and last in the series
splitting up.

## Summary

Snapshot `Load3d.getCurrentModelCapabilities()` into 5 Vue refs on each
model load and pipe them through the existing `Load3D` /
`Load3DControls` / `Load3dViewerContent` / `ModelControls` /
`ViewerModelControls` / `Preview3d` props. Replaces the format-specific
`:is-splat-model` / `:is-ply-model` props and the hardcoded "splat →
drop light/gizmo/export" subtraction with additive capability gates. No
engine behavior changes — capability values are what previous PR already
produces; UI now consumes them.

## Changes

- **`useLoad3d.ts` / `useLoad3dViewer.ts`**: 5 new refs
(`canFitToViewer` / `canUseGizmo` / `canUseLighting` / `canExport` /
`materialModes`) refreshed on every load via
`load3d.getCurrentModelCapabilities()`. `useLoad3dViewer` extracts the
snapshot into a single `captureAdapterFlags(source)` helper because it
runs in three places (initializeViewer / initializeStandaloneViewer /
loadStandaloneModel).
- **`Load3D.vue`**: gate the fit-to-viewer button on `canFitToViewer`;
pass capability refs to `Load3DControls` instead of `isSplatModel` /
`isPlyModel`.
- **`Load3DControls.vue`**: build `availableCategories` additively
(`['scene','model','camera']` plus `light` / `gizmo` / `export` if their
capability is true) rather than subtracting from a fixed list when
`isSplatModel` is true. Forwards `materialModes` to `ModelControls`.
- **`Load3dViewerContent.vue`**: gate the light / gizmo / export sidebar
sections on the capability refs; pass `materialModes` to
`ViewerModelControls`.
- **`ModelControls.vue` / `ViewerModelControls.vue`**: drop the local
`materialModes` computed (which derived its options from `isPlyModel`
and a hardcoded mesh list) and accept `materialModes` as a `readonly
MaterialMode[]` prop. An empty array hides the dropdown entirely.
- **`Preview3d.vue`** (renderer linearMode): mirror the prop swap on the
standalone preview path.

## Review Focus

- **Capability prop wiring is the only public-API change for child
components**. `ModelControls` and `ViewerModelControls` lost
`hideMaterialMode` / `isPlyModel` props. Any extension that imported
these components directly will need to migrate, but they're internal
`src/components/load3d/controls/**` files and not part of the documented
extension surface.
- **Empty-`materialModes` semantics**: previously hidden via
`:hide-material-mode`; now hidden via `materialModes.length === 0`.
`SplatModelAdapter` declares `materialModes: []`, so the splat case
keeps the same behavior — the dropdown disappears. PLY adds
`'pointCloud'` to the array, so the dropdown picks up that mode
automatically without the controls needing an `isPlyModel` branch.
- **`captureAdapterFlags` runs after every load completes**, so
switching between mesh and splat in the same viewer instance updates the
chrome correctly. Verified via the new `Load3D.test.ts` /
`Load3dViewerContent.test.ts` cases.
- **Capability gating is inclusive of `canFitToViewer`** in this PR even
though `Load3DControls` has no fit category — the fit-to-viewer floating
button on `Load3D.vue` is what reads it. PLY's `fitToViewer: true` means
the button stays visible for PLY users.

## Coverage

| File | Stmts | Branch | Funcs |
|---|---|---|---|
| `Load3D.vue` (modified) | 53.3% | **95.5%** | 83.3% |
| `Load3DControls.vue` (modified) | 77.5% | **94.8%** | 86.4% |
| `Load3dViewerContent.vue` (modified) | 60.6% | 72.1% | 54.5% |
| `controls/ModelControls.vue` (modified) | 16.3% | 0% | 0% |
| `controls/viewer/ViewerModelControls.vue` (modified) | **100%** |
**100%** | **100%** |
| `composables/useLoad3d.ts` (modified) | 78.7% | 64.5% | 71.4% |
| `composables/useLoad3dViewer.ts` (modified) | 76.0% | 52.1% | 66.7% |

Four new test files (`Load3D.test.ts` / `Load3DControls.test.ts` /
`Load3dViewerContent.test.ts` /
`controls/viewer/ViewerModelControls.test.ts`) cover the new capability
gating directly: each component is rendered with capability flags
toggled on/off and the appropriate sidebar / dropdown / button
visibility is asserted. Capability prop forwarding from `Load3D.vue` →
`Load3DControls.vue` and from `Load3dViewerContent.vue` →
`ViewerModelControls.vue` is exercised end-to-end.

`controls/ModelControls.vue` is the legacy node-side ModelControls — its
existing tests live elsewhere and were not in this PR's scope; the diff
line covered (the `v-if="materialModes.length > 0"` swap) is exercised
by the new `Load3DControls.test.ts` cases that drive a non-empty / empty
`materialModes` through. `Preview3d.vue` (renderer linearMode) has no
test file in the project; the prop swap there is the same shape as the
`Load3D.vue` swap which is covered.

`useLoad3d.ts` / `useLoad3dViewer.ts` percentages are roughly the
pre-existing baseline. The diff lines (the 5 new refs and the
`captureAdapterFlags` helper) are exercised by the existing composable
tests via the mock that now stubs `getCurrentModelCapabilities()`.

73 new component unit tests; 393 total load3d-related tests pass on this
branch.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11711-feat-load3d-bind-UI-capability-gating-to-ModelAdapterCapabilities-3506d73d365081b3af68f30e3f728e24)
by [Unito](https://www.unito.io)
2026-04-28 16:39:06 -04:00

405 lines
12 KiB
TypeScript

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 Load3DControls from '@/components/load3d/Load3DControls.vue'
import type {
CameraConfig,
LightConfig,
MaterialMode,
ModelConfig,
SceneConfig
} from '@/extensions/core/load3d/interfaces'
vi.mock('@/composables/useDismissableOverlay', () => ({
useDismissableOverlay: vi.fn()
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
menu: { showMenu: 'Show menu' },
load3d: {
scene: 'Scene',
model: 'Model',
camera: 'Camera',
light: 'Light',
gizmo: { label: 'Gizmo' },
export: 'Export'
}
}
}
})
const childStubs = {
SceneControls: {
name: 'SceneControls',
emits: ['update-background-image'],
template: `<div data-testid="scene-controls">
<button data-testid="scene-emit-bg" @click="$emit('update-background-image', null)" />
</div>`
},
ModelControls: {
name: 'ModelControls',
template: '<div data-testid="model-controls" />'
},
CameraControls: {
name: 'CameraControls',
template: '<div data-testid="camera-controls" />'
},
LightControls: {
name: 'LightControls',
template: '<div data-testid="light-controls" />'
},
HDRIControls: {
name: 'HDRIControls',
emits: ['update-hdri-file'],
template: `<div data-testid="hdri-controls">
<button data-testid="hdri-emit-file" @click="$emit('update-hdri-file', null)" />
</div>`
},
ExportControls: {
name: 'ExportControls',
emits: ['export-model'],
template: `<div data-testid="export-controls">
<button data-testid="export-emit-glb" @click="$emit('export-model', 'glb')" />
</div>`
},
GizmoControls: {
name: 'GizmoControls',
emits: ['toggle-gizmo', 'set-gizmo-mode', 'reset-gizmo-transform'],
template: `<div data-testid="gizmo-controls">
<button data-testid="gizmo-emit-toggle" @click="$emit('toggle-gizmo', true)" />
<button data-testid="gizmo-emit-mode" @click="$emit('set-gizmo-mode', 'rotate')" />
<button data-testid="gizmo-emit-reset" @click="$emit('reset-gizmo-transform')" />
</div>`
}
}
const defaultSceneConfig: SceneConfig = {
showGrid: true,
backgroundColor: '#000000',
backgroundImage: '',
backgroundRenderMode: 'tiled'
}
const defaultModelConfig: ModelConfig = {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false,
gizmo: {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
}
const defaultCameraConfig: CameraConfig = {
cameraType: 'perspective',
fov: 75
}
const defaultLightConfig: LightConfig = {
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
}
type RenderProps = {
sceneConfig?: SceneConfig
modelConfig?: ModelConfig
cameraConfig?: CameraConfig
lightConfig?: LightConfig
canUseGizmo?: boolean
canUseLighting?: boolean
canExport?: boolean
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
onUpdateBackgroundImage?: (file: File | null) => void
onExportModel?: (format: string) => void
onUpdateHdriFile?: (file: File | null) => void
onToggleGizmo?: (enabled: boolean) => void
onSetGizmoMode?: (mode: string) => void
onResetGizmoTransform?: () => void
}
function renderControls(overrides: RenderProps = {}) {
const result = render(Load3DControls, {
props: {
sceneConfig: defaultSceneConfig,
modelConfig: defaultModelConfig,
cameraConfig: defaultCameraConfig,
lightConfig: defaultLightConfig,
canUseGizmo: true,
canUseLighting: true,
canExport: true,
materialModes: ['original', 'normal', 'wireframe'],
hasSkeleton: false,
...overrides
},
global: {
plugins: [i18n],
stubs: childStubs,
directives: {
tooltip: () => {}
}
}
})
return { ...result, user: userEvent.setup() }
}
async function openMenu(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Show menu' }))
}
describe('Load3DControls', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('category menu', () => {
it('renders SceneControls by default', () => {
renderControls()
expect(screen.getByTestId('scene-controls')).toBeInTheDocument()
})
it('keeps the category menu closed until the trigger is clicked', async () => {
const { user } = renderControls()
expect(
screen.queryByRole('button', { name: 'Scene' })
).not.toBeInTheDocument()
await openMenu(user)
expect(screen.getByRole('button', { name: 'Scene' })).toBeInTheDocument()
})
it('shows every category when all capabilities are enabled', async () => {
const { user } = renderControls()
await openMenu(user)
for (const label of [
'Scene',
'Model',
'Camera',
'Light',
'Gizmo',
'Export'
]) {
expect(screen.getByRole('button', { name: label })).toBeInTheDocument()
}
})
it('omits the light category when canUseLighting is false', async () => {
const { user } = renderControls({ canUseLighting: false })
await openMenu(user)
expect(
screen.queryByRole('button', { name: 'Light' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Scene' })).toBeInTheDocument()
})
it('omits the gizmo category when canUseGizmo is false', async () => {
const { user } = renderControls({ canUseGizmo: false })
await openMenu(user)
expect(
screen.queryByRole('button', { name: 'Gizmo' })
).not.toBeInTheDocument()
})
it('omits the export category when canExport is false', async () => {
const { user } = renderControls({ canExport: false })
await openMenu(user)
expect(
screen.queryByRole('button', { name: 'Export' })
).not.toBeInTheDocument()
})
it('selecting a category closes the menu and swaps the visible control', async () => {
const { user } = renderControls()
await openMenu(user)
await user.click(screen.getByRole('button', { name: 'Model' }))
expect(
screen.queryByRole('button', { name: 'Scene' })
).not.toBeInTheDocument()
expect(screen.getByTestId('model-controls')).toBeInTheDocument()
expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument()
})
})
describe('control visibility', () => {
async function selectCategory(
user: ReturnType<typeof userEvent.setup>,
label: string
) {
await openMenu(user)
await user.click(screen.getByRole('button', { name: label }))
}
it.each([
['Model', 'model-controls'],
['Camera', 'camera-controls']
])('%s category renders only %s', async (label, testId) => {
const { user } = renderControls()
await selectCategory(user, label)
expect(screen.getByTestId(testId)).toBeInTheDocument()
expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument()
})
it('Light category renders both LightControls and HDRIControls', async () => {
const { user } = renderControls()
await selectCategory(user, 'Light')
expect(screen.getByTestId('light-controls')).toBeInTheDocument()
expect(screen.getByTestId('hdri-controls')).toBeInTheDocument()
})
it('Gizmo category renders GizmoControls', async () => {
const { user } = renderControls()
await selectCategory(user, 'Gizmo')
expect(screen.getByTestId('gizmo-controls')).toBeInTheDocument()
})
it('Export category renders ExportControls', async () => {
const { user } = renderControls()
await selectCategory(user, 'Export')
expect(screen.getByTestId('export-controls')).toBeInTheDocument()
})
it('hides all controls when the corresponding v-model is undefined', () => {
renderControls({
sceneConfig: undefined,
modelConfig: undefined,
cameraConfig: undefined,
lightConfig: undefined
})
expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument()
})
})
describe('capability desync handling', () => {
it('hides the active panel and resets to scene when its capability is dropped at runtime', async () => {
const { user, rerender } = renderControls()
await openMenu(user)
await user.click(screen.getByRole('button', { name: 'Light' }))
expect(screen.getByTestId('light-controls')).toBeInTheDocument()
await rerender({ canUseLighting: false })
expect(screen.queryByTestId('light-controls')).not.toBeInTheDocument()
expect(screen.getByTestId('scene-controls')).toBeInTheDocument()
await openMenu(user)
expect(
screen.queryByRole('button', { name: 'Light' })
).not.toBeInTheDocument()
})
it.each([
['Gizmo', 'gizmo-controls', 'canUseGizmo' as const],
['Export', 'export-controls', 'canExport' as const]
])(
'hides the %s panel when its capability flips off at runtime',
async (label, testId, capabilityProp) => {
const { user, rerender } = renderControls()
await openMenu(user)
await user.click(screen.getByRole('button', { name: label }))
expect(screen.getByTestId(testId)).toBeInTheDocument()
await rerender({ [capabilityProp]: false })
expect(screen.queryByTestId(testId)).not.toBeInTheDocument()
expect(screen.getByTestId('scene-controls')).toBeInTheDocument()
}
)
it('does not reset activeCategory when capabilities change but the active one is still available', async () => {
const { user, rerender } = renderControls()
await openMenu(user)
await user.click(screen.getByRole('button', { name: 'Camera' }))
expect(screen.getByTestId('camera-controls')).toBeInTheDocument()
await rerender({ canUseLighting: false, canUseGizmo: false })
expect(screen.getByTestId('camera-controls')).toBeInTheDocument()
expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument()
})
})
describe('event forwarding', () => {
it('forwards updateBackgroundImage from SceneControls', async () => {
const onUpdateBackgroundImage = vi.fn()
const { user } = renderControls({ onUpdateBackgroundImage })
await user.click(screen.getByTestId('scene-emit-bg'))
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(null)
})
it('forwards exportModel from ExportControls', async () => {
const onExportModel = vi.fn()
const { user } = renderControls({ onExportModel })
await openMenu(user)
await user.click(screen.getByRole('button', { name: 'Export' }))
await user.click(screen.getByTestId('export-emit-glb'))
expect(onExportModel).toHaveBeenCalledWith('glb')
})
it('forwards updateHdriFile from HDRIControls', async () => {
const onUpdateHdriFile = vi.fn()
const { user } = renderControls({ onUpdateHdriFile })
await openMenu(user)
await user.click(screen.getByRole('button', { name: 'Light' }))
await user.click(screen.getByTestId('hdri-emit-file'))
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
})
it('forwards gizmo events from GizmoControls', async () => {
const onToggleGizmo = vi.fn()
const onSetGizmoMode = vi.fn()
const onResetGizmoTransform = vi.fn()
const { user } = renderControls({
onToggleGizmo,
onSetGizmoMode,
onResetGizmoTransform
})
await openMenu(user)
await user.click(screen.getByRole('button', { name: 'Gizmo' }))
await user.click(screen.getByTestId('gizmo-emit-toggle'))
await user.click(screen.getByTestId('gizmo-emit-mode'))
await user.click(screen.getByTestId('gizmo-emit-reset'))
expect(onToggleGizmo).toHaveBeenCalledWith(true)
expect(onSetGizmoMode).toHaveBeenCalledWith('rotate')
expect(onResetGizmoTransform).toHaveBeenCalledOnce()
})
})
})