Compare commits

..

3 Commits

Author SHA1 Message Date
huang47
0e01ff5bc4 test: verify critical coverage drop 2026-07-08 20:36:51 -07:00
huang47
12b3cc8fca ci: tolerate codecov unit report drift 2026-07-08 20:33:29 -07:00
huang47
427e9a20c4 ci: gate critical unit coverage drops 2026-07-08 17:51:18 -07:00
5 changed files with 12420 additions and 11078 deletions

View File

@@ -5,6 +5,54 @@ comment:
require_base: false
require_head: true
coverage:
status:
project:
critical-unit:
target: auto
threshold: 0.15%
flags:
- unit
paths:
- src/base/
- src/composables/
- src/core/
- src/lib/litegraph/src/node/
- src/lib/litegraph/src/subgraph/
- src/lib/litegraph/src/utils/
- src/platform/assets/composables/
- src/platform/assets/mappings/
- src/platform/assets/schemas/
- src/platform/assets/services/
- src/platform/assets/utils/
- src/platform/errorCatalog/
- src/platform/keybindings/
- src/platform/missingMedia/
- src/platform/missingModel/
- src/platform/navigation/
- src/platform/nodeReplacement/
- src/platform/remote/
- src/platform/remoteConfig/
- src/platform/secrets/
- src/platform/settings/
- src/platform/workflow/
- src/platform/workspace/api/
- src/platform/workspace/auth/
- src/platform/workspace/composables/
- src/platform/workspace/stores/
- src/platform/workspace/utils/
- src/schemas/
- src/scripts/
- src/services/
- src/stores/
- src/utils/
- src/workbench/extensions/manager/composables/
- src/workbench/extensions/manager/services/
- src/workbench/extensions/manager/stores/
- src/workbench/extensions/manager/utils/
- src/workbench/utils/
informational: false
# Carry forward the last known coverage for a flag when its upload is missing or
# late. The `e2e` flag is uploaded by a separate workflow_run job that can fail
# or arrive after Codecov has already computed the patch status; without this,

View File

