diff --git a/src/core/object-pool.js b/src/core/object-pool.js index 46e089c7022..09a38938e3c 100644 --- a/src/core/object-pool.js +++ b/src/core/object-pool.js @@ -29,6 +29,15 @@ class ObjectPool { */ _count = 0; + /** + * A map from object references to their index in `_pool`. This is used to determine + * whether an object is actually allocated from this pool (and at which index). + * + * @type {WeakMap, number>} + * @private + */ + _objToIndexMap = new WeakMap(); + /** * @param {T} constructorFunc - The constructor function for the * objects in the pool. @@ -40,18 +49,6 @@ class ObjectPool { this._resize(size); } - /** - * @param {number} size - The number of object instances to allocate. - * @private - */ - _resize(size) { - if (size > this._pool.length) { - for (let i = this._pool.length; i < size; i++) { - this._pool[i] = new this._constructor(); - } - } - } - /** * Returns an object instance from the pool. If no instances are available, the pool will be * doubled in size and a new instance will be returned. @@ -60,11 +57,42 @@ class ObjectPool { */ allocate() { if (this._count >= this._pool.length) { - this._resize(this._pool.length * 2); + this._resize(Math.max(1, this._pool.length * 2)); } return this._pool[this._count++]; } + /** + * Attempts to free the given object back into the pool. This only works if the object + * was previously allocated and is still in use. + * + * @param {InstanceType} obj - The object instance to be freed back into the pool. + * @returns {boolean} Whether freeing succeeded. + */ + free(obj) { + const index = this._objToIndexMap.get(obj); + if (index === undefined) { + return false; + } + + if (index >= this._count) { + return false; + } + + // Swap this object with the last allocated object, then decrement `_count` + const lastIndex = this._count - 1; + const lastObj = this._pool[lastIndex]; + + this._pool[index] = lastObj; + this._pool[lastIndex] = obj; + + this._objToIndexMap.set(lastObj, index); + this._objToIndexMap.set(obj, lastIndex); + + this._count -= 1; + return true; + } + /** * All object instances in the pool will be available again. The pool itself will not be * resized. @@ -72,6 +100,21 @@ class ObjectPool { freeAll() { this._count = 0; } + + /** + * @param {number} size - The number of object instances to allocate. + * @private + */ + _resize(size) { + if (size > this._pool.length) { + for (let i = this._pool.length; i < size; i++) { + const obj = new this._constructor(); + this._pool[i] = obj; + + this._objToIndexMap.set(obj, i); + } + } + } } export { ObjectPool }; diff --git a/src/core/queue.js b/src/core/queue.js new file mode 100644 index 00000000000..be9aa4436f5 --- /dev/null +++ b/src/core/queue.js @@ -0,0 +1,131 @@ +/** + * A circular queue that automatically extends its capacity when full. + * This implementation uses a fixed-size array to store elements and + * supports efficient enqueue and dequeue operations. + * It is recommended to use `initialCapacity` that is close to **real-world** usage. + * @template T + */ +class Queue { + /** + * Create a new queue. + * @param {number} [initialCapacity] - The initial capacity of the queue. + */ + constructor(initialCapacity = 8) { + /** + * Underlying storage for the queue. + * @type {Array} + * @private + */ + this._storage = new Array(initialCapacity); + + /** + * The head (front) index. + * @type {number} + * @private + */ + this._head = 0; + + /** + * The current number of elements in the queue. + * @type {number} + * @private + */ + this._length = 0; + } + + /** + * The current number of elements in the queue. + * @type {number} + * @readonly + */ + get length() { + return this._length; + } + + /** + * Change the capacity of the underlying storage. + * Does not shrink capacity if new capacity is less than or equal to the current length. + * @param {number} capacity - The new capacity for the queue. + */ + set capacity(capacity) { + if (capacity <= this._length) { + return; + } + + const oldCapacity = this._storage.length; + this._storage.length = capacity; + + // Handle wrap-around scenario by moving elements. + if (this._head + this._length > oldCapacity) { + const endLength = oldCapacity - this._head; + for (let i = 0; i < endLength; i++) { + this._storage[capacity - endLength + i] = this._storage[this._head + i]; + } + this._head = capacity - endLength; + } + } + + /** + * The capacity of the queue. + * @type {number} + * @readonly + */ + get capacity() { + return this._storage.length; + } + + /** + * Enqueue (push) a value to the back of the queue. + * Automatically extends capacity if the queue is full. + * @param {T} value - The value to enqueue. + * @returns {number} The new length of the queue. + */ + enqueue(value) { + if (this._length === this._storage.length) { + this.capacity = this._storage.length * 2; + } + + const tailIndex = (this._head + this._length) % this._storage.length; + this._storage[tailIndex] = value; + this._length++; + return this._length; + } + + /** + * Dequeue (pop) a value from the front of the queue. + * @returns {T|undefined} The dequeued value, or `undefined` if the queue is empty. + */ + dequeue() { + if (this.isEmpty()) { + return undefined; + } + + const value = this._storage[this._head]; + this._storage[this._head] = undefined; + this._head = (this._head + 1) % this._storage.length; + this._length--; + + return value; + } + + /** + * Returns the value at the front of the queue without removing it. + * @returns {T|undefined} The front value, or `undefined` if the queue is empty. + */ + peek() { + if (this.isEmpty()) { + return undefined; + } + return this._storage[this._head]; + } + + /** + * Determines whether the queue is empty. + * @returns {boolean} True if the queue is empty, false otherwise. + */ + isEmpty() { + return this._length === 0; + } +} + +export { Queue }; diff --git a/src/framework/lightmapper/lightmapper.js b/src/framework/lightmapper/lightmapper.js index 4b3147b3d44..1d72d9ad1ca 100644 --- a/src/framework/lightmapper/lightmapper.js +++ b/src/framework/lightmapper/lightmapper.js @@ -811,7 +811,7 @@ class Lightmapper { const meshInstances = bakeNode.meshInstances; for (let i = 0; i < meshInstances.length; i++) { - if (meshInstances[i]._isVisible(shadowCam)) { + if (meshInstances[i]._isVisible(shadowCam, this.renderer._aabbUpdateIndex)) { nodeVisible = true; break; } diff --git a/src/scene/graph-node.js b/src/scene/graph-node.js index 49eff9b6195..4247d24acd0 100644 --- a/src/scene/graph-node.js +++ b/src/scene/graph-node.js @@ -7,6 +7,9 @@ import { Mat4 } from '../core/math/mat4.js'; import { Quat } from '../core/math/quat.js'; import { Vec3 } from '../core/math/vec3.js'; +import { Queue } from '../core/queue.js'; +import { ObjectPool } from '../core/object-pool.js'; + const scaleCompensatePosTransform = new Mat4(); const scaleCompensatePos = new Vec3(); const scaleCompensateRot = new Quat(); @@ -43,31 +46,6 @@ function createTest(attr, value) { }; } -/** - * Helper function to recurse findOne without calling createTest constantly. - * - * @param {GraphNode} node - Current node. - * @param {FindNodeCallback} test - Test function. - * @returns {GraphNode|null} A graph node that matches the search criteria. Returns null if no - * node is found. - */ -function findNode(node, test) { - if (test(node)) { - return node; - } - - const children = node._children; - const len = children.length; - for (let i = 0; i < len; ++i) { - const result = findNode(children[i], test); - if (result) { - return result; - } - } - - return null; -} - /** * Callback used by {@link GraphNode#find} and {@link GraphNode#findOne} to search through a graph * node and all of its descendants. @@ -98,6 +76,31 @@ function findNode(node, test) { * a powerful set of features that are leveraged by the `Entity` class. */ class GraphNode extends EventHandler { + /** + * It is a pool of graph node stacks that are used to reduce memory allocation overhead + * Note: we usually don't need more than 1 stack at a time, but we allocate 2 just in case. + * + * @type {ObjectPool>} + * @private + */ + static _stackPool = new ObjectPool(Array, 2); + + /** + * It is a pool of graph node queues that are used to reduce memory allocation overhead + * + * @type {ObjectPool>} + * @private + */ + static _queuePool = new ObjectPool(Queue, 1); + + /** + * Maximum number of nodes that can be stored in a graph node queue. + * + * @type {number} + * @private + */ + static _maxQueueCapacity = 512; + /** * The non-unique name of a graph node. Defaults to 'Untitled'. * @@ -533,7 +536,6 @@ class GraphNode extends EventHandler { return this; } - /** * Destroy the graph node and all of its descendants. First, the graph node is removed from the * hierarchy. This is then repeated recursively for all descendants of the graph node. @@ -629,7 +631,30 @@ class GraphNode extends EventHandler { */ findOne(attr, value) { const test = createTest(attr, value); - return findNode(this, test); + + const stack = GraphNode._stackPool.allocate(); + let size = 0; + stack[size++] = this; + + while (size > 0) { + const node = stack[--size]; + + if (test(node)) { + GraphNode._stackPool.free(stack); + + return node; + } + + const children = node._children; + const length = children.length; + for (let i = 0; i < length; ++i) { + stack[size++] = children[i]; + } + } + + GraphNode._stackPool.free(stack); + + return null; } /** @@ -653,21 +678,30 @@ class GraphNode extends EventHandler { * // Return all assets that tagged by (`carnivore` AND `mammal`) OR (`carnivore` AND `reptile`) * const meatEatingMammalsAndReptiles = node.findByTag(["carnivore", "mammal"], ["carnivore", "reptile"]); */ - findByTag() { - const query = arguments; + findByTag(...query) { const results = []; + const stack = GraphNode._stackPool.allocate(); + + let size = 0; + for (let i = 0; i < this._children.length; ++i) { + stack[size++] = this._children[i]; + } - const queryNode = (node, checkNode) => { - if (checkNode && node.tags.has(...query)) { + while (size > 0) { + const node = stack[--size]; + + if (node.tags.has(...query)) { results.push(node); } - for (let i = 0; i < node._children.length; i++) { - queryNode(node._children[i], true); + const children = node._children; + const length = children.length; + for (let i = 0; i < length; ++i) { + stack[size++] = children[i]; } - }; + } - queryNode(this, false); + GraphNode._stackPool.free(stack); return results; } @@ -715,24 +749,36 @@ class GraphNode extends EventHandler { /** * Executes a provided function once on this graph node and all of its descendants. + * The method executes the provided function for each node in the graph in a breadth-first manner. * * @param {ForEachNodeCallback} callback - The function to execute on the graph node and each * descendant. - * @param {object} [thisArg] - Optional value to use as this when executing callback function. + * @param {object} [thisArg] - Optional value to use as this when executing the callback function. * @example - * // Log the path and name of each node in descendant tree starting with "parent" + * // Log the path and name of each node in the descendant tree starting with "parent" * parent.forEach(function (node) { * console.log(node.path + "/" + node.name); * }); */ forEach(callback, thisArg) { - callback.call(thisArg, this); + // This is BFS via a queue, so not a stack usage + const queue = GraphNode._queuePool.allocate(); + queue.capacity = GraphNode._maxQueueCapacity; + queue.enqueue(this); - const children = this._children; - const len = children.length; - for (let i = 0; i < len; ++i) { - children[i].forEach(callback, thisArg); + while (queue.length) { + const node = queue.dequeue(); + + callback.call(thisArg, node); + + const children = node.children; + for (let i = 0; i < children.length; i++) { + queue.enqueue(children[i]); + } } + + GraphNode._maxQueueCapacity = Math.max(GraphNode._maxQueueCapacity, queue.capacity); + GraphNode._queuePool.free(queue); } /** @@ -751,7 +797,6 @@ class GraphNode extends EventHandler { if (parent === node) { return true; } - parent = parent._parent; } return false; @@ -941,11 +986,9 @@ class GraphNode extends EventHandler { * @ignore */ get worldScaleSign() { - if (this._worldScaleSign === 0) { this._worldScaleSign = this.getWorldTransform().scaleSign; } - return this._worldScaleSign; } @@ -1115,18 +1158,31 @@ class GraphNode extends EventHandler { /** @private */ _dirtifyWorldInternal() { - if (!this._dirtyWorld) { - this._frozen = false; - this._dirtyWorld = true; - for (let i = 0; i < this._children.length; i++) { - if (!this._children[i]._dirtyWorld) { - this._children[i]._dirtifyWorldInternal(); + const stack = GraphNode._stackPool.allocate(); + let size = 0; + stack[size++] = this; + + while (size > 0) { + const node = stack[--size]; + + if (!node._dirtyWorld) { + node._frozen = false; + node._dirtyWorld = true; + } + + node._dirtyNormal = true; + node._worldScaleSign = 0; // world matrix is dirty, mark this flag dirty too + node._aabbVer++; + + const children = node._children; + for (let i = 0; i < children.length; i++) { + if (!children[i]._dirtyWorld) { + stack[size++] = children[i]; } } } - this._dirtyNormal = true; - this._worldScaleSign = 0; // world matrix is dirty, mark this flag dirty too - this._aabbVer++; + + GraphNode._stackPool.free(stack); } /** @@ -1288,7 +1344,6 @@ class GraphNode extends EventHandler { * @ignore */ addChildAndSaveTransform(node) { - const wPos = node.getPosition(); const wRot = node.getRotation(); @@ -1313,7 +1368,6 @@ class GraphNode extends EventHandler { * this.entity.insertChild(e, 1); */ insertChild(node, index) { - this._prepareInsertChild(node); this._children.splice(index, 0, node); this._onInsertChild(node); @@ -1326,7 +1380,6 @@ class GraphNode extends EventHandler { * @private */ _prepareInsertChild(node) { - // remove it from the existing parent node.remove(); @@ -1345,9 +1398,23 @@ class GraphNode extends EventHandler { */ _fireOnHierarchy(name, nameHierarchy, parent) { this.fire(name, parent); - for (let i = 0; i < this._children.length; i++) { - this._children[i]._fireOnHierarchy(nameHierarchy, nameHierarchy, parent); + + const stack = GraphNode._stackPool.allocate(); + let size = 1; + + while (size > 0) { + const node = stack[--size]; + + node.fire(nameHierarchy, parent); + + const children = node._children; + const length = children.length; + for (let i = 0; i < length; ++i) { + stack[size++] = children[i]; + } } + + GraphNode._stackPool.free(stack); } /** @@ -1390,16 +1457,27 @@ class GraphNode extends EventHandler { } /** - * Recurse the hierarchy and update the graph depth at each node. + * Iterates through the hierarchy and update the graph depth at each node. * * @private */ _updateGraphDepth() { - this._graphDepth = this._parent ? this._parent._graphDepth + 1 : 0; - - for (let i = 0, len = this._children.length; i < len; i++) { - this._children[i]._updateGraphDepth(); + const stack = GraphNode._stackPool.allocate(); + let size = 0; + stack[size++] = this; + + while (size > 0) { + const node = stack[--size]; + node._graphDepth = node._parent ? node._parent._graphDepth + 1 : 0; + + const children = node._children; + const length = children.length; + for (let i = 0; i < length; ++i) { + stack[size++] = children[i]; + } } + + GraphNode._stackPool.free(stack); } /** @@ -1501,23 +1579,35 @@ class GraphNode extends EventHandler { * @ignore */ syncHierarchy() { - if (!this._enabled) { + if (!this._enabled || this._frozen) { return; } - if (this._frozen) { - return; - } - this._frozen = true; + const stack = GraphNode._stackPool.allocate(); + let size = 0; + stack[size++] = this; - if (this._dirtyLocal || this._dirtyWorld) { - this._sync(); - } + while (size > 0) { + const node = stack[--size]; - const children = this._children; - for (let i = 0, len = children.length; i < len; i++) { - children[i].syncHierarchy(); + if (!node._enabled || node._frozen) { + continue; + } + + node._frozen = true; + + if (node._dirtyLocal || node._dirtyWorld) { + node._sync(); + } + + const children = node._children; + const length = children.length; + for (let i = 0; i < length; ++i) { + stack[size++] = children[i]; + } } + + GraphNode._stackPool.free(stack); } /** diff --git a/src/scene/mesh-instance.js b/src/scene/mesh-instance.js index 4b8fe23f1eb..85f5caaef36 100644 --- a/src/scene/mesh-instance.js +++ b/src/scene/mesh-instance.js @@ -277,6 +277,8 @@ class MeshInstance { */ pick = true; + _aabbUpdateIndex = NaN; + /** * The stencil parameters for front faces or null if no stencil is enabled. * @@ -945,7 +947,7 @@ class MeshInstance { * @returns {boolean} - True if the mesh instance is visible by the camera, false otherwise. * @ignore */ - _isVisible(camera) { + _isVisible(camera, aabbUpdateIndex) { if (this.visible) { @@ -954,8 +956,11 @@ class MeshInstance { return this.isVisibleFunc(camera); } - _tempSphere.center = this.aabb.center; // this line evaluates aabb - _tempSphere.radius = this._aabb.halfExtents.length(); + const aabb = this._aabbUpdateIndex === aabbUpdateIndex ? this._aabb : this.aabb; // this line evaluates aabb + this._aabbUpdateIndex = aabbUpdateIndex; + + _tempSphere.center = aabb.center; + _tempSphere.radius = aabb.halfExtents.length(); return camera.frustum.containsSphere(_tempSphere) > 0; } diff --git a/src/scene/renderer/renderer.js b/src/scene/renderer/renderer.js index 945f9a44234..24f3a70d0d0 100644 --- a/src/scene/renderer/renderer.js +++ b/src/scene/renderer/renderer.js @@ -158,6 +158,8 @@ class Renderer { blueNoise = new BlueNoise(123); + _aabbUpdateIndex = 0; + /** * Create a new instance. * @@ -916,7 +918,7 @@ class Renderer { const drawCall = drawCalls[i]; if (drawCall.visible) { - const visible = !doCull || !drawCall.cull || drawCall._isVisible(camera); + const visible = !doCull || !drawCall.cull || drawCall._isVisible(camera, this._aabbUpdateIndex); if (visible) { drawCall.visibleThisFrame = true; @@ -1127,6 +1129,7 @@ class Renderer { */ cullComposition(comp) { + this._aabbUpdateIndex++; // #if _PROFILER const cullTime = now(); // #endif diff --git a/src/scene/renderer/shadow-renderer.js b/src/scene/renderer/shadow-renderer.js index b61adea89d5..dcfb68e94d0 100644 --- a/src/scene/renderer/shadow-renderer.js +++ b/src/scene/renderer/shadow-renderer.js @@ -142,7 +142,7 @@ class ShadowRenderer { const meshInstance = meshInstances[i]; if (meshInstance.castShadow) { - if (!meshInstance.cull || meshInstance._isVisible(camera)) { + if (!meshInstance.cull || meshInstance._isVisible(camera, this.renderer._aabbUpdateIndex)) { meshInstance.visibleThisFrame = true; visible.push(meshInstance); } diff --git a/test/core/object-pool.test.mjs b/test/core/object-pool.test.mjs new file mode 100644 index 00000000000..bff82164c73 --- /dev/null +++ b/test/core/object-pool.test.mjs @@ -0,0 +1,49 @@ +import { expect } from 'chai'; + +import { ObjectPool } from '../../src/core/object-pool.js'; + +class SampleObject { + constructor() { + this.value = 0; + } +} + +describe('ObjectPool', function () { + let pool; + + beforeEach(function () { + pool = new ObjectPool(SampleObject, 2); + }); + + it('should allocate an object from the pool', function () { + const obj = pool.allocate(); + expect(obj).to.be.an.instanceof(SampleObject); + }); + + it('should resize the pool when allocation exceeds size', function () { + pool.allocate(); + pool.allocate(); + const obj = pool.allocate(); + expect(obj).to.be.an.instanceof(SampleObject); + }); + + it('should free an allocated object back to the pool', function () { + const obj = pool.allocate(); + const success = pool.free(obj); + expect(success).to.be.true; + }); + + it('should not free an object not allocated from the pool', function () { + const obj = new SampleObject(); + const success = pool.free(obj); + expect(success).to.be.false; + }); + + it('should free all allocated objects', function () { + pool.allocate(); + pool.allocate(); + pool.freeAll(); + const obj = pool.allocate(); + expect(obj).to.be.an.instanceof(SampleObject); + }); +}); diff --git a/test/core/queue.test.mjs b/test/core/queue.test.mjs new file mode 100644 index 00000000000..dc30ccd9135 --- /dev/null +++ b/test/core/queue.test.mjs @@ -0,0 +1,215 @@ +import { expect } from 'chai'; + +import { Queue } from '../../src/core/queue.js'; + +describe('Queue', function () { + let queue; + + beforeEach(function () { + queue = new Queue(2); + }); + + describe('#constructor', function () { + it('should initialize as empty', function () { + expect(queue.isEmpty()).to.be.true; + expect(queue.length).to.equal(0); + }); + + it('should return the initial capacity from the constructor', function () { + expect(queue.capacity).to.equal(2); + + const customQueue = new Queue(5); + expect(customQueue.capacity).to.equal(5); + }); + }); + + describe('#enqueue()/#dequeue()', function () { + it('should enqueue elements into the queue', function () { + queue.enqueue(1); + expect(queue.isEmpty()).to.be.false; + expect(queue.length).to.equal(1); + expect(queue.peek()).to.equal(1); + }); + + it('should dequeue elements from the queue', function () { + queue.enqueue(1); + queue.enqueue(2); + const dequeued = queue.dequeue(); + expect(dequeued).to.equal(1); + expect(queue.length).to.equal(1); + expect(queue.peek()).to.equal(2); + }); + + it('should return undefined when dequeuing from an empty queue', function () { + const dequeued = queue.dequeue(); + expect(dequeued).to.be.undefined; + expect(queue.isEmpty()).to.be.true; + }); + + it('should resize when capacity is exceeded', function () { + queue.enqueue(1); + queue.enqueue(2); + queue.enqueue(3); // This should trigger a resize + expect(queue.length).to.equal(3); + expect(queue.peek()).to.equal(1); + }); + + it('should maintain order after resizing', function () { + queue.enqueue(1); + queue.enqueue(2); + queue.enqueue(3); + expect(queue.dequeue()).to.equal(1); + expect(queue.dequeue()).to.equal(2); + expect(queue.dequeue()).to.equal(3); + }); + + it('should correctly report if the queue is empty', function () { + expect(queue.isEmpty()).to.be.true; + queue.enqueue(1); + expect(queue.isEmpty()).to.be.false; + queue.dequeue(); + expect(queue.isEmpty()).to.be.true; + }); + + it('should correctly report the size of the queue', function () { + expect(queue.length).to.equal(0); + queue.enqueue(1); + expect(queue.length).to.equal(1); + queue.enqueue(2); + expect(queue.length).to.equal(2); + queue.dequeue(); + expect(queue.length).to.equal(1); + }); + + it('should handle multiple resizes', function () { + for (let i = 1; i <= 5; i++) { + queue.enqueue(i); + } + + expect(queue.capacity).to.be.at.least(4); + expect(queue.length).to.equal(5); + + expect(queue.dequeue()).to.equal(1); + expect(queue.dequeue()).to.equal(2); + expect(queue.dequeue()).to.equal(3); + expect(queue.dequeue()).to.equal(4); + expect(queue.dequeue()).to.equal(5); + }); + + it('should allow enqueuing after manually setting a larger capacity', function () { + expect(queue.capacity).to.equal(2); + queue.capacity = 10; + expect(queue.capacity).to.equal(10); + + for (let i = 0; i < 10; i++) { + queue.enqueue(i); + } + expect(queue.length).to.equal(10); + expect(queue.capacity).to.equal(10); + }); + }); + + describe('#capacity', function () { + it('should not reduce capacity if new capacity is smaller or equal to current length', function () { + queue.enqueue(1); + queue.enqueue(2); + expect(queue.length).to.equal(2); + + queue.capacity = 1; + expect(queue.capacity).to.equal(2); + + expect(queue.dequeue()).to.equal(1); + expect(queue.dequeue()).to.equal(2); + expect(queue.isEmpty()).to.be.true; + }); + + it('should set a larger capacity if new capacity is bigger than current size', function () { + expect(queue.capacity).to.equal(2); + queue.enqueue(1); + + queue.capacity = 5; + expect(queue.capacity).to.equal(5); + expect(queue.length).to.equal(1); + expect(queue.dequeue()).to.equal(1); + }); + + it('should handle wrap-around when manually setting capacity', function () { + queue.enqueue(1); + queue.enqueue(2); + + const first = queue.dequeue(); + expect(first).to.equal(1); + + queue.enqueue(3); + + queue.capacity = 4; + expect(queue.capacity).to.equal(4); + + expect(queue.dequeue()).to.equal(2); + expect(queue.dequeue()).to.equal(3); + expect(queue.isEmpty()).to.be.true; + }); + }); + + describe('#length', function () { + it('should accurately reflect the number of items in the queue', function () { + expect(queue.length).to.equal(0); + + queue.enqueue(10); + queue.enqueue(20); + expect(queue.length).to.equal(2); + + queue.dequeue(); + expect(queue.length).to.equal(1); + + queue.enqueue(30); + expect(queue.length).to.equal(2); + + queue.dequeue(); + queue.dequeue(); + expect(queue.length).to.equal(0); + expect(queue.isEmpty()).to.be.true; + }); + }); + + describe('#isEmpty()', function () { + it('should return true when queue is newly created', function () { + expect(queue.isEmpty()).to.be.true; + }); + + it('should return false when at least one item is enqueued', function () { + queue.enqueue('test'); + expect(queue.isEmpty()).to.be.false; + }); + + it('should return true after enqueuing and then dequeuing the same number of items', function () { + queue.enqueue('a'); + queue.enqueue('b'); + queue.dequeue(); + queue.dequeue(); + expect(queue.isEmpty()).to.be.true; + }); + }); + + describe('#peek()', function () { + it('should return undefined if the queue is empty', function () { + expect(queue.peek()).to.be.undefined; + }); + + it('should peek at the front element without dequeuing it', function () { + queue.enqueue(1); + const front = queue.peek(); + expect(front).to.equal(1); + expect(queue.length).to.equal(1); + }); + + it('should return the front element after multiple enqueues/dequeues', function () { + queue.enqueue(1); + queue.enqueue(2); + queue.enqueue(3); + queue.dequeue(); + + expect(queue.peek()).to.equal(2); + }); + }); +});