diff --git a/src/engine/collision/collider-component.ts b/src/engine/collision/collider-component.ts index 65548f2c..aa2a3d26 100644 --- a/src/engine/collision/collider-component.ts +++ b/src/engine/collision/collider-component.ts @@ -10,7 +10,7 @@ import { CircleCollider } from './colliders/circle-collider'; import type { Collider } from './colliders/collider'; import { CompositeCollider } from './colliders/composite-collider'; import { PolygonCollider } from './colliders/polygon-collider'; -import type { EdgeCollider } from './colliders/edge-collider'; +import { EdgeCollider } from './colliders/edge-collider'; import { Shape } from './colliders/shape'; import { EventEmitter } from '../event-emitter'; import { Actor } from '../actor'; @@ -342,6 +342,12 @@ export class ColliderComponent extends Component { radius: collider.radius, offset: { x: collider.offset.x, y: collider.offset.y } } as CircleColliderData; + } else if (collider instanceof EdgeCollider) { + returnData.colliderType = 'edge'; + returnData.colliderData = { + start: { x: collider.begin.x, y: collider.begin.y }, + end: { x: collider.end.x, y: collider.end.y } + } as EdgeColliderData; } else if (collider instanceof CompositeCollider) { returnData.colliderType = 'composite'; const partsData: ColliderCreationData[] = []; @@ -365,6 +371,9 @@ export class ColliderComponent extends Component { } else if (data.colliderType === 'circle') { const circleData = data.colliderData as CircleColliderData; this.useCircleCollider(circleData.radius, Vector.Zero).offset = new Vector(circleData.offset?.x ?? 0, circleData.offset?.y ?? 0); + } else if (data.colliderType === 'edge') { + const edgeData = data.colliderData as EdgeColliderData; + this.useEdgeCollider(new Vector(edgeData.start.x, edgeData.start.y), new Vector(edgeData.end.x, edgeData.end.y)); } else if (data.colliderType === 'composite') { const compositeData = data.colliderData as CompositeColliderData; const parts: Collider[] = []; diff --git a/src/engine/interfaces/lifecycle-events.ts b/src/engine/interfaces/lifecycle-events.ts index 22b73de5..e60b3753 100644 --- a/src/engine/interfaces/lifecycle-events.ts +++ b/src/engine/interfaces/lifecycle-events.ts @@ -82,7 +82,7 @@ export interface _postupdate { * */ export function has_postupdate(a: any): a is _postupdate { - return !!a.onPostUpdate; + return !!a._postupdate; } export interface OnPostUpdate { diff --git a/src/spec/vitest/bezier-curve-spec.ts b/src/spec/vitest/bezier-curve-spec.ts new file mode 100644 index 00000000..23e0015d --- /dev/null +++ b/src/spec/vitest/bezier-curve-spec.ts @@ -0,0 +1,137 @@ +import * as ex from '@excalibur'; + +describe('A BezierCurve', () => { + it('exists', () => { + expect(ex.BezierCurve).toBeDefined(); + }); + + it('can be constructed with 4 control points', () => { + const curve = new ex.BezierCurve({ + controlPoints: [ex.vec(0, 0), ex.vec(0, 100), ex.vec(100, 100), ex.vec(100, 0)] + }); + expect(curve).toBeDefined(); + expect(curve.controlPoints).toEqual([ex.vec(0, 0), ex.vec(0, 100), ex.vec(100, 100), ex.vec(100, 0)]); + }); + + it('throws if not given exactly 4 control points', () => { + expect(() => { + new ex.BezierCurve({ + controlPoints: [ex.vec(0, 0), ex.vec(0, 100), ex.vec(100, 100)] as any + }); + }).toThrowError('Only cubic bezier curves are supported'); + }); + + it('defaults quality to 4', () => { + const curve = new ex.BezierCurve({ + controlPoints: [ex.vec(0, 0), ex.vec(0, 100), ex.vec(100, 100), ex.vec(100, 0)] + }); + expect(curve.quality).toBe(4); + }); + + it('can specify a quality', () => { + const curve = new ex.BezierCurve({ + controlPoints: [ex.vec(0, 0), ex.vec(0, 100), ex.vec(100, 100), ex.vec(100, 0)], + quality: 10 + }); + expect(curve.quality).toBe(10); + }); + + it('returns the start point at time 0 and the end point at time 1', () => { + const start = ex.vec(0, 0); + const end = ex.vec(100, 0); + const curve = new ex.BezierCurve({ + controlPoints: [start, ex.vec(0, 100), ex.vec(100, 100), end] + }); + + expect(curve.getPoint(0)).toBeVector(start); + expect(curve.getPoint(1)).toBeVector(end); + }); + + it('has a positive arc length for a non-degenerate curve', () => { + const curve = new ex.BezierCurve({ + controlPoints: [ex.vec(0, 0), ex.vec(0, 100), ex.vec(100, 100), ex.vec(100, 0)] + }); + + expect(curve.arcLength).toBeGreaterThan(0); + }); + + it('has zero arc length when all control points are the same', () => { + const same = ex.vec(50, 50); + const curve = new ex.BezierCurve({ + controlPoints: [same, same, same, same] + }); + + expect(curve.arcLength).toBeCloseTo(0, 10); + }); + + it('can set an individual control point', () => { + const curve = new ex.BezierCurve({ + controlPoints: [ex.vec(0, 0), ex.vec(0, 100), ex.vec(100, 100), ex.vec(100, 0)] + }); + const originalArcLength = curve.arcLength; + + curve.setControlPoint(3, ex.vec(1000, 1000)); + + expect(curve.controlPoints[3]).toBeVector(ex.vec(1000, 1000)); + expect(curve.arcLength).not.toBe(originalArcLength); + }); + + it('recalculates when control points are reassigned', () => { + const curve = new ex.BezierCurve({ + controlPoints: [ex.vec(0, 0), ex.vec(0, 100), ex.vec(100, 100), ex.vec(100, 0)] + }); + const originalArcLength = curve.arcLength; + + curve.controlPoints = [ex.vec(0, 0), ex.vec(0, 1000), ex.vec(1000, 1000), ex.vec(1000, 0)]; + + expect(curve.arcLength).not.toBe(originalArcLength); + }); + + it('can get the tangent along the curve', () => { + const curve = new ex.BezierCurve({ + controlPoints: [ex.vec(0, 0), ex.vec(0, 100), ex.vec(100, 100), ex.vec(100, 0)] + }); + + const tangent = curve.getTangent(0.5); + expect(tangent.magnitude).toBeCloseTo(1, 5); + }); + + it('can get the normal along the curve, perpendicular to the tangent', () => { + const curve = new ex.BezierCurve({ + controlPoints: [ex.vec(0, 0), ex.vec(0, 100), ex.vec(100, 100), ex.vec(100, 0)] + }); + + const tangent = curve.getTangent(0.5); + const normal = curve.getNormal(0.5); + expect(tangent.dot(normal)).toBeCloseTo(0, 5); + }); + + it('can get a uniformly distributed point, tangent, and normal along the curve', () => { + const curve = new ex.BezierCurve({ + controlPoints: [ex.vec(0, 0), ex.vec(0, 100), ex.vec(100, 100), ex.vec(100, 0)] + }); + + expect(curve.getUniformPoint(0)).toBeVector(curve.getPoint(0)); + expect(curve.getUniformPoint(1)).toBeVector(curve.getPoint(1)); + + const uniformTangent = curve.getUniformTangent(0.5); + expect(uniformTangent.magnitude).toBeCloseTo(1, 5); + + const uniformNormal = curve.getUniformNormal(0.5); + expect(uniformTangent.dot(uniformNormal)).toBeCloseTo(0, 5); + }); + + it('can be cloned', () => { + const curve = new ex.BezierCurve({ + controlPoints: [ex.vec(0, 0), ex.vec(0, 100), ex.vec(100, 100), ex.vec(100, 0)], + quality: 8 + }); + + const clone = curve.clone(); + + expect(clone).not.toBe(curve); + expect(clone.controlPoints).toEqual(curve.controlPoints); + expect(clone.quality).toBe(curve.quality); + expect(clone.arcLength).toBeCloseTo(curve.arcLength, 5); + }); +}); diff --git a/src/spec/vitest/collider-component-spec.ts b/src/spec/vitest/collider-component-spec.ts index 751c2b8d..441be187 100644 --- a/src/spec/vitest/collider-component-spec.ts +++ b/src/spec/vitest/collider-component-spec.ts @@ -102,4 +102,249 @@ describe('A ColliderComponent', () => { expect(comp.$colliderRemoved.notifyAll).toHaveBeenCalled(); expect(collider.owner).toBeNull(); }); + + it('returns null and warns once when set() is called with no collider on an owned component', () => { + const comp = new ex.ColliderComponent(ex.Shape.Circle(50)); + const e = new ex.Entity(); + e.addComponent(comp); + + const logger = ex.Logger.getInstance(); + vi.spyOn(logger, 'warnOnce'); + + const result = comp.set(undefined); + + expect(result).toBeNull(); + expect(comp.get()).toBeNull(); + expect(logger.warnOnce).toHaveBeenCalled(); + }); + + it('returns null and does not warn when set() is called with no collider and no owner', () => { + const comp = new ex.ColliderComponent(ex.Shape.Circle(50)); + const logger = ex.Logger.getInstance(); + vi.spyOn(logger, 'warnOnce'); + + const result = comp.set(undefined); + + expect(result).toBeNull(); + expect(logger.warnOnce).not.toHaveBeenCalled(); + }); + + it('bounds and localBounds default to an empty BoundingBox with no collider set', () => { + const comp = new ex.ColliderComponent(); + + expect(comp.bounds).toEqual(new ex.BoundingBox()); + expect(comp.localBounds).toEqual(new ex.BoundingBox()); + }); + + it('collide() returns no contacts if either side has no collider', () => { + const empty = new ex.ColliderComponent(); + const withCollider = new ex.ColliderComponent(ex.Shape.Circle(50)); + + expect(empty.collide(withCollider)).toEqual([]); + expect(withCollider.collide(empty)).toEqual([]); + }); + + describe('update()', () => { + it('is a no-op with no collider set', () => { + const comp = new ex.ColliderComponent(); + expect(() => comp.update()).not.toThrow(); + }); + + it('updates the collider transform when the owner has a TransformComponent', () => { + const collider = ex.Shape.Circle(10); + const comp = new ex.ColliderComponent(collider); + const e = new ex.Entity([new ex.TransformComponent(), comp]); + e.get(ex.TransformComponent).pos = ex.vec(50, 75); + + comp.update(); + + expect(collider.center).toBeVector(ex.vec(50, 75)); + }); + }); + + describe('use*Collider factory methods', () => { + it('useBoxCollider sets a box-shaped polygon collider', () => { + const comp = new ex.ColliderComponent(); + const collider = comp.useBoxCollider(100, 50); + + expect(comp.get()).toBe(collider); + expect(collider).toBeInstanceOf(ex.PolygonCollider); + expect(collider.bounds.width).toBe(100); + expect(collider.bounds.height).toBe(50); + }); + + it('usePolygonCollider sets a polygon collider', () => { + const comp = new ex.ColliderComponent(); + const points = [ex.vec(0, 0), ex.vec(10, 0), ex.vec(10, 10)]; + const collider = comp.usePolygonCollider(points); + + expect(comp.get()).toBe(collider); + expect(collider).toBeInstanceOf(ex.PolygonCollider); + }); + + it('useCircleCollider sets a circle collider', () => { + const comp = new ex.ColliderComponent(); + const collider = comp.useCircleCollider(25); + + expect(comp.get()).toBe(collider); + expect(collider).toBeInstanceOf(ex.CircleCollider); + expect(collider.radius).toBe(25); + }); + + it('useEdgeCollider sets an edge collider', () => { + const comp = new ex.ColliderComponent(); + const collider = comp.useEdgeCollider(ex.vec(0, 0), ex.vec(100, 0)); + + expect(comp.get()).toBe(collider); + expect(collider).toBeInstanceOf(ex.EdgeCollider); + }); + + it('useCompositeCollider sets a composite collider', () => { + const comp = new ex.ColliderComponent(); + const collider = comp.useCompositeCollider([ex.Shape.Circle(10), ex.Shape.Box(10, 10)]); + + expect(comp.get()).toBe(collider); + expect(collider).toBeInstanceOf(ex.CompositeCollider); + }); + }); + + describe('serialize()/deserialize()', () => { + it('round-trips a circle collider', () => { + const comp = new ex.ColliderComponent(); + comp.useCircleCollider(30, ex.vec(1, 2)); + const data = comp.serialize(); + + expect(data.colliderType).toBe('circle'); + + const roundTripped = new ex.ColliderComponent(); + roundTripped.deserialize(data); + + const original = comp.get() as ex.CircleCollider; + const restored = roundTripped.get() as ex.CircleCollider; + expect(restored.radius).toBe(original.radius); + expect(restored.offset).toEqual(original.offset); + }); + + it('round-trips a polygon collider', () => { + const comp = new ex.ColliderComponent(); + comp.usePolygonCollider([ex.vec(0, 0), ex.vec(10, 0), ex.vec(10, 10), ex.vec(0, 10)]); + const data = comp.serialize(); + + expect(data.colliderType).toBe('polygon'); + + const roundTripped = new ex.ColliderComponent(); + roundTripped.deserialize(data); + + const original = comp.get() as ex.PolygonCollider; + const restored = roundTripped.get() as ex.PolygonCollider; + expect(restored.points).toEqual(original.points); + }); + + it('round-trips a composite collider made of circle and polygon parts', () => { + const comp = new ex.ColliderComponent(); + comp.useCompositeCollider([ex.Shape.Circle(15), ex.Shape.Polygon([ex.vec(0, 0), ex.vec(5, 0), ex.vec(5, 5)])]); + const data = comp.serialize(); + + expect(data.colliderType).toBe('composite'); + + const roundTripped = new ex.ColliderComponent(); + roundTripped.deserialize(data); + + const restored = roundTripped.get() as ex.CompositeCollider; + expect(restored).toBeInstanceOf(ex.CompositeCollider); + expect(restored.getColliders().length).toBe(2); + }); + + it('serialize() with no collider set produces null collider type/data', () => { + const comp = new ex.ColliderComponent(); + const data = comp.serialize(); + + expect(data.colliderType).toBeNull(); + expect(data.colliderData).toBeNull(); + }); + + it('round-trips an edge collider', () => { + const comp = new ex.ColliderComponent(); + comp.useEdgeCollider(ex.vec(0, 0), ex.vec(100, 50)); + const data = comp.serialize(); + + expect(data.colliderType).toBe('edge'); + expect(data.colliderData).toEqual({ start: { x: 0, y: 0 }, end: { x: 100, y: 50 } }); + + const roundTripped = new ex.ColliderComponent(); + roundTripped.deserialize(data); + + const restored = roundTripped.get() as ex.EdgeCollider; + expect(restored).toBeInstanceOf(ex.EdgeCollider); + expect(restored.begin).toBeVector(ex.vec(0, 0)); + expect(restored.end).toBeVector(ex.vec(100, 50)); + }); + + it('round-trips a composite collider with an edge part', () => { + const comp = new ex.ColliderComponent(); + comp.useCompositeCollider([ex.Shape.Circle(15), ex.Shape.Edge(ex.vec(0, 0), ex.vec(20, 0))]); + const data = comp.serialize(); + + const roundTripped = new ex.ColliderComponent(); + roundTripped.deserialize(data); + + const restored = roundTripped.get() as ex.CompositeCollider; + const parts = restored.getColliders(); + expect(parts.length).toBe(2); + expect(parts.some((p) => p instanceof ex.EdgeCollider)).toBe(true); + }); + }); + + describe('event wiring on add', () => { + it('propagates precollision/postcollision/collisionstart/collisionend to the owning entity', () => { + const comp = new ex.ColliderComponent(ex.Shape.Circle(50)); + const e = new ex.Entity([comp]); + + const precollision = vi.fn(); + const postcollision = vi.fn(); + const collisionstart = vi.fn(); + const collisionend = vi.fn(); + e.events.on('precollision', precollision); + e.events.on('postcollision', postcollision); + e.events.on('collisionstart', collisionstart); + e.events.on('collisionend', collisionend); + + const other = ex.Shape.Circle(50); + const self = comp.get()!; + + comp.events.emit('precollision', new ex.PreCollisionEvent(self, other, ex.Side.None, ex.Vector.Zero, null as any)); + comp.events.emit('postcollision', new ex.PostCollisionEvent(self, other, ex.Side.None, ex.Vector.Zero, null as any)); + comp.events.emit('collisionstart', new ex.CollisionStartEvent(self, other, ex.Side.None, null as any)); + comp.events.emit('collisionend', new ex.CollisionEndEvent(self, other, ex.Side.None, null as any)); + + expect(precollision).toHaveBeenCalledTimes(1); + expect(postcollision).toHaveBeenCalledTimes(1); + expect(collisionstart).toHaveBeenCalledTimes(1); + expect(collisionend).toHaveBeenCalledTimes(1); + }); + + it('calls the Actor-specific collision resolve hooks when the owner is an Actor', () => { + const comp = new ex.ColliderComponent(ex.Shape.Circle(50)); + const actor = new ex.Actor({ collider: comp.get()! }); + actor.removeComponent(ex.ColliderComponent, true); + actor.addComponent(comp); + + vi.spyOn(actor, 'onPreCollisionResolve'); + vi.spyOn(actor, 'onPostCollisionResolve'); + vi.spyOn(actor, 'onCollisionStart'); + vi.spyOn(actor, 'onCollisionEnd'); + + const other = ex.Shape.Circle(50); + const self = comp.get()!; + comp.events.emit('precollision', new ex.PreCollisionEvent(self, other, ex.Side.None, ex.Vector.Zero, null as any)); + comp.events.emit('postcollision', new ex.PostCollisionEvent(self, other, ex.Side.None, ex.Vector.Zero, null as any)); + comp.events.emit('collisionstart', new ex.CollisionStartEvent(self, other, ex.Side.None, null as any)); + comp.events.emit('collisionend', new ex.CollisionEndEvent(self, other, ex.Side.None, null as any)); + + expect(actor.onPreCollisionResolve).toHaveBeenCalledTimes(1); + expect(actor.onPostCollisionResolve).toHaveBeenCalledTimes(1); + expect(actor.onCollisionStart).toHaveBeenCalledTimes(1); + expect(actor.onCollisionEnd).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/spec/vitest/color-spec.ts b/src/spec/vitest/color-spec.ts index bdfff0ca..4ededa27 100644 --- a/src/spec/vitest/color-spec.ts +++ b/src/spec/vitest/color-spec.ts @@ -330,4 +330,86 @@ describe('A color', () => { const hslBlue = color.toHSLA(); expect(hslBlue).toBe('hsla(240, 100%, 50%, 1)'); }); + + it('can be saturated', () => { + const color = ex.Color.fromHSL(0, 0.5, 0.5); + const saturated = color.saturate(0.5); + expect(saturated.toHSLA()).not.toBe(color.toHSLA()); + }); + + it('can be desaturated', () => { + const color = ex.Color.fromHSL(0, 0.5, 0.5); + const desaturated = color.desaturate(0.5); + expect(desaturated.toHSLA()).not.toBe(color.toHSLA()); + }); + + it('can throw on an invalid rgb string', () => { + expect(() => ex.Color.fromRGBString('not-a-color')).toThrowError('Invalid rgb/a string: not-a-color'); + }); + + it('can be compared for equality', () => { + expect(ex.Color.Red.equal(ex.Color.fromRGB(255, 0, 0))).toBe(true); + expect(ex.Color.Red.equal(ex.Color.Blue)).toBe(false); + }); + + it('produces a hashCode', () => { + const a = ex.Color.fromRGB(10, 20, 30, 1); + const b = ex.Color.fromRGB(10, 20, 30, 1); + const c = ex.Color.fromRGB(11, 20, 30, 1); + + expect(a.hashCode).toBe(b.hashCode); + expect(a.hashCode).not.toBe(c.hashCode); + }); + + it('fillStyle returns the same as toString', () => { + color = ex.Color.fromRGB(10, 20, 30, 0.5); + expect(color.fillStyle()).toBe(color.toString()); + }); + + it('can clone into a destination color', () => { + const source = ex.Color.fromRGB(10, 20, 30, 0.4); + const dest = new ex.Color(0, 0, 0); + + const result = source.clone(dest); + + expect(result).toBe(dest); + expect(dest.r).toBe(10); + expect(dest.g).toBe(20); + expect(dest.b).toBe(30); + expect(dest.a).toBe(0.4); + }); + + it('exposes the standard palette of named colors', () => { + const palette: [string, string][] = [ + ['Black', '#000000'], + ['White', '#ffffff'], + ['Gray', '#808080'], + ['LightGray', '#d3d3d3'], + ['DarkGray', '#a9a9a9'], + ['Yellow', '#ffff00'], + ['Orange', '#ffa500'], + ['Red', '#ff0000'], + ['Vermilion', '#ff5b31'], + ['Rose', '#ff007f'], + ['Pink', '#ffc0cb'], + ['Magenta', '#ff00ff'], + ['Violet', '#7f00ff'], + ['Purple', '#800080'], + ['Blue', '#0000ff'], + ['Azure', '#007fff'], + ['Cyan', '#00ffff'], + ['Viridian', '#59978f'], + ['Teal', '#008080'], + ['Green', '#00ff00'], + ['Chartreuse', '#7fff00'], + ['ExcaliburBlue', '#176baa'], + ['Brown', '#964b00'] + ]; + + for (const [name, hex] of palette) { + expect((ex.Color as any)[name].toHex(), name).toBe(hex); + } + + expect(ex.Color.Transparent.toHex()).toBe('#ffffff00'); + }); }); diff --git a/src/spec/vitest/dynamic-tree-broadphase-spec.ts b/src/spec/vitest/dynamic-tree-broadphase-spec.ts index 31bf959a..4f8c1e49 100644 --- a/src/spec/vitest/dynamic-tree-broadphase-spec.ts +++ b/src/spec/vitest/dynamic-tree-broadphase-spec.ts @@ -1,5 +1,6 @@ import * as ex from '@excalibur'; import { getDefaultPhysicsConfig } from '../../engine/collision/physics-config'; +import { TestUtils } from '../__util__/test-utils'; describe('A DynamicTree Broadphase', () => { let actorA: ex.Actor; @@ -248,6 +249,157 @@ describe('A DynamicTree Broadphase', () => { expect(hits[0].point).toEqual(ex.vec(275, 0)); }); + it('exposes tracked colliders via getColliders', () => { + const dt = new ex.DynamicTreeCollisionProcessor(getDefaultPhysicsConfig()); + dt.track(actorA.collider.get()); + dt.track(actorB.collider.get()); + + expect(dt.getColliders()).toEqual([actorA.collider.get(), actorB.collider.get()]); + }); + + it('can query by point', () => { + const dt = new ex.DynamicTreeCollisionProcessor(getDefaultPhysicsConfig()); + dt.track(actorA.collider.get()); + dt.track(actorB.collider.get()); + dt.track(actorC.collider.get()); + + const results = dt.query(ex.vec(0, 0)); + expect(results).toEqual([actorA.collider.get()]); + }); + + it('can query by bounds', () => { + const dt = new ex.DynamicTreeCollisionProcessor(getDefaultPhysicsConfig()); + dt.track(actorA.collider.get()); + dt.track(actorB.collider.get()); + dt.track(actorC.collider.get()); + + const results = dt.query(new ex.BoundingBox(-15, -15, 35, 15)); + expect(results.length).toBe(2); + expect(results).toEqual(expect.arrayContaining([actorA.collider.get(), actorB.collider.get()])); + }); + + it('warns and no-ops tracking a null collider', () => { + const dt = new ex.DynamicTreeCollisionProcessor(getDefaultPhysicsConfig()); + const logger = ex.Logger.getInstance(); + vi.spyOn(logger, 'warn'); + + dt.track(null as any); + + expect(logger.warn).toHaveBeenCalledWith('Cannot track null collider'); + expect(dt.getColliders()).toEqual([]); + }); + + it('warns and no-ops untracking a null collider', () => { + const dt = new ex.DynamicTreeCollisionProcessor(getDefaultPhysicsConfig()); + const logger = ex.Logger.getInstance(); + vi.spyOn(logger, 'warn'); + + dt.untrack(null as any); + + expect(logger.warn).toHaveBeenCalledWith('Cannot untrack a null collider'); + }); + + it('can untrack a collider', () => { + const dt = new ex.DynamicTreeCollisionProcessor(getDefaultPhysicsConfig()); + dt.track(actorA.collider.get()); + dt.track(actorB.collider.get()); + expect(dt.getColliders().length).toBe(2); + + expect(dt.query(ex.vec(0, 0))).toEqual([actorA.collider.get()]); + + dt.untrack(actorA.collider.get()); + + expect(dt.getColliders()).toEqual([actorB.collider.get()]); + // actorA's own position (0,0) is outside actorB's bounds (centered at 20,0), + // so the tree should no longer report anything there once actorA is untracked + expect(dt.query(ex.vec(0, 0))).toEqual([]); + }); + + it('can untrack a composite collider, removing every sub-collider', () => { + const circle = ex.Shape.Circle(50); + const box = ex.Shape.Box(200, 10); + const compCollider = new ex.CompositeCollider([circle, box]); + const actor = new ex.Actor({ collider: compCollider }); + const dt = new ex.DynamicTreeCollisionProcessor(getDefaultPhysicsConfig()); + dt.track(compCollider); + expect(dt.getColliders().length).toBe(2); + + dt.untrack(compCollider); + + expect(dt.getColliders()).toEqual([]); + }); + + it('applies narrowphase to collision pairs and returns actual contacts', () => { + const overlapping = new ex.Actor({ x: 0, y: 0, radius: 10, collisionType: ex.CollisionType.Active }); + const overlapping2 = new ex.Actor({ x: 15, y: 0, radius: 10, collisionType: ex.CollisionType.Active }); + const dt = new ex.DynamicTreeCollisionProcessor(getDefaultPhysicsConfig()); + dt.track(overlapping.collider.get()); + dt.track(overlapping2.collider.get()); + + const pairs = dt.broadphase([overlapping.collider.get(), overlapping2.collider.get()], 100); + expect(pairs.length).toBe(1); + + const contacts = dt.narrowphase(pairs); + expect(contacts.length).toBe(1); + }); + + it('narrowphase returns no contacts for non-overlapping pairs', () => { + const dt = new ex.DynamicTreeCollisionProcessor(getDefaultPhysicsConfig()); + dt.track(actorA.collider.get()); + dt.track(actorC.collider.get()); + + const contacts = dt.narrowphase([new ex.Pair(actorA.collider.get(), actorC.collider.get())]); + expect(contacts).toEqual([]); + }); + + it('update() reports how many tracked colliders actually moved', () => { + const dt = new ex.DynamicTreeCollisionProcessor(getDefaultPhysicsConfig()); + dt.track(actorA.collider.get()); + dt.track(actorB.collider.get()); + + // no movement yet + expect(dt.update([actorA.collider.get(), actorB.collider.get()])).toBe(0); + + actorA.pos = ex.vec(500, 500); + actorA.collider.update(); + + expect(dt.update([actorA.collider.get(), actorB.collider.get()])).toBe(1); + }); + + it('debug delegates to the underlying dynamic tree without throwing', () => { + const dt = new ex.DynamicTreeCollisionProcessor(getDefaultPhysicsConfig()); + dt.track(actorA.collider.get()); + const engine = TestUtils.engine({ width: 100, height: 100 }); + + expect(() => dt.debug(engine.graphicsContext)).not.toThrow(); + + engine.dispose(); + }); + + it('catches a fast moving object via continuous collision detection and repositions it on contact', () => { + const dt = new ex.DynamicTreeCollisionProcessor(getDefaultPhysicsConfig()); + const stationary = new ex.Actor({ x: 200, y: 0, radius: 10, collisionType: ex.CollisionType.Active }); + const fast = new ex.Actor({ x: 0, y: 0, radius: 10, collisionType: ex.CollisionType.Active }); + fast.body.vel = ex.vec(5000, 0); + + dt.track(stationary.collider.get()); + dt.track(fast.collider.get()); + + const stats = { physics: { pairs: 0, fastBodies: 0, fastBodyCollisions: 0, contacts: new Map(), collisions: 0 } } as any; + // naive integration (uncaught) would put the fast body at 5000 * (100/1000) = 500, + // tunneling straight through the stationary collider at x=200 + const naiveTunneledPosition = 500; + + dt.broadphase([stationary.collider.get(), fast.collider.get()], 100, stats); + + expect(stats.physics.fastBodies).toBe(1); + expect(stats.physics.fastBodyCollisions).toBe(1); + // continuous collision detection should have caught it and pulled it back to + // somewhere near the stationary collider's surface, short of tunneling through + expect(fast.body.globalPos.x).toBeGreaterThan(0); + expect(fast.body.globalPos.x).toBeLessThan(naiveTunneledPosition); + }); + it('can rayCast with filter and search all colliders false, returns 1 hit', () => { const sut = new ex.DynamicTreeCollisionProcessor({ ...getDefaultPhysicsConfig() diff --git a/src/spec/vitest/dynamic-tree-spec.ts b/src/spec/vitest/dynamic-tree-spec.ts new file mode 100644 index 00000000..8fe7c522 --- /dev/null +++ b/src/spec/vitest/dynamic-tree-spec.ts @@ -0,0 +1,236 @@ +import * as ex from '@excalibur'; +import { createId } from '../../engine/id'; +import type { ColliderProxy } from '../../engine/collision/detection/dynamic-tree'; +import { TestUtils } from '../__util__/test-utils'; + +function makeProxy(id: number, bounds: ex.BoundingBox): ColliderProxy { + return { + id: createId('collider', id), + owner: null, + bounds + }; +} + +const defaultConfig = { boundsPadding: 5, velocityMultiplier: 2 }; + +describe('A DynamicTree', () => { + it('exists', () => { + expect(ex.DynamicTree).toBeDefined(); + }); + + it('can be constructed with default world bounds', () => { + const tree = new ex.DynamicTree(defaultConfig); + expect(tree.root).toBeUndefined(); + expect(tree.getHeight()).toBe(0); + expect(tree.getNodes()).toEqual([]); + }); + + it('tracks a single collider as the root leaf', () => { + const tree = new ex.DynamicTree(defaultConfig); + const proxy = makeProxy(1, new ex.BoundingBox(0, 0, 10, 10)); + tree.trackCollider(proxy); + + expect(tree.root).toBeDefined(); + expect(tree.root!.isLeaf()).toBe(true); + expect(tree.root!.data).toBe(proxy); + expect(tree.getHeight()).toBe(0); + }); + + it('pads the bounds of a tracked collider by 2px on insert', () => { + const tree = new ex.DynamicTree(defaultConfig); + const proxy = makeProxy(1, new ex.BoundingBox(0, 0, 10, 10)); + tree.trackCollider(proxy); + + expect(tree.root!.bounds.left).toBe(-2); + expect(tree.root!.bounds.top).toBe(-2); + expect(tree.root!.bounds.right).toBe(12); + expect(tree.root!.bounds.bottom).toBe(12); + }); + + it('builds a tree that maintains height/bounds invariants across many inserts', () => { + const tree = new ex.DynamicTree(defaultConfig); + for (let i = 0; i < 30; i++) { + const x = (i % 6) * 100; + const y = Math.floor(i / 6) * 100; + tree.trackCollider(makeProxy(i, new ex.BoundingBox(x, y, x + 20, y + 20))); + } + + // 30 leaves in a full binary tree means 29 internal nodes = 59 nodes total + expect(tree.getNodes().length).toBe(59); + expect(tree.getHeight()).toBeGreaterThan(1); + + const verifyInvariants = (node?: ex.TreeNode) => { + if (!node) { + return; + } + if (node.isLeaf()) { + expect(node.height).toBe(0); + } else { + expect(node.height).toBe(1 + Math.max(node.left!.height, node.right!.height)); + // a parent's bounds must fully contain both children's bounds + expect(node.bounds.left).toBeLessThanOrEqual(node.left!.bounds.left); + expect(node.bounds.top).toBeLessThanOrEqual(node.left!.bounds.top); + expect(node.bounds.right).toBeGreaterThanOrEqual(node.left!.bounds.right); + expect(node.bounds.bottom).toBeGreaterThanOrEqual(node.left!.bounds.bottom); + + expect(node.bounds.left).toBeLessThanOrEqual(node.right!.bounds.left); + expect(node.bounds.top).toBeLessThanOrEqual(node.right!.bounds.top); + expect(node.bounds.right).toBeGreaterThanOrEqual(node.right!.bounds.right); + expect(node.bounds.bottom).toBeGreaterThanOrEqual(node.right!.bounds.bottom); + } + verifyInvariants(node.left); + verifyInvariants(node.right); + }; + verifyInvariants(tree.root); + }); + + it('untracks a collider, removing it from the tree', () => { + const tree = new ex.DynamicTree(defaultConfig); + const a = makeProxy(1, new ex.BoundingBox(0, 0, 10, 10)); + const b = makeProxy(2, new ex.BoundingBox(100, 100, 110, 110)); + tree.trackCollider(a); + tree.trackCollider(b); + expect(tree.getNodes().length).toBe(3); + + tree.untrackCollider(a); + expect(tree.getNodes().length).toBe(1); + expect(tree.root!.data).toBe(b); + }); + + it('untrackCollider is a no-op for an unknown collider', () => { + const tree = new ex.DynamicTree(defaultConfig); + const a = makeProxy(1, new ex.BoundingBox(0, 0, 10, 10)); + tree.trackCollider(a); + + const unknown = makeProxy(999, new ex.BoundingBox(0, 0, 1, 1)); + expect(() => tree.untrackCollider(unknown)).not.toThrow(); + expect(tree.getNodes().length).toBe(1); + }); + + describe('updateCollider', () => { + it('returns false for an untracked collider', () => { + const tree = new ex.DynamicTree(defaultConfig); + const unknown = makeProxy(1, new ex.BoundingBox(0, 0, 10, 10)); + expect(tree.updateCollider(unknown)).toBe(false); + }); + + it('returns false and does not reinsert if the new bounds are still contained by the padded node bounds', () => { + const tree = new ex.DynamicTree(defaultConfig); + const proxy = makeProxy(1, new ex.BoundingBox(0, 0, 10, 10)); + tree.trackCollider(proxy); + const originalNode = tree.root; + + // tiny move within the 2px padding applied at insert time + proxy.bounds = new ex.BoundingBox(0.5, 0.5, 10.5, 10.5); + expect(tree.updateCollider(proxy)).toBe(false); + expect(tree.root).toBe(originalNode); + }); + + it('returns true and repositions the node when bounds move outside the padded bounds', () => { + const tree = new ex.DynamicTree(defaultConfig); + const proxy = makeProxy(1, new ex.BoundingBox(0, 0, 10, 10)); + tree.trackCollider(proxy); + + proxy.bounds = new ex.BoundingBox(500, 500, 510, 510); + expect(tree.updateCollider(proxy)).toBe(true); + expect(tree.root!.bounds.left).toBeLessThanOrEqual(500); + expect(tree.root!.bounds.right).toBeGreaterThanOrEqual(510); + }); + + it('untracks and warns when a collider moves outside the tree world bounds', () => { + const tree = new ex.DynamicTree(defaultConfig, new ex.BoundingBox(0, 0, 100, 100)); + const proxy = makeProxy(1, new ex.BoundingBox(0, 0, 10, 10)); + tree.trackCollider(proxy); + + const logger = ex.Logger.getInstance(); + vi.spyOn(logger, 'warn'); + + proxy.bounds = new ex.BoundingBox(1000, 1000, 1010, 1010); + const result = tree.updateCollider(proxy); + + expect(result).toBe(false); + expect(logger.warn).toHaveBeenCalled(); + expect(tree.getNodes().length).toBe(0); + }); + }); + + describe('query', () => { + it('finds overlapping colliders excluding itself', () => { + const tree = new ex.DynamicTree(defaultConfig); + const a = makeProxy(1, new ex.BoundingBox(0, 0, 10, 10)); + const b = makeProxy(2, new ex.BoundingBox(5, 5, 15, 15)); + const c = makeProxy(3, new ex.BoundingBox(1000, 1000, 1010, 1010)); + tree.trackCollider(a); + tree.trackCollider(b); + tree.trackCollider(c); + + const found: any[] = []; + tree.query(a, (other) => { + found.push(other); + return false; + }); + + expect(found).toEqual([b]); + }); + + it('stops searching once the callback returns true', () => { + const tree = new ex.DynamicTree(defaultConfig); + const a = makeProxy(1, new ex.BoundingBox(0, 0, 100, 100)); + const b = makeProxy(2, new ex.BoundingBox(0, 0, 100, 100)); + const c = makeProxy(3, new ex.BoundingBox(0, 0, 100, 100)); + tree.trackCollider(a); + tree.trackCollider(b); + tree.trackCollider(c); + + const callback = vi.fn(() => true); + tree.query(a, callback); + + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + describe('rayCastQuery', () => { + it('finds colliders intersecting the ray', () => { + const tree = new ex.DynamicTree(defaultConfig); + const a = makeProxy(1, new ex.BoundingBox(50, -10, 70, 10)); + const b = makeProxy(2, new ex.BoundingBox(1000, 1000, 1010, 1010)); + tree.trackCollider(a); + tree.trackCollider(b); + + const ray = new ex.Ray(ex.vec(0, 0), ex.Vector.Right); + const hits: any[] = []; + tree.rayCastQuery(ray, Infinity, (other) => { + hits.push(other); + return false; + }); + + expect(hits).toEqual([a]); + }); + + it('respects the max distance', () => { + const tree = new ex.DynamicTree(defaultConfig); + const a = makeProxy(1, new ex.BoundingBox(500, -10, 520, 10)); + tree.trackCollider(a); + + const ray = new ex.Ray(ex.vec(0, 0), ex.Vector.Right); + const hits: any[] = []; + tree.rayCastQuery(ray, 10, (other) => { + hits.push(other); + return false; + }); + + expect(hits).toEqual([]); + }); + }); + + it('debug draws leaf and internal node bounds without throwing', () => { + const engine = TestUtils.engine({ width: 100, height: 100 }); + const tree = new ex.DynamicTree(defaultConfig); + tree.trackCollider(makeProxy(1, new ex.BoundingBox(0, 0, 10, 10))); + tree.trackCollider(makeProxy(2, new ex.BoundingBox(100, 100, 110, 110))); + + expect(() => tree.debug(engine.graphicsContext)).not.toThrow(); + + engine.dispose(); + }); +}); diff --git a/src/spec/vitest/flash-spec.ts b/src/spec/vitest/flash-spec.ts new file mode 100644 index 00000000..3fb7f756 --- /dev/null +++ b/src/spec/vitest/flash-spec.ts @@ -0,0 +1,119 @@ +import * as ex from '@excalibur'; +import { TestUtils } from '../__util__/test-utils'; + +describe('A Flash action', () => { + let actor: ex.Actor; + let engine: ex.Engine & any; + let scene: ex.Scene; + + beforeEach(async () => { + engine = TestUtils.engine({ width: 100, height: 100 }); + + actor = new ex.Actor(); + scene = new ex.Scene(); + scene.add(actor); + engine.addScene('test', scene); + await engine.goToScene('test'); + await engine.start(); + const clock = engine.clock as ex.TestClock; + clock.step(100); + engine.stop(); + }); + + afterEach(() => { + engine.stop(); + engine.dispose(); + engine = null; + }); + + it('exists', () => { + expect(ex.Flash).toBeDefined(); + }); + + it('can be constructed', () => { + const flash = new ex.Flash(actor, ex.Color.Red, 500); + expect(flash).toBeDefined(); + }); + + it('applies a material to the actor graphics on first update', () => { + const flash = new ex.Flash(actor, ex.Color.Red, 500); + expect(actor.graphics.material).toBeNull(); + + flash.update(0); + + expect(actor.graphics.material).toBeDefined(); + expect(actor.graphics.material).not.toBeNull(); + }); + + it('is not complete until the duration elapses', () => { + const flash = new ex.Flash(actor, ex.Color.Red, 1000); + + flash.update(0); + expect(flash.isComplete()).toBe(false); + + flash.update(500); + expect(flash.isComplete()).toBe(false); + + flash.update(500); + expect(flash.isComplete()).toBe(true); + }); + + it('clears the material once complete', () => { + const flash = new ex.Flash(actor, ex.Color.Red, 200); + + flash.update(0); + expect(actor.graphics.material).not.toBeNull(); + + flash.update(200); + expect(flash.isComplete()).toBe(true); + expect(actor.graphics.material).toBeNull(); + }); + + it('can be stopped early', () => { + const flash = new ex.Flash(actor, ex.Color.Red, 1000); + + flash.update(0); + flash.update(100); + expect(flash.isComplete()).toBe(false); + + flash.stop(); + + expect(flash.isComplete()).toBe(true); + expect(actor.graphics.isVisible).toBe(true); + }); + + it('can be reset and re-run after completing', () => { + const flash = new ex.Flash(actor, ex.Color.Red, 200); + + flash.update(0); + flash.update(200); + expect(flash.isComplete()).toBe(true); + + // reset() clears the started/stopped flags, but isComplete() only reads + // true again once update() re-primes _currentDuration from _duration + flash.reset(); + flash.update(0); + expect(flash.isComplete()).toBe(false); + }); + + it('can be reset after being stopped', () => { + const flash = new ex.Flash(actor, ex.Color.Red, 200); + + flash.update(0); + flash.stop(); + expect(flash.isComplete()).toBe(true); + + flash.reset(); + expect(flash.isComplete()).toBe(false); + }); + + it('can be run through the actor action context', () => { + actor.actions.flash(ex.Color.Red, 200); + + scene.update(engine, 100); + expect(actor.graphics.material).not.toBeNull(); + + scene.update(engine, 100); + expect(actor.graphics.material).toBeNull(); + }); +}); diff --git a/src/spec/vitest/lifecycle-events-spec.ts b/src/spec/vitest/lifecycle-events-spec.ts new file mode 100644 index 00000000..33e9d60d --- /dev/null +++ b/src/spec/vitest/lifecycle-events-spec.ts @@ -0,0 +1,82 @@ +import { + has_initialize, + has_add, + has_remove, + hasOnInitialize, + has_preupdate, + hasOnPreUpdate, + has_postupdate, + hasOnPostUpdate, + hasOnAdd, + hasOnRemove, + hasPreDraw, + hasPostDraw +} from '../../engine/interfaces/lifecycle-events'; + +describe('lifecycle event type guards', () => { + it('has_initialize detects an object with _initialize', () => { + expect(has_initialize({ _initialize: vi.fn() })).toBe(true); + expect(has_initialize({})).toBe(false); + }); + + it('has_add detects an object with onAdd', () => { + expect(has_add({ onAdd: vi.fn() })).toBe(true); + expect(has_add({})).toBe(false); + }); + + it('has_remove detects an object with onRemove', () => { + expect(has_remove({ onRemove: vi.fn() })).toBe(true); + expect(has_remove({})).toBe(false); + }); + + it('hasOnInitialize detects an object with onInitialize', () => { + expect(hasOnInitialize({ onInitialize: vi.fn() })).toBe(true); + expect(hasOnInitialize({})).toBe(false); + }); + + it('has_preupdate detects an object with _preupdate', () => { + expect(has_preupdate({ _preupdate: vi.fn() })).toBe(true); + expect(has_preupdate({})).toBe(false); + }); + + it('hasOnPreUpdate detects an object with onPreUpdate', () => { + expect(hasOnPreUpdate({ onPreUpdate: vi.fn() })).toBe(true); + expect(hasOnPreUpdate({})).toBe(false); + }); + + it('has_postupdate detects an object with _postupdate', () => { + expect(has_postupdate({ _postupdate: vi.fn() })).toBe(true); + expect(has_postupdate({})).toBe(false); + }); + + it('has_postupdate does not match an object that only has onPostUpdate', () => { + // _postupdate (internal) and onPostUpdate (user-overridable) are distinct hooks, + // same distinction as has_preupdate/hasOnPreUpdate + expect(has_postupdate({ onPostUpdate: vi.fn() })).toBe(false); + }); + + it('hasOnPostUpdate detects an object with onPostUpdate', () => { + expect(hasOnPostUpdate({ onPostUpdate: vi.fn() })).toBe(true); + expect(hasOnPostUpdate({})).toBe(false); + }); + + it('hasOnAdd detects an object with onAdd', () => { + expect(hasOnAdd({ onAdd: vi.fn() })).toBe(true); + expect(hasOnAdd({})).toBe(false); + }); + + it('hasOnRemove detects an object with onRemove', () => { + expect(hasOnRemove({ onRemove: vi.fn() })).toBe(true); + expect(hasOnRemove({})).toBe(false); + }); + + it('hasPreDraw detects an object with onPreDraw', () => { + expect(hasPreDraw({ onPreDraw: vi.fn() })).toBe(true); + expect(hasPreDraw({})).toBe(false); + }); + + it('hasPostDraw detects an object with onPostDraw', () => { + expect(hasPostDraw({ onPostDraw: vi.fn() })).toBe(true); + expect(hasPostDraw({})).toBe(false); + }); +}); diff --git a/src/spec/vitest/matrix-spec.ts b/src/spec/vitest/matrix-spec.ts index 00ff9115..d60fc7f6 100644 --- a/src/spec/vitest/matrix-spec.ts +++ b/src/spec/vitest/matrix-spec.ts @@ -230,4 +230,52 @@ describe('A Matrix', () => { expect(mat.data[4]).toBeCloseTo(-sin * 3, 5); expect(mat.data[5]).toBeCloseTo(cos * 3, 5); }); + + it('can make an orthographic projection matrix', () => { + const mat = ex.Matrix.ortho(0, 100, 100, 0, -1, 1); + + expect(mat.data[0]).toBeCloseTo(2 / 100, 5); + expect(mat.data[5]).toBeCloseTo(-2 / 100, 5); + expect(mat.data[10]).toBeCloseTo(-1, 5); + expect(mat.data[15]).toBe(1); + }); + + it('can multiply two matrices together', () => { + const translation = ex.Matrix.translation(10, 20); + const scale = ex.Matrix.scale(2, 2); + + const result = translation.multiply(scale); + + // scaling then translating a point should match multiplying the matrices in that order + const point = scale.multiply(ex.vec(1, 1)); + const expected = translation.multiply(point); + expect(result.multiply(ex.vec(1, 1))).toBeVector(expected); + }); + + it('can multiply two matrices into a destination matrix', () => { + const translation = ex.Matrix.translation(10, 20); + const scale = ex.Matrix.scale(2, 2); + const dest = ex.Matrix.identity(); + + const result = translation.multiply(scale, dest); + + expect(result).toBe(dest); + }); + + it('can convert to a DOMMatrix', () => { + const mat = ex.Matrix.identity().translate(10, 20); + const domMatrix = mat.toDOMMatrix(); + + expect(domMatrix).toBeInstanceOf(DOMMatrix); + expect(domMatrix.e).toBe(10); + expect(domMatrix.f).toBe(20); + }); + + it('can get the basis determinant', () => { + const identity = ex.Matrix.identity(); + expect(identity.getBasisDeterminant()).toBe(1); + + const scale = ex.Matrix.scale(2, 3); + expect(scale.getBasisDeterminant()).toBe(6); + }); }); diff --git a/src/spec/vitest/watch-spec.ts b/src/spec/vitest/watch-spec.ts new file mode 100644 index 00000000..335d2f91 --- /dev/null +++ b/src/spec/vitest/watch-spec.ts @@ -0,0 +1,130 @@ +import { watch, watchAny, watchDeep } from '../../engine/util/watch'; + +describe('watch', () => { + it('exists', () => { + expect(watch).toBeDefined(); + expect(watchDeep).toBeDefined(); + expect(watchAny).toBeDefined(); + }); + + describe('watch()', () => { + it('fires the change callback when a property value changes', () => { + const change = vi.fn(); + const proxy = watch({ x: 1 }, change); + + proxy.x = 2; + + expect(change).toHaveBeenCalledTimes(1); + expect(proxy.x).toBe(2); + }); + + it('does not fire the change callback when set to the same value', () => { + const change = vi.fn(); + const proxy = watch({ x: 1 }, change); + + proxy.x = 1; + + expect(change).not.toHaveBeenCalled(); + }); + + it('does not fire the change callback for underscore-prefixed (private) properties', () => { + const change = vi.fn(); + const proxy = watch({ _x: 1 } as any, change); + + (proxy as any)._x = 2; + + expect(change).not.toHaveBeenCalled(); + expect((proxy as any)._x).toBe(2); + }); + + it('returns the same value if it is already a proxy', () => { + const change = vi.fn(); + const proxy = watch({ x: 1 }, change); + const doubleProxy = watch(proxy, change); + + expect(doubleProxy).toBe(proxy); + }); + + it('returns falsy values unchanged', () => { + const change = vi.fn(); + expect(watch(null as any, change)).toBe(null); + expect(watch(undefined as any, change)).toBe(undefined); + }); + }); + + describe('watchAny()', () => { + it('fires the change callback even when set to the same value', () => { + const change = vi.fn(); + const proxy = watchAny({ x: 1 }, change); + + proxy.x = 1; + + expect(change).toHaveBeenCalledTimes(1); + }); + + it('does not fire the change callback for underscore-prefixed (private) properties', () => { + const change = vi.fn(); + const proxy = watchAny({ _x: 1 } as any, change); + + (proxy as any)._x = 2; + + expect(change).not.toHaveBeenCalled(); + }); + + it('returns the same value if it is already a proxy', () => { + const change = vi.fn(); + const proxy = watchAny({ x: 1 }, change); + const doubleProxy = watchAny(proxy, change); + + expect(doubleProxy).toBe(proxy); + }); + + it('returns falsy values unchanged', () => { + const change = vi.fn(); + expect(watchAny(null as any, change)).toBe(null); + }); + }); + + describe('watchDeep()', () => { + it('fires the change callback when a top level property changes', () => { + const change = vi.fn(); + const proxy = watchDeep({ x: 1, nested: { y: 2 } }, change); + + proxy.x = 2; + + expect(change).toHaveBeenCalledTimes(1); + }); + + it('fires the change callback when a nested property changes', () => { + const change = vi.fn(); + const proxy = watchDeep({ x: 1, nested: { y: 2 } }, change); + + proxy.nested.y = 3; + + expect(change).toHaveBeenCalledTimes(1); + expect(proxy.nested.y).toBe(3); + }); + + it('does not fire the change callback for underscore-prefixed (private) properties', () => { + const change = vi.fn(); + const proxy = watchDeep({ _x: 1 } as any, change); + + (proxy as any)._x = 2; + + expect(change).not.toHaveBeenCalled(); + }); + + it('returns the same value if it is already a proxy', () => { + const change = vi.fn(); + const proxy = watchDeep({ x: 1 }, change); + const doubleProxy = watchDeep(proxy, change); + + expect(doubleProxy).toBe(proxy); + }); + + it('returns falsy values unchanged', () => { + const change = vi.fn(); + expect(watchDeep(null as any, change)).toBe(null); + }); + }); +});