Files
webeditor3d/tests/domain/runtime-project-scheduler.test.ts
Victor Giers f0d18442c2 auto-git:
[change] tests/domain/runtime-project-scheduler.test.ts
2026-04-23 02:39:07 +02:00

701 lines
20 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
applyControlEffectToResolvedState,
createActivateCameraRigOverrideControlEffect,
createActorControlTargetRef,
createCameraRigControlTargetRef,
createClearCameraRigOverrideControlEffect,
createDefaultResolvedControlSource,
createEmptyRuntimeResolvedControlState,
createLightControlTargetRef,
createFollowActorPathControlEffect,
createPlayActorAnimationControlEffect,
createSetActorPresenceControlEffect
} from "../../src/controls/control-surface";
import { createSetLightIntensityControlEffect } from "../../src/controls/control-surface";
import {
createEmptyProjectScheduler,
createProjectScheduleRoutine,
createProjectScheduleSelectedDaysSelection
} from "../../src/scheduler/project-scheduler";
import {
createEmptyProjectSequenceLibrary,
createProjectSequence
} from "../../src/sequencer/project-sequences";
import {
applyRuntimeProjectScheduleToControlState,
resolveRuntimeProjectScheduleState
} from "../../src/runtime-three/runtime-project-scheduler";
describe("runtime project scheduler", () => {
it("resolves cross-midnight actor routines and keeps unscheduled actors implicitly active", () => {
const actorTarget = createActorControlTargetRef("actor-night-watch");
const scheduler = createEmptyProjectScheduler();
const sequences = createEmptyProjectSequenceLibrary();
scheduler.routines["routine-night-watch"] = createProjectScheduleRoutine({
id: "routine-night-watch",
title: "Night Watch",
target: actorTarget,
days: createProjectScheduleSelectedDaysSelection(["monday"]),
startHour: 22,
endHour: 2,
effect: createSetActorPresenceControlEffect({
target: actorTarget,
active: true
})
});
expect(
resolveRuntimeProjectScheduleState({
scheduler,
sequences,
actorIds: ["actor-night-watch", "actor-always-on"],
dayNumber: 1,
timeOfDayHours: 23
}).actors
).toEqual(
expect.arrayContaining([
expect.objectContaining({
actorId: "actor-night-watch",
hasRules: true,
active: true,
activeRoutineId: "routine-night-watch",
activeRoutineTitle: "Night Watch"
}),
expect.objectContaining({
actorId: "actor-always-on",
hasRules: false,
active: true,
activeRoutineId: null,
activeRoutineTitle: null
})
])
);
expect(
resolveRuntimeProjectScheduleState({
scheduler,
sequences,
actorIds: ["actor-night-watch"],
dayNumber: 2,
timeOfDayHours: 1.5
}).actors[0]
).toEqual(
expect.objectContaining({
actorId: "actor-night-watch",
active: true,
activeRoutineTitle: "Night Watch"
})
);
expect(
resolveRuntimeProjectScheduleState({
scheduler,
sequences,
actorIds: ["actor-night-watch"],
dayNumber: 2,
timeOfDayHours: 3
}).actors[0]
).toEqual(
expect.objectContaining({
actorId: "actor-night-watch",
hasRules: true,
active: true,
activeRoutineId: null,
activeRoutineTitle: null
})
);
});
it("prefers the highest-priority active routine and writes actor presence into resolved control state", () => {
const actorTarget = createActorControlTargetRef("actor-market-vendor");
const scheduler = createEmptyProjectScheduler();
const sequences = createEmptyProjectSequenceLibrary();
scheduler.routines["routine-open"] = createProjectScheduleRoutine({
id: "routine-open",
title: "Open Stall",
target: actorTarget,
startHour: 9,
endHour: 18,
priority: 0,
effect: createSetActorPresenceControlEffect({
target: actorTarget,
active: true
})
});
scheduler.routines["routine-break"] = createProjectScheduleRoutine({
id: "routine-break",
title: "Lunch Break",
target: actorTarget,
startHour: 12,
endHour: 13.5,
priority: 5,
effect: createSetActorPresenceControlEffect({
target: actorTarget,
active: false
})
});
const resolvedSchedule = resolveRuntimeProjectScheduleState({
scheduler,
sequences,
actorIds: ["actor-market-vendor"],
dayNumber: 1,
timeOfDayHours: 12.25
});
const resolvedControl = applyRuntimeProjectScheduleToControlState(
createEmptyRuntimeResolvedControlState(),
resolvedSchedule
);
expect(resolvedSchedule.actors[0]).toEqual(
expect.objectContaining({
actorId: "actor-market-vendor",
active: false,
activeRoutineId: "routine-break",
activeRoutineTitle: "Lunch Break"
})
);
expect(resolvedControl.discrete).toEqual([
expect.objectContaining({
type: "actorPresence",
target: {
kind: "actor",
actorId: "actor-market-vendor"
},
value: false,
source: {
kind: "scheduler",
scheduleId: "routine-break"
}
})
]);
});
it("resolves actor held steps from referenced project sequences", () => {
const actorTarget = createActorControlTargetRef("actor-sequenced-vendor");
const scheduler = createEmptyProjectScheduler();
const sequences = createEmptyProjectSequenceLibrary();
sequences.sequences["sequence-vendor-open"] = createProjectSequence({
id: "sequence-vendor-open",
title: "Vendor Open Sequence",
steps: [
{
stepClass: "held",
type: "controlEffect",
effect: createPlayActorAnimationControlEffect({
target: actorTarget,
clipName: "Wave",
loop: true
})
}
]
});
scheduler.routines["routine-vendor-open"] = createProjectScheduleRoutine({
id: "routine-vendor-open",
title: "Vendor Open",
target: actorTarget,
startHour: 8,
endHour: 16,
sequenceId: "sequence-vendor-open",
effects: []
});
const resolved = resolveRuntimeProjectScheduleState({
scheduler,
sequences,
actorIds: ["actor-sequenced-vendor"],
dayNumber: 1,
timeOfDayHours: 10
});
expect(resolved.actors[0]).toEqual(
expect.objectContaining({
actorId: "actor-sequenced-vendor",
active: true,
activeRoutineId: "routine-vendor-open",
activeRoutineTitle: "Vendor Open",
animationEffect: expect.objectContaining({
type: "playActorAnimation",
clipName: "Wave",
loop: true
})
})
);
});
it("keeps actors visible outside active placements when they have sequence rules", () => {
const actorTarget = createActorControlTargetRef("actor-patrol");
const scheduler = createEmptyProjectScheduler();
const sequences = createEmptyProjectSequenceLibrary();
sequences.sequences["sequence-patrol"] = createProjectSequence({
id: "sequence-patrol",
title: "Patrol",
steps: [
{
stepClass: "held",
type: "controlEffect",
effect: createFollowActorPathControlEffect({
target: actorTarget,
pathId: "path-a",
speed: 1,
loop: false,
progressMode: "deriveFromTime"
})
}
]
});
scheduler.routines["routine-patrol"] = createProjectScheduleRoutine({
id: "routine-patrol",
title: "Patrol",
target: actorTarget,
startHour: 8,
endHour: 10,
sequenceId: "sequence-patrol",
effects: []
});
const resolved = resolveRuntimeProjectScheduleState({
scheduler,
sequences,
actorIds: ["actor-patrol"],
dayNumber: 1,
timeOfDayHours: 12,
pathsById: new Map([
[
"path-a",
{
id: "path-a",
loop: false,
points: [
{
position: { x: 0, y: 0, z: 0 }
},
{
position: { x: 4, y: 0, z: 0 }
}
],
segments: [
{
start: { x: 0, y: 0, z: 0 },
end: { x: 4, y: 0, z: 0 },
length: 4,
distanceStart: 0,
distanceEnd: 4,
tangent: { x: 1, y: 0, z: 0 }
}
],
totalLength: 4
}
]
])
});
expect(resolved.actors[0]).toEqual(
expect.objectContaining({
actorId: "actor-patrol",
hasRules: true,
active: true,
activeRoutineId: null,
activeRoutineTitle: null,
pathEffect: expect.objectContaining({
type: "followActorPath",
pathId: "path-a"
}),
resolvedPath: expect.objectContaining({
pathId: "path-a",
progress: 0.5,
position: { x: 2, y: 0, z: 0 }
})
})
);
});
it("applies non-actor scheduler effects over baseline control state and restores defaults when inactive", () => {
const lightTarget = createLightControlTargetRef(
"pointLight",
"entity-point-light-main"
);
const scheduler = createEmptyProjectScheduler();
const sequences = createEmptyProjectSequenceLibrary();
scheduler.routines["routine-night-light"] = createProjectScheduleRoutine({
id: "routine-night-light",
title: "Night Light",
target: lightTarget,
startHour: 18,
endHour: 6,
effect: createSetLightIntensityControlEffect({
target: lightTarget,
intensity: 3.5
})
});
const baselineResolved = applyControlEffectToResolvedState(
createEmptyRuntimeResolvedControlState(),
createSetLightIntensityControlEffect({
target: lightTarget,
intensity: 1.25
}),
createDefaultResolvedControlSource()
);
const activeSchedule = resolveRuntimeProjectScheduleState({
scheduler,
sequences,
actorIds: [],
dayNumber: 1,
timeOfDayHours: 21
});
const activeResolved = applyRuntimeProjectScheduleToControlState(
baselineResolved,
activeSchedule,
baselineResolved
);
const inactiveSchedule = resolveRuntimeProjectScheduleState({
scheduler,
sequences,
actorIds: [],
dayNumber: 2,
timeOfDayHours: 9
});
const inactiveResolved = applyRuntimeProjectScheduleToControlState(
activeResolved,
inactiveSchedule,
baselineResolved
);
expect(activeSchedule.controls).toEqual([
expect.objectContaining({
routineId: "routine-night-light",
title: "Night Light",
resolutionKey: "channel:light.intensity:entity:pointLight:entity-point-light-main"
})
]);
expect(activeResolved.channels).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "lightIntensity",
value: 3.5,
source: {
kind: "scheduler",
scheduleId: "routine-night-light"
}
})
])
);
expect(inactiveResolved.channels).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "lightIntensity",
value: 1.25,
source: {
kind: "default"
}
})
])
);
});
it("applies camera rig override scheduler effects over baseline control state and restores fallback when inactive", () => {
const cameraRigTarget = createCameraRigControlTargetRef(
"entity-camera-rig-main"
);
const scheduler = createEmptyProjectScheduler();
const sequences = createEmptyProjectSequenceLibrary();
scheduler.routines["routine-overlook-camera"] = createProjectScheduleRoutine({
id: "routine-overlook-camera",
title: "Overlook Camera",
target: cameraRigTarget,
startHour: 18,
endHour: 20,
effect: createActivateCameraRigOverrideControlEffect({
target: cameraRigTarget
})
});
const baselineResolved = applyControlEffectToResolvedState(
createEmptyRuntimeResolvedControlState(),
createClearCameraRigOverrideControlEffect({
target: cameraRigTarget
}),
createDefaultResolvedControlSource()
);
const activeSchedule = resolveRuntimeProjectScheduleState({
scheduler,
sequences,
actorIds: [],
dayNumber: 1,
timeOfDayHours: 19
});
const activeResolved = applyRuntimeProjectScheduleToControlState(
baselineResolved,
activeSchedule,
baselineResolved
);
const inactiveSchedule = resolveRuntimeProjectScheduleState({
scheduler,
sequences,
actorIds: [],
dayNumber: 1,
timeOfDayHours: 9
});
const inactiveResolved = applyRuntimeProjectScheduleToControlState(
activeResolved,
inactiveSchedule,
baselineResolved
);
expect(activeSchedule.controls).toEqual([
expect.objectContaining({
routineId: "routine-overlook-camera",
title: "Overlook Camera",
resolutionKey: "state:cameraRigOverride:global:project"
})
]);
expect(activeResolved.discrete).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "cameraRigOverride",
entityId: "entity-camera-rig-main",
source: {
kind: "scheduler",
scheduleId: "routine-overlook-camera"
}
})
])
);
expect(inactiveResolved.discrete).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "cameraRigOverride",
entityId: null,
source: {
kind: "default"
}
})
])
);
});
it("resolves actor animation and deterministic follow-path state from the active routine window", () => {
const actorTarget = createActorControlTargetRef("actor-patrol");
const scheduler = createEmptyProjectScheduler();
const sequences = createEmptyProjectSequenceLibrary();
scheduler.routines["routine-patrol"] = createProjectScheduleRoutine({
id: "routine-patrol",
title: "Patrolling",
target: actorTarget,
startHour: 22,
endHour: 2,
effects: [
createSetActorPresenceControlEffect({
target: actorTarget,
active: true
}),
createPlayActorAnimationControlEffect({
target: actorTarget,
clipName: "Walk",
loop: true
}),
createFollowActorPathControlEffect({
target: actorTarget,
pathId: "path-patrol",
speed: 2,
loop: false,
progressMode: "deriveFromTime"
})
]
});
const resolved = resolveRuntimeProjectScheduleState({
scheduler,
sequences,
actorIds: ["actor-patrol"],
dayNumber: 2,
timeOfDayHours: 1,
pathsById: new Map([
[
"path-patrol",
{
id: "path-patrol",
loop: false,
points: [
{
position: { x: 0, y: 0, z: 0 }
},
{
position: { x: 8, y: 0, z: 0 }
}
],
segments: [
{
start: { x: 0, y: 0, z: 0 },
end: { x: 8, y: 0, z: 0 },
length: 8,
distanceStart: 0,
distanceEnd: 8,
tangent: { x: 1, y: 0, z: 0 }
}
],
totalLength: 8
}
]
])
});
const actorState = resolved.actors[0];
const resolvedControl = applyRuntimeProjectScheduleToControlState(
createEmptyRuntimeResolvedControlState(),
resolved
);
expect(actorState).toEqual(
expect.objectContaining({
actorId: "actor-patrol",
active: true,
activeRoutineId: "routine-patrol",
activeRoutineTitle: "Patrolling",
animationEffect: expect.objectContaining({
type: "playActorAnimation",
clipName: "Walk"
}),
pathEffect: expect.objectContaining({
type: "followActorPath",
pathId: "path-patrol",
speed: 2
}),
resolvedPath: expect.objectContaining({
progress: 0.75,
elapsedHours: 3,
position: {
x: 6,
y: 0,
z: 0
},
yawDegrees: 90
})
})
);
expect(resolvedControl.discrete).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "actorPresence",
value: true,
source: {
kind: "scheduler",
scheduleId: "routine-patrol"
}
}),
expect.objectContaining({
type: "actorAnimationPlayback",
clipName: "Walk",
source: {
kind: "scheduler",
scheduleId: "routine-patrol"
}
}),
expect.objectContaining({
type: "actorPathAssignment",
pathId: "path-patrol",
speed: 2,
source: {
kind: "scheduler",
scheduleId: "routine-patrol"
}
})
])
);
});
it("samples followed actor paths with rounded corners when smoothPath is enabled", () => {
const actorTarget = createActorControlTargetRef("actor-smooth-guard");
const scheduler = createEmptyProjectScheduler();
const sequences = createEmptyProjectSequenceLibrary();
sequences.sequences["sequence-smooth-patrol"] = createProjectSequence({
id: "sequence-smooth-patrol",
title: "Smooth Patrol",
effects: [
{
stepClass: "held",
type: "controlEffect",
effect: createFollowActorPathControlEffect({
target: actorTarget,
pathId: "path-corner",
speed: 7,
loop: false,
smoothPath: true
})
}
]
});
scheduler.routines["routine-smooth-patrol"] = createProjectScheduleRoutine({
id: "routine-smooth-patrol",
title: "Smooth Patrol",
target: actorTarget,
startHour: 9,
endHour: 10,
sequenceId: "sequence-smooth-patrol"
});
const resolved = resolveRuntimeProjectScheduleState({
scheduler,
sequences,
actorIds: [actorTarget.actorId],
dayNumber: 1,
timeOfDayHours: 9.5,
pathsById: new Map([
[
"path-corner",
{
id: "path-corner",
loop: false,
points: [
{ position: { x: 0, y: 0, z: 0 } },
{ position: { x: 0, y: 0, z: 3 } },
{ position: { x: 4, y: 0, z: 3 } }
],
segments: [
{
start: { x: 0, y: 0, z: 0 },
end: { x: 0, y: 0, z: 3 },
length: 3,
distanceStart: 0,
distanceEnd: 3,
tangent: { x: 0, y: 0, z: 1 }
},
{
start: { x: 0, y: 0, z: 3 },
end: { x: 4, y: 0, z: 3 },
length: 4,
distanceStart: 3,
distanceEnd: 7,
tangent: { x: 1, y: 0, z: 0 }
}
],
totalLength: 7
}
]
])
});
expect(resolved.actors[0]).toEqual(
expect.objectContaining({
actorId: actorTarget.actorId,
pathEffect: expect.objectContaining({
type: "followActorPath",
smoothPath: true
}),
resolvedPath: expect.objectContaining({
smoothPath: true
})
})
);
expect(resolved.actors[0]!.resolvedPath!.position.x).toBeGreaterThan(0);
expect(resolved.actors[0]!.resolvedPath!.position.z).toBeLessThan(3);
expect(resolved.actors[0]!.resolvedPath!.tangent.x).toBeGreaterThan(0);
expect(resolved.actors[0]!.resolvedPath!.tangent.z).toBeGreaterThan(0);
});
});