diff --git a/tests/domain/interaction-links.validation.test.ts b/tests/domain/interaction-links.validation.test.ts index d7e69f47..a9a1392e 100644 --- a/tests/domain/interaction-links.validation.test.ts +++ b/tests/domain/interaction-links.validation.test.ts @@ -3,8 +3,14 @@ import { describe, expect, it } from "vitest"; import { createBoxBrush } from "../../src/document/brushes"; import { createEmptySceneDocument } from "../../src/document/scene-document"; import { validateSceneDocument } from "../../src/document/scene-document-validation"; +import { createModelInstance } from "../../src/assets/model-instances"; +import { createProjectAssetStorageKey, type ModelAssetRecord } from "../../src/assets/project-assets"; import { createInteractableEntity, createPlayerStartEntity, createTeleportTargetEntity, createTriggerVolumeEntity } from "../../src/entities/entity-instances"; -import { createTeleportPlayerInteractionLink, createToggleVisibilityInteractionLink } from "../../src/interactions/interaction-links"; +import { + createPlayAnimationInteractionLink, + createTeleportPlayerInteractionLink, + createToggleVisibilityInteractionLink +} from "../../src/interactions/interaction-links"; describe("interaction link validation", () => { it("accepts valid Trigger Volume and Interactable links", () => { @@ -125,4 +131,67 @@ describe("interaction link validation", () => { ]) ); }); + + it("detects playAnimation links that reference a missing clip on the target model asset", () => { + const modelAsset = { + id: "asset-model-animated", + kind: "model", + sourceName: "animated.glb", + mimeType: "model/gltf-binary", + storageKey: createProjectAssetStorageKey("asset-model-animated"), + byteLength: 1024, + metadata: { + kind: "model" as const, + format: "glb" as const, + sceneName: null, + nodeCount: 1, + meshCount: 1, + materialNames: [], + textureNames: [], + animationNames: ["Idle", "Run"], + boundingBox: null, + warnings: [] + } + } satisfies ModelAssetRecord; + const modelInstance = createModelInstance({ + id: "model-instance-animated", + assetId: modelAsset.id + }); + const triggerVolume = createTriggerVolumeEntity({ + id: "entity-trigger-main" + }); + + const document = { + ...createEmptySceneDocument(), + assets: { + [modelAsset.id]: modelAsset + }, + modelInstances: { + [modelInstance.id]: modelInstance + }, + entities: { + [triggerVolume.id]: triggerVolume + }, + interactionLinks: { + "link-play-missing-clip": createPlayAnimationInteractionLink({ + id: "link-play-missing-clip", + sourceEntityId: triggerVolume.id, + trigger: "enter", + targetModelInstanceId: modelInstance.id, + clipName: "Walk" + }) + } + }; + + const validation = validateSceneDocument(document); + + expect(validation.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "missing-play-animation-clip", + path: "interactionLinks.link-play-missing-clip.action.clipName" + }) + ]) + ); + }); }); diff --git a/tests/domain/runtime-interaction-system.test.ts b/tests/domain/runtime-interaction-system.test.ts index 9f3a4879..1b00da7c 100644 --- a/tests/domain/runtime-interaction-system.test.ts +++ b/tests/domain/runtime-interaction-system.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { createTeleportPlayerInteractionLink, createToggleVisibilityInteractionLink } from "../../src/interactions/interaction-links"; +import { + createPlayAnimationInteractionLink, + createTeleportPlayerInteractionLink, + createToggleVisibilityInteractionLink, + createStopAnimationInteractionLink +} from "../../src/interactions/interaction-links"; import { RuntimeInteractionSystem } from "../../src/runtime-three/runtime-interaction-system"; import type { RuntimeSceneDefinition } from "../../src/runtime-three/runtime-scene-build"; @@ -28,6 +33,10 @@ function createRuntimeSceneFixture(): RuntimeSceneDefinition { brushes: [], colliders: [], sceneBounds: null, + localLights: { + pointLights: [], + spotLights: [] + }, modelInstances: [], entities: { playerStarts: [], @@ -155,6 +164,49 @@ describe("RuntimeInteractionSystem", () => { expect(dispatches).toEqual(["link-teleport:entity-teleport-main:8"]); }); + it("dispatches animation actions with the authored target model instance and clip", () => { + const runtimeScene = createRuntimeSceneFixture(); + runtimeScene.interactionLinks = [ + createPlayAnimationInteractionLink({ + id: "link-play-animation", + sourceEntityId: "entity-interactable-console", + trigger: "click", + targetModelInstanceId: "model-instance-animated", + clipName: "Walk", + loop: false + }), + createStopAnimationInteractionLink({ + id: "link-stop-animation", + sourceEntityId: "entity-interactable-console", + trigger: "click", + targetModelInstanceId: "model-instance-animated" + }) + ]; + + const interactionSystem = new RuntimeInteractionSystem(); + const dispatches: string[] = []; + + interactionSystem.dispatchClickInteraction("entity-interactable-console", runtimeScene, { + teleportPlayer: () => { + throw new Error("Teleport should not dispatch in this fixture."); + }, + toggleBrushVisibility: () => { + throw new Error("Visibility should not dispatch in this fixture."); + }, + playAnimation: (instanceId, clipName, loop, link) => { + dispatches.push(`${link.id}:${instanceId}:${clipName}:${loop === false ? "once" : "loop"}`); + }, + stopAnimation: (instanceId, link) => { + dispatches.push(`${link.id}:${instanceId}`); + } + }); + + expect(dispatches).toEqual([ + "link-play-animation:model-instance-animated:Walk:once", + "link-stop-animation:model-instance-animated" + ]); + }); + it("dispatches visibility actions only when exiting an occupied Trigger Volume", () => { const runtimeScene = createRuntimeSceneFixture(); runtimeScene.interactionLinks = [ diff --git a/tests/serialization/scene-document-json.test.ts b/tests/serialization/scene-document-json.test.ts index 16f78f6c..7ad5c231 100644 --- a/tests/serialization/scene-document-json.test.ts +++ b/tests/serialization/scene-document-json.test.ts @@ -180,7 +180,8 @@ describe("scene document JSON", () => { }; document.world.background = { mode: "image", - assetId: imageAsset.id + assetId: imageAsset.id, + environmentIntensity: 0.75 }; expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual(document); @@ -630,7 +631,8 @@ describe("scene document JSON", () => { expect(migratedDocument.version).toBe(SCENE_DOCUMENT_VERSION); expect(migratedDocument.world.background).toEqual({ mode: "image", - assetId: imageAsset.id + assetId: imageAsset.id, + environmentIntensity: 0.5 }); expect(migratedDocument.entities[pointLight.id]).toEqual(pointLight); expect(migratedDocument.entities[spotLight.id]).toEqual(spotLight); @@ -792,7 +794,7 @@ describe("scene document JSON", () => { boundingBox: null, warnings: [] } - }; + } satisfies ModelAssetRecord; const migratedDocument = migrateSceneDocument({ version: 11,