@@ -12,6 +12,11 @@ export type {
AddAssetTagsErrors,
AddAssetTagsResponse,
AddAssetTagsResponses,
AdminDeleteHubWorkflowData,
AdminDeleteHubWorkflowError,
AdminDeleteHubWorkflowErrors,
AdminDeleteHubWorkflowResponse,
AdminDeleteHubWorkflowResponses,
Asset,
AssetCreated,
AssetCreatedWritable,
@@ -84,6 +89,11 @@ export type {
CreateAssetFromHashResponses,
CreateAssetResponse,
CreateAssetResponses,
CreateDeletionRequestData,
CreateDeletionRequestError,
CreateDeletionRequestErrors,
CreateDeletionRequestResponse,
CreateDeletionRequestResponses,
CreateDesktopLoginCodeData,
CreateDesktopLoginCodeError,
CreateDesktopLoginCodeErrors,
@@ -160,6 +170,9 @@ export type {
DeleteHubWorkflowErrors,
DeleteHubWorkflowResponse,
DeleteHubWorkflowResponses,
DeleteMonitoringTasksSubpathData,
DeleteMonitoringTasksSubpathErrors,
DeleteMonitoringTasksSubpathResponses,
DeleteSecretData,
DeleteSecretError,
DeleteSecretErrors,
@@ -186,6 +199,8 @@ export type {
DeleteWorkspaceErrors,
DeleteWorkspaceResponse,
DeleteWorkspaceResponses,
DeletionRequest,
DeletionStatus,
DesktopLoginCodeCreateRequest,
DesktopLoginCodeCreateResponse,
DesktopLoginCodeExchangeRequest,
@@ -197,6 +212,14 @@ export type {
DownloadExportErrors,
DownloadExportResponse,
DownloadExportResponses,
EnsureWorkspaceBillingLegacySnapshot,
EnsureWorkspaceBillingProvisionedData,
EnsureWorkspaceBillingProvisionedError,
EnsureWorkspaceBillingProvisionedErrors,
EnsureWorkspaceBillingProvisionedRequest,
EnsureWorkspaceBillingProvisionedResponse,
EnsureWorkspaceBillingProvisionedResponse2,
EnsureWorkspaceBillingProvisionedResponses,
ErrorResponse,
ExchangeDesktopLoginCodeData,
ExchangeDesktopLoginCodeError,
@@ -219,6 +242,7 @@ export type {
ExportDownloadUrlResponse,
FeedbackRequest,
FeedbackResponse,
FeedbackResponseWritable,
ForkWorkflowData,
ForkWorkflowError,
ForkWorkflowErrors,
@@ -281,6 +305,11 @@ export type {
GetCustomNodeProxyData,
GetCustomNodeProxyErrors,
GetCustomNodeProxyResponses,
GetDeletionRequestData,
GetDeletionRequestError,
GetDeletionRequestErrors,
GetDeletionRequestResponse,
GetDeletionRequestResponses,
GetEmbeddingsData,
GetEmbeddingsResponse,
GetEmbeddingsResponses,
@@ -290,6 +319,11 @@ export type {
GetFeaturesData,
GetFeaturesResponse,
GetFeaturesResponses,
GetFilesData,
GetFilesError,
GetFilesErrors,
GetFilesResponse,
GetFilesResponses,
GetGlobalSubgraphData,
GetGlobalSubgraphError,
GetGlobalSubgraphErrors,
@@ -308,6 +342,11 @@ export type {
GetHistoryData,
GetHistoryError,
GetHistoryErrors,
GetHistoryEventsData,
GetHistoryEventsError,
GetHistoryEventsErrors,
GetHistoryEventsResponse,
GetHistoryEventsResponses,
GetHistoryForPromptData,
GetHistoryForPromptError,
GetHistoryForPromptErrors,
@@ -370,6 +409,11 @@ export type {
GetLegacyUserdataV2Errors,
GetLegacyViewMetadataData,
GetLegacyViewMetadataErrors,
GetLogsData,
GetLogsError,
GetLogsErrors,
GetLogsResponse,
GetLogsResponses,
GetMaskLayersData,
GetMaskLayersError,
GetMaskLayersErrors,
@@ -387,6 +431,12 @@ export type {
GetModelsInFolderErrors,
GetModelsInFolderResponse,
GetModelsInFolderResponses,
GetMonitoringTasksData,
GetMonitoringTasksErrors,
GetMonitoringTasksResponses,
GetMonitoringTasksSubpathData,
GetMonitoringTasksSubpathErrors,
GetMonitoringTasksSubpathResponses,
GetMyHubProfileData,
GetMyHubProfileError,
GetMyHubProfileErrors,
@@ -430,6 +480,12 @@ export type {
GetPaymentPortalErrors,
GetPaymentPortalResponse,
GetPaymentPortalResponses,
GetPprofData,
GetPprofProfileData,
GetPprofProfileResponses,
GetPprofResponses,
GetPprofTraceData,
GetPprofTraceResponses,
GetPromptInfoData,
GetPromptInfoError,
GetPromptInfoErrors,
@@ -542,6 +598,7 @@ export type {
HistoryDetailEntry,
HistoryDetailResponse,
HistoryEntry,
HistoryEventRequest,
HistoryManageRequest,
HistoryResponse,
HubAssetUploadUrlRequest,
@@ -563,6 +620,11 @@ export type {
ImportPublishedAssetsResponse,
ImportPublishedAssetsResponse2,
ImportPublishedAssetsResponses,
InsertDynamicConfigData,
InsertDynamicConfigError,
InsertDynamicConfigErrors,
InsertDynamicConfigResponse,
InsertDynamicConfigResponses,
InterruptJobData,
InterruptJobError,
InterruptJobErrors,
@@ -610,6 +672,13 @@ export type {
ListJobsErrors,
ListJobsResponse,
ListJobsResponses,
ListLinkedFirebaseUidsData,
ListLinkedFirebaseUidsError,
ListLinkedFirebaseUidsErrors,
ListLinkedFirebaseUidsRequest,
ListLinkedFirebaseUidsResponse,
ListLinkedFirebaseUidsResponse2,
ListLinkedFirebaseUidsResponses,
ListMembersResponse,
ListSecretProvidersData,
ListSecretProvidersError,
@@ -659,6 +728,7 @@ export type {
ListWorkspacesResponse,
ListWorkspacesResponse2,
ListWorkspacesResponses,
LogsResponse,
ManageHistoryData,
ManageHistoryError,
ManageHistoryErrors,
@@ -689,6 +759,8 @@ export type {
OAuthTokenError,
OAuthTokenResponse,
PaginationInfo,
PartnerUsageRequest,
PartnerUsageResponse,
PaymentPortalRequest,
PaymentPortalResponse,
PendingInvite,
@@ -704,6 +776,9 @@ export type {
PostCustomNodeProxyData,
PostCustomNodeProxyErrors,
PostCustomNodeProxyResponses,
PostMonitoringTasksSubpathData,
PostMonitoringTasksSubpathErrors,
PostMonitoringTasksSubpathResponses,
PostOAuthAuthorizeData,
PostOAuthAuthorizeError,
PostOAuthAuthorizeErrors,
@@ -719,6 +794,8 @@ export type {
PostOAuthTokenErrors,
PostOAuthTokenResponse,
PostOAuthTokenResponses,
PostPprofSymbolData,
PostPprofSymbolResponses,
PostUserdataFileData,
PostUserdataFileError,
PostUserdataFileErrors,
@@ -760,6 +837,12 @@ export type {
RedeemDesktopLoginCodeErrors,
RedeemDesktopLoginCodeResponse,
RedeemDesktopLoginCodeResponses,
ReleaseDeletionHoldData,
ReleaseDeletionHoldError,
ReleaseDeletionHoldErrors,
ReleaseDeletionHoldResponse,
ReleaseDeletionHoldResponses,
ReleaseHoldResponse,
RemoveAssetTagsData,
RemoveAssetTagsError,
RemoveAssetTagsErrors,
@@ -770,6 +853,16 @@ export type {
RemoveWorkspaceMemberErrors,
RemoveWorkspaceMemberResponse,
RemoveWorkspaceMemberResponses,
ReportHistoryEventData,
ReportHistoryEventError,
ReportHistoryEventErrors,
ReportHistoryEventResponse,
ReportHistoryEventResponses,
ReportPartnerUsageData,
ReportPartnerUsageError,
ReportPartnerUsageErrors,
ReportPartnerUsageResponse,
ReportPartnerUsageResponses,
ResubscribeData,
ResubscribeError,
ResubscribeErrors,
@@ -794,6 +887,15 @@ export type {
SeedAssetsData,
SeedAssetsResponse,
SeedAssetsResponses,
SetReviewStatusData,
SetReviewStatusError,
SetReviewStatusErrors,
SetReviewStatusRequest,
SetReviewStatusResponse,
SetReviewStatusResponse2,
SetReviewStatusResponses,
ShortLinkRedirectData,
ShortLinkRedirectErrors,
SubmitFeedbackData,
SubmitFeedbackError,
SubmitFeedbackErrors,
@@ -810,6 +912,13 @@ export type {
SubscribeToLogsResponses,
SubscriptionDuration,
SubscriptionTier,
SyncApiKeyData,
SyncApiKeyError,
SyncApiKeyErrors,
SyncApiKeyRequest,
SyncApiKeyResponse,
SyncApiKeyResponse2,
SyncApiKeyResponses,
SystemStatsResponse,
TagInfo,
TagsModificationResponse,
@@ -831,6 +940,12 @@ export type {
UpdateHubProfileRequest,
UpdateHubProfileResponse,
UpdateHubProfileResponses,
UpdateHubWorkflowData,
UpdateHubWorkflowError,
UpdateHubWorkflowErrors,
UpdateHubWorkflowRequest,
UpdateHubWorkflowResponse,
UpdateHubWorkflowResponses,
UpdateMemberRoleRequest,
UpdateMultipleSettingsData,
UpdateMultipleSettingsError,
@@ -848,6 +963,11 @@ export type {
UpdateSettingByIdErrors,
UpdateSettingByIdResponse,
UpdateSettingByIdResponses,
UpdateSubscriptionCacheData,
UpdateSubscriptionCacheError,
UpdateSubscriptionCacheErrors,
UpdateSubscriptionCacheResponse,
UpdateSubscriptionCacheResponses,
UpdateWorkflowData,
UpdateWorkflowError,
UpdateWorkflowErrors,
@@ -879,6 +999,13 @@ export type {
UserResponse,
ValidationError,
ValidationResult,
VerifyApiKeyRequest,
VerifyApiKeyResponse,
VerifyWorkspaceApiKeyData,
VerifyWorkspaceApiKeyError,
VerifyWorkspaceApiKeyErrors,
VerifyWorkspaceApiKeyResponse,
VerifyWorkspaceApiKeyResponses,
ViewFileData,
ViewFileError,
ViewFileErrors,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,743 +0,0 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type Load3d from '@/extensions/core/load3d/Load3d'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useLoad3dService } from '@/services/load3dService'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
const { nodeMap, useLoad3dViewerMock, skeletonCloneMock } = vi.hoisted(() => ({
nodeMap: new Map<LGraphNode, Load3d>(),
useLoad3dViewerMock: vi.fn(),
skeletonCloneMock: vi.fn()
}))
vi.mock('@/composables/useLoad3d', () => ({
nodeToLoad3dMap: nodeMap
}))
vi.mock('@/composables/useLoad3dViewer', () => ({
useLoad3dViewer: useLoad3dViewerMock
}))
vi.mock('three/examples/jsm/utils/SkeletonUtils', () => ({
clone: skeletonCloneMock
}))
// Track every node a test creates so the load3dService singleton's
// internal viewerInstances map can be drained in beforeEach without
// reaching into the module's private state.
const createdNodes = new Set<LGraphNode>()
function makeNode(id: number | string): LGraphNode {
const node = createMockLGraphNode({ id })
createdNodes.add(node)
return node
}
function makeLoad3d(): Load3d {
return {
remove: vi.fn()
} as unknown as Load3d
}
function makeViewer(overrides: Record<string, unknown> = {}) {
return {
needApplyChanges: { value: false },
applyChanges: vi.fn().mockResolvedValue(true),
cleanup: vi.fn(),
...overrides
}
}
describe('load3dService', () => {
beforeEach(() => {
vi.clearAllMocks()
nodeMap.clear()
const svc = useLoad3dService()
for (const node of createdNodes) svc.removeViewer(node)
createdNodes.clear()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('singleton', () => {
it('returns the same instance from useLoad3dService()', () => {
expect(useLoad3dService()).toBe(useLoad3dService())
})
})
describe('getLoad3d (sync)', () => {
it('returns null when the load3d module has not been loaded yet', () => {
// Before any async accessor has been called, the cache is empty.
// We can't easily simulate "module never loaded" because vi.mock makes
// it eagerly available, so this test verifies the behavior via missing
// entries instead.
const node = makeNode('missing')
expect(useLoad3dService().getLoad3d(node)).toBeNull()
})
it('returns null after async load when the node has no entry in the map', async () => {
const svc = useLoad3dService()
// Trigger the async loader so the sync path has a populated cache.
await svc.getLoad3dAsync(makeNode('anything'))
expect(svc.getLoad3d(makeNode('still-missing'))).toBeNull()
})
it('returns the registered Load3d instance once the map has been populated', async () => {
const svc = useLoad3dService()
const node = makeNode('a')
const load3d = makeLoad3d()
nodeMap.set(node, load3d)
await svc.getLoad3dAsync(node)
expect(svc.getLoad3d(node)).toBe(load3d)
})
})
describe('getLoad3dAsync', () => {
it('returns the Load3d for a registered node', async () => {
const svc = useLoad3dService()
const node = makeNode('async-a')
const load3d = makeLoad3d()
nodeMap.set(node, load3d)
await expect(svc.getLoad3dAsync(node)).resolves.toBe(load3d)
})
it('returns null for an unregistered node', async () => {
const svc = useLoad3dService()
await expect(
svc.getLoad3dAsync(makeNode('async-missing'))
).resolves.toBeNull()
})
})
describe('getNodeByLoad3d', () => {
it('finds the node owning a given Load3d instance', async () => {
const svc = useLoad3dService()
const node = makeNode('owner')
const load3d = makeLoad3d()
nodeMap.set(node, load3d)
await svc.getLoad3dAsync(node)
expect(svc.getNodeByLoad3d(load3d)).toBe(node)
})
it('returns null when the Load3d instance is not in the map', async () => {
const svc = useLoad3dService()
await svc.getLoad3dAsync(makeNode('warmup'))
expect(svc.getNodeByLoad3d(makeLoad3d())).toBeNull()
})
})
describe('removeLoad3d', () => {
it('calls remove() on the instance and drops it from the map', async () => {
const svc = useLoad3dService()
const node = makeNode('to-remove')
const load3d = makeLoad3d()
nodeMap.set(node, load3d)
await svc.getLoad3dAsync(node)
svc.removeLoad3d(node)
expect(load3d.remove).toHaveBeenCalled()
expect(nodeMap.has(node)).toBe(false)
})
it('is a no-op when the node has no registered Load3d', async () => {
const svc = useLoad3dService()
await svc.getLoad3dAsync(makeNode('warmup'))
expect(() => svc.removeLoad3d(makeNode('not-there'))).not.toThrow()
})
})
describe('clear', () => {
it('removes every registered Load3d', async () => {
const svc = useLoad3dService()
const a = makeNode('a')
const b = makeNode('b')
const ld1 = makeLoad3d()
const ld2 = makeLoad3d()
nodeMap.set(a, ld1)
nodeMap.set(b, ld2)
await svc.getLoad3dAsync(a)
svc.clear()
expect(nodeMap.size).toBe(0)
expect(ld1.remove).toHaveBeenCalled()
expect(ld2.remove).toHaveBeenCalled()
})
})
describe('viewer lifecycle', () => {
it('getOrCreateViewer creates a viewer on first call and reuses it on subsequent calls', async () => {
const svc = useLoad3dService()
const node = makeNode('v1')
const viewer = makeViewer()
useLoad3dViewerMock.mockReturnValue(viewer)
const first = await svc.getOrCreateViewer(node)
const second = await svc.getOrCreateViewer(node)
expect(first).toBe(viewer)
expect(second).toBe(viewer)
expect(useLoad3dViewerMock).toHaveBeenCalledTimes(1)
expect(useLoad3dViewerMock).toHaveBeenCalledWith(node)
})
it('getOrCreateViewerSync uses the supplied factory once and caches the result', () => {
const svc = useLoad3dService()
const node = makeNode('v-sync')
const viewer = makeViewer()
const factory = vi.fn().mockReturnValue(viewer)
const first = svc.getOrCreateViewerSync(
node,
factory as unknown as typeof useLoad3dViewerMock
)
const second = svc.getOrCreateViewerSync(
node,
factory as unknown as typeof useLoad3dViewerMock
)
expect(first).toBe(viewer)
expect(second).toBe(viewer)
expect(factory).toHaveBeenCalledTimes(1)
})
it('removeViewer calls cleanup and forgets the viewer', async () => {
const svc = useLoad3dService()
const node = makeNode('v2')
const viewer = makeViewer()
useLoad3dViewerMock.mockReturnValue(viewer)
await svc.getOrCreateViewer(node)
svc.removeViewer(node)
expect(viewer.cleanup).toHaveBeenCalled()
useLoad3dViewerMock.mockClear()
const fresh = makeViewer()
useLoad3dViewerMock.mockReturnValue(fresh)
const result = await svc.getOrCreateViewer(node)
expect(useLoad3dViewerMock).toHaveBeenCalledTimes(1)
expect(result).toBe(fresh)
})
it('removeViewer is safe when no viewer has been created for the node', () => {
const svc = useLoad3dService()
expect(() => svc.removeViewer(makeNode('never'))).not.toThrow()
})
})
describe('handleViewerClose', () => {
it('removes the viewer without applying changes when none are pending', async () => {
const svc = useLoad3dService()
const node = makeNode('close-clean')
const viewer = makeViewer({ needApplyChanges: { value: false } })
useLoad3dViewerMock.mockReturnValue(viewer)
await svc.getOrCreateViewer(node)
await svc.handleViewerClose(node)
expect(viewer.applyChanges).not.toHaveBeenCalled()
expect(viewer.cleanup).toHaveBeenCalled()
})
it('applies changes and syncs the node config when changes are pending', async () => {
const svc = useLoad3dService()
const syncLoad3dConfig = vi.fn()
const node = Object.assign(makeNode('close-dirty'), {
syncLoad3dConfig
}) as LGraphNode
const viewer = makeViewer({ needApplyChanges: { value: true } })
useLoad3dViewerMock.mockReturnValue(viewer)
await svc.getOrCreateViewer(node)
await svc.handleViewerClose(node)
expect(viewer.applyChanges).toHaveBeenCalled()
expect(syncLoad3dConfig).toHaveBeenCalled()
expect(viewer.cleanup).toHaveBeenCalled()
})
it('skips syncLoad3dConfig when the node does not define it', async () => {
const svc = useLoad3dService()
const node = makeNode('close-no-sync')
const viewer = makeViewer({ needApplyChanges: { value: true } })
useLoad3dViewerMock.mockReturnValue(viewer)
await svc.getOrCreateViewer(node)
await expect(svc.handleViewerClose(node)).resolves.toBeUndefined()
expect(viewer.applyChanges).toHaveBeenCalled()
expect(viewer.cleanup).toHaveBeenCalled()
})
})
describe('handleViewportRefresh', () => {
it('returns silently when the load3d is null', () => {
expect(() => useLoad3dService().handleViewportRefresh(null)).not.toThrow()
})
it('toggles the camera through the opposite type and back, then updates controls', () => {
const controls = { update: vi.fn() }
const load3d = {
handleResize: vi.fn(),
getCurrentCameraType: vi.fn().mockReturnValue('perspective'),
toggleCamera: vi.fn(),
getControlsManager: vi.fn().mockReturnValue({ controls })
} as unknown as Load3d
useLoad3dService().handleViewportRefresh(load3d)
expect(load3d.handleResize).toHaveBeenCalled()
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(1, 'orthographic')
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(2, 'perspective')
expect(controls.update).toHaveBeenCalled()
})
it('toggles in the reverse direction when starting from orthographic', () => {
const controls = { update: vi.fn() }
const load3d = {
handleResize: vi.fn(),
getCurrentCameraType: vi.fn().mockReturnValue('orthographic'),
toggleCamera: vi.fn(),
getControlsManager: vi.fn().mockReturnValue({ controls })
} as unknown as Load3d
useLoad3dService().handleViewportRefresh(load3d)
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(1, 'perspective')
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(2, 'orthographic')
})
})
describe('copyLoad3dState', () => {
type SourceOverrides = Partial<{
currentModel: THREE.Object3D | null
isSplat: boolean
originalURL: string | null
originalModel: unknown
materialMode: string
currentUpDirection: string
appliedTexture: unknown
gizmoEnabled: boolean
hasAnimations: boolean
cameraType: 'perspective' | 'orthographic'
backgroundInfo: { type: 'image' | 'color' }
lightsIntensity: number | undefined
fov: number
}>
function makeSource(overrides: SourceOverrides = {}): Load3d {
const {
currentModel = null,
isSplat = false,
originalURL = null,
originalModel = null,
materialMode = 'original',
currentUpDirection = 'original',
appliedTexture = null,
gizmoEnabled = false,
hasAnimations = false,
cameraType = 'perspective',
backgroundInfo = { type: 'color' },
lightsIntensity = 0.8,
fov = 35
} = overrides
const ambient = { intensity: 0.5 }
const main = { intensity: lightsIntensity }
return {
modelManager: { currentModel, originalURL },
getGizmoManager: () => ({
isEnabled: () => gizmoEnabled,
getInitialTransform: () => ({
position: { x: 1, y: 2, z: 3 },
rotation: { x: 0.1, y: 0.2, z: 0.3 },
scale: { x: 4, y: 5, z: 6 }
})
}),
isSplatModel: () => isSplat,
getModelManager: () => ({
originalModel,
materialMode,
currentUpDirection,
appliedTexture
}),
getGizmoTransform: () => ({
position: { x: 7, y: 8, z: 9 },
rotation: { x: 0.4, y: 0.5, z: 0.6 },
scale: { x: 10, y: 11, z: 12 }
}),
hasAnimations: () => hasAnimations,
getCurrentCameraType: () => cameraType,
getCameraState: () => ({ snapshot: true }),
getSceneManager: () => ({
scene: new THREE.Scene(),
currentBackgroundColor: '#abcdef',
gridHelper: { visible: true },
getCurrentBackgroundInfo: () => backgroundInfo
}),
getLightingManager: () => ({ lights: [ambient, main] }),
getCameraManager: () => ({ perspectiveCamera: { fov } })
} as unknown as Load3d
}
type TargetState = {
modelManager: {
currentModel: THREE.Object3D | null
originalModel: unknown
materialMode: string
currentUpDirection: string
appliedTexture: unknown
}
gizmoManager: {
isEnabled: () => boolean
detach: ReturnType<typeof vi.fn>
setupForModel: ReturnType<typeof vi.fn>
}
animationManager: {
setupModelAnimations: ReturnType<typeof vi.fn>
}
sceneRemoved: THREE.Object3D[]
sceneAdded: THREE.Object3D[]
}
function makeTarget(
opts: {
gizmoEnabled?: boolean
existingModel?: THREE.Object3D | null
} = {}
) {
const { gizmoEnabled = false, existingModel = null } = opts
const scene = new THREE.Scene()
const sceneRemoved: THREE.Object3D[] = []
const sceneAdded: THREE.Object3D[] = []
const sceneRemove = vi.fn((o: THREE.Object3D) => {
sceneRemoved.push(o)
scene.remove(o)
})
const sceneAdd = vi.fn((o: THREE.Object3D) => {
sceneAdded.push(o)
scene.add(o)
})
const modelManager = {
currentModel: existingModel as THREE.Object3D | null,
originalModel: null as unknown,
materialMode: 'original',
currentUpDirection: 'original',
appliedTexture: null as unknown
}
const animationManager = {
setupModelAnimations: vi.fn()
}
// Memoize the gizmo manager so production code's repeated
// `target.getGizmoManager()` calls reach the same vi.fn instances.
const gizmoManager = {
isEnabled: () => gizmoEnabled,
detach: vi.fn(),
setupForModel: vi.fn()
}
const target = {
getGizmoManager: () => gizmoManager,
getModelManager: () => modelManager,
getSceneManager: () => ({
scene: {
add: sceneAdd,
remove: sceneRemove
} as unknown as THREE.Scene
}),
loadModel: vi.fn().mockResolvedValue(undefined),
setMaterialMode: vi.fn(),
setUpDirection: vi.fn(),
applyGizmoTransform: vi.fn(),
setGizmoEnabled: vi.fn(),
animationManager,
toggleCamera: vi.fn(),
setCameraState: vi.fn(),
setBackgroundColor: vi.fn(),
toggleGrid: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setLightIntensity: vi.fn(),
setFOV: vi.fn()
} as unknown as Load3d
const state: TargetState = {
modelManager,
gizmoManager,
animationManager,
sceneRemoved,
sceneAdded
}
return { target, state }
}
function makeModel(): THREE.Object3D {
return new THREE.Object3D()
}
it('copies camera/scene/lighting/FOV even when there is no source model', async () => {
const source = makeSource({ currentModel: null, lightsIntensity: 2 })
const { target } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.toggleCamera).toHaveBeenCalledWith('perspective')
expect(target.setCameraState).toHaveBeenCalledWith({ snapshot: true })
expect(target.setBackgroundColor).toHaveBeenCalledWith('#abcdef')
expect(target.toggleGrid).toHaveBeenCalledWith(true)
expect(target.setLightIntensity).toHaveBeenCalledWith(2)
expect(target.setFOV).toHaveBeenCalledWith(35)
expect(skeletonCloneMock).not.toHaveBeenCalled()
expect(target.loadModel).not.toHaveBeenCalled()
})
it('uses target.loadModel(originalURL) for splat models, never invoking SkeletonUtils.clone', async () => {
const source = makeSource({
currentModel: makeModel(),
isSplat: true,
originalURL: 'http://example.com/scan.splat'
})
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.loadModel).toHaveBeenCalledWith(
'http://example.com/scan.splat'
)
expect(skeletonCloneMock).not.toHaveBeenCalled()
})
it('skips loadModel for splat models when originalURL is null', async () => {
const source = makeSource({
currentModel: makeModel(),
isSplat: true,
originalURL: null
})
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.loadModel).not.toHaveBeenCalled()
})
it('removes the target existing model from the scene before adding the clone', async () => {
const existing = makeModel()
existing.name = 'existing'
const source = makeSource({ currentModel: makeModel() })
const { target, state } = makeTarget({ existingModel: existing })
const clone = makeModel()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(state.sceneRemoved).toContain(existing)
expect(state.sceneAdded).toContain(clone)
})
it('clones the source model via SkeletonUtils and assigns it as the target current model', async () => {
const sourceModel = makeModel()
const clone = makeModel()
const source = makeSource({ currentModel: sourceModel })
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(skeletonCloneMock).toHaveBeenCalledWith(sourceModel)
expect(state.modelManager.currentModel).toBe(clone)
})
it('copies originalModel, material mode, up direction, and applied texture from source to target', async () => {
const sourceOriginal = { kind: 'gltf' }
const texture = { id: 'tex1' }
const source = makeSource({
currentModel: makeModel(),
originalModel: sourceOriginal,
materialMode: 'wireframe',
currentUpDirection: '+y',
appliedTexture: texture
})
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(state.modelManager.originalModel).toBe(sourceOriginal)
expect(state.modelManager.materialMode).toBe('wireframe')
expect(state.modelManager.currentUpDirection).toBe('+y')
expect(state.modelManager.appliedTexture).toBe(texture)
expect(target.setMaterialMode).toHaveBeenCalledWith('wireframe')
expect(target.setUpDirection).toHaveBeenCalledWith('+y')
})
it('positions the clone at the source initial transform', async () => {
const clone = makeModel()
const source = makeSource({ currentModel: makeModel() })
const { target } = makeTarget()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(clone.position.toArray()).toEqual([1, 2, 3])
expect(clone.rotation.toArray().slice(0, 3)).toEqual([0.1, 0.2, 0.3])
expect(clone.scale.toArray()).toEqual([4, 5, 6])
})
it('applies the source gizmo transform to the target', async () => {
const source = makeSource({ currentModel: makeModel() })
const { target } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.applyGizmoTransform).toHaveBeenCalledWith(
{ x: 7, y: 8, z: 9 },
{ x: 0.4, y: 0.5, z: 0.6 },
{ x: 10, y: 11, z: 12 }
)
})
it('enables the gizmo on target when the source had it enabled', async () => {
const source = makeSource({
currentModel: makeModel(),
gizmoEnabled: true
})
const { target } = makeTarget({ gizmoEnabled: false })
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setGizmoEnabled).toHaveBeenCalledWith(true)
})
it('enables the gizmo on target when the target previously had it enabled, even if source did not', async () => {
const source = makeSource({
currentModel: makeModel(),
gizmoEnabled: false
})
const { target } = makeTarget({ gizmoEnabled: true })
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setGizmoEnabled).toHaveBeenCalledWith(true)
})
it('does not enable the gizmo when neither side had it', async () => {
const source = makeSource({
currentModel: makeModel(),
gizmoEnabled: false
})
const { target } = makeTarget({ gizmoEnabled: false })
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setGizmoEnabled).not.toHaveBeenCalled()
})
it('forwards animation setup when the source has animations', async () => {
const sourceOriginal = { kind: 'gltf' }
const clone = makeModel()
const source = makeSource({
currentModel: makeModel(),
originalModel: sourceOriginal,
hasAnimations: true
})
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(state.animationManager.setupModelAnimations).toHaveBeenCalledWith(
clone,
sourceOriginal
)
})
it('does not forward animation setup when the source has none', async () => {
const source = makeSource({
currentModel: makeModel(),
hasAnimations: false
})
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(state.animationManager.setupModelAnimations).not.toHaveBeenCalled()
})
it('forwards an image background to setBackgroundImage when the source node has a configured path', async () => {
const node = createMockLGraphNode({
id: 'bg-source',
properties: { 'Scene Config': { backgroundImage: '3d/bg.png' } }
})
createdNodes.add(node)
const source = makeSource({ backgroundInfo: { type: 'image' } })
nodeMap.set(node, source)
// Warm the cache so `getNodeByLoad3d` finds the source.
await useLoad3dService().getLoad3dAsync(node)
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setBackgroundImage).toHaveBeenCalledWith('3d/bg.png')
})
it('clears the background when the source background type is not image', async () => {
const source = makeSource({ backgroundInfo: { type: 'color' } })
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setBackgroundImage).toHaveBeenCalledWith('')
})
it('falls back to setLightIntensity(1) when the second light intensity is falsy', async () => {
const source = makeSource({ lightsIntensity: 0 })
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setLightIntensity).toHaveBeenCalledWith(1)
})
it('skips setFOV when the source camera is orthographic', async () => {
const source = makeSource({ cameraType: 'orthographic' })
const { target } = makeTarget()
await useLoad3dService().copyLoad3dState(source, target)
expect(target.setFOV).not.toHaveBeenCalled()
})
it('always detaches the target gizmo at the start of the copy', async () => {
const source = makeSource({ currentModel: makeModel() })
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(makeModel())
await useLoad3dService().copyLoad3dState(source, target)
expect(state.gizmoManager.detach).toHaveBeenCalled()
})
it('calls setupForModel on the target gizmo with the freshly cloned model', async () => {
const clone = makeModel()
const source = makeSource({ currentModel: makeModel() })
const { target, state } = makeTarget()
skeletonCloneMock.mockReturnValue(clone)
await useLoad3dService().copyLoad3dState(source, target)
expect(state.gizmoManager.setupForModel).toHaveBeenCalledWith(clone)
})
})
})