From acc29c6c780c14552b2c0fdcf5be2769326806ba Mon Sep 17 00:00:00 2001 From: Orion Reed Date: Sat, 7 Dec 2024 02:26:11 -0500 Subject: [PATCH] add more tests --- src/__tests__/TransformDOMRect.test.ts | 283 ++++++++++++++++++++----- src/common/TransformDOMRect.ts | 233 ++++++++++++++------ 2 files changed, 399 insertions(+), 117 deletions(-) diff --git a/src/__tests__/TransformDOMRect.test.ts b/src/__tests__/TransformDOMRect.test.ts index e8d2605..5e0689f 100644 --- a/src/__tests__/TransformDOMRect.test.ts +++ b/src/__tests__/TransformDOMRect.test.ts @@ -127,8 +127,8 @@ describe('TransformDOMRect', () => { }); }); - describe('corner setters', () => { - test('setTopLeft maintains rectangle properties', () => { + describe('corner', () => { + test('setTopLeft with local space coordinates', () => { const rect = new TransformDOMRect({ x: 100, y: 100, @@ -136,14 +136,15 @@ describe('TransformDOMRect', () => { height: 100, }); - rect.setTopLeft({ x: 50, y: 50 }); - expect(rect.x).toBe(50); - expect(rect.y).toBe(50); - expect(rect.width).toBe(250); // Increased by 50 - expect(rect.height).toBe(150); // Increased by 50 + // Move top-left corner 50 units right and 25 units down in local space + rect.setTopLeft({ x: 50, y: 25 }); + expect(rect.x).toBe(150); // Original x + local x + expect(rect.y).toBe(125); // Original y + local y + expect(rect.width).toBe(150); // Original width - local x + expect(rect.height).toBe(75); // Original height - local y }); - test('setTopRight maintains rectangle properties', () => { + test('setTopRight with local space coordinates', () => { const rect = new TransformDOMRect({ x: 100, y: 100, @@ -151,14 +152,15 @@ describe('TransformDOMRect', () => { height: 100, }); - rect.setTopRight({ x: 350, y: 50 }); - expect(rect.x).toBe(100); - expect(rect.y).toBe(50); - expect(rect.width).toBe(350); - expect(rect.height).toBe(150); + // Set top-right corner to local coordinates (150, 25) + rect.setTopRight({ x: 150, y: 25 }); + expect(rect.x).toBe(100); // Original x unchanged + expect(rect.y).toBe(125); // Original y + local y + expect(rect.width).toBe(150); // New local x + expect(rect.height).toBe(75); // Original height - local y }); - test('setBottomRight maintains rectangle properties', () => { + test('setBottomRight with local space coordinates', () => { const rect = new TransformDOMRect({ x: 100, y: 100, @@ -166,14 +168,15 @@ describe('TransformDOMRect', () => { height: 100, }); - rect.setBottomRight({ x: 350, y: 250 }); - expect(rect.x).toBe(100); - expect(rect.y).toBe(100); - expect(rect.width).toBe(350); - expect(rect.height).toBe(250); + // Set bottom-right corner to local coordinates (150, 75) + rect.setBottomRight({ x: 150, y: 75 }); + expect(rect.x).toBe(100); // Original x unchanged + expect(rect.y).toBe(100); // Original y unchanged + expect(rect.width).toBe(150); // New local x + expect(rect.height).toBe(75); // New local y }); - test('setBottomLeft maintains rectangle properties', () => { + test('setBottomLeft with local space coordinates', () => { const rect = new TransformDOMRect({ x: 100, y: 100, @@ -181,14 +184,15 @@ describe('TransformDOMRect', () => { height: 100, }); - rect.setBottomLeft({ x: 50, y: 250 }); - expect(rect.x).toBe(50); - expect(rect.y).toBe(100); - expect(rect.width).toBe(250); - expect(rect.height).toBe(250); + // Move bottom-left corner 50 units right in local space + rect.setBottomLeft({ x: 50, y: 75 }); + expect(rect.x).toBe(150); // Original x + local x + expect(rect.y).toBe(100); // Original y unchanged + expect(rect.width).toBe(150); // Original width - local x + expect(rect.height).toBe(75); // New local y }); - test('corner setters work with rotation', () => { + test('corner setters with rotation', () => { const rect = new TransformDOMRect({ x: 100, y: 100, @@ -197,11 +201,18 @@ describe('TransformDOMRect', () => { rotation: Math.PI / 4, // 45 degrees }); - const newTopLeft = rect.toParentSpace({ x: 0, y: 0 }); - rect.setTopLeft(newTopLeft); + // Move top-left corner in local space + rect.setTopLeft({ x: 50, y: 25 }); - const transformedTopLeft = rect.toLocalSpace(newTopLeft); - expectPointClose(transformedTopLeft, { x: 0, y: 0 }); + // Verify the dimensions are correct + expect(rect.width).toBe(150); // Original width - local x + expect(rect.height).toBe(75); // Original height - local y + + // Verify we can still transform points correctly + const localPoint = { x: 0, y: 0 }; + const parentPoint = rect.toParentSpace(localPoint); + const backToLocal = rect.toLocalSpace(parentPoint); + expectPointClose(backToLocal, localPoint); }); test('setBottomRight works with upside down rotation', () => { @@ -213,15 +224,184 @@ describe('TransformDOMRect', () => { rotation: Math.PI, // 180 degrees - upside down }); - rect.setBottomRight({ x: 350, y: 250 }); - expect(rect.x).toBe(100); - expect(rect.y).toBe(100); - expect(rect.width).toBe(250); - expect(rect.height).toBe(150); + // Set bottom-right corner in local space + rect.setBottomRight({ x: 150, y: 75 }); - // Verify the corner is actually at the expected position - const transformedBottomRight = rect.toParentSpace(rect.bottomRight); - expectPointClose(transformedBottomRight, { x: 350, y: 250 }); + expect(rect.width).toBe(150); + expect(rect.height).toBe(75); + + // Verify the corner is actually at the expected position in local space + expectPointClose(rect.bottomRight, { x: 150, y: 75 }); + }); + }); + + describe('point conversion with rotation', () => { + test('converts points correctly with 90-degree rotation', () => { + const rect = new TransformDOMRect({ + x: 100, + y: 100, + width: 200, + height: 100, + rotation: Math.PI / 2, // 90 degrees + }); + + // Test points in local space and their expected parent space coordinates + const testCases = [ + { + local: { x: 0, y: 0 }, // Top-left + parent: { x: 150, y: 50 }, // After rotation: center + (-height/2, -width/2) + }, + { + local: { x: 200, y: 0 }, // Top-right + parent: { x: 150, y: 250 }, // After rotation: center + (height/2, -width/2) + }, + { + local: { x: 100, y: 50 }, // Center + parent: { x: 200, y: 150 }, // After rotation: stays at center + }, + ]; + + testCases.forEach(({ local, parent }) => { + const toParent = rect.toParentSpace(local); + expectPointClose(toParent, parent); + + const backToLocal = rect.toLocalSpace(toParent); + expectPointClose(backToLocal, local); + }); + }); + + test('converts points correctly with 45-degree rotation', () => { + const rect = new TransformDOMRect({ + x: 100, + y: 100, + width: 100, + height: 100, + rotation: Math.PI / 4, // 45 degrees + }); + + // Center point should remain at the same position after transformation + const center = { x: 50, y: 50 }; // Center in local space + const centerInParent = rect.toParentSpace(center); + expectPointClose(centerInParent, { x: 150, y: 150 }); // Center in parent space + + // Test a point on the edge + const edge = { x: 100, y: 50 }; // Right-middle in local space + const edgeInParent = rect.toParentSpace(edge); + // At 45 degrees, this point should be √2/2 * 100 units right and up from center + expectPointClose(edgeInParent, { + x: 150 + Math.cos(Math.PI / 4) * 50, + y: 150 + Math.sin(Math.PI / 4) * 50, + }); + }); + + test('maintains relative positions through multiple transformations', () => { + const rect = new TransformDOMRect({ + x: 100, + y: 100, + width: 100, + height: 100, + rotation: Math.PI / 6, // 30 degrees + }); + + // Create a grid of test points + const gridPoints: Point[] = []; + for (let x = 0; x <= 100; x += 25) { + for (let y = 0; y <= 100; y += 25) { + gridPoints.push({ x, y }); + } + } + + // Verify all points maintain their relative distances + gridPoints.forEach((point1, i) => { + gridPoints.forEach((point2, j) => { + if (i === j) return; + + // Calculate distance in local space + const dx = point2.x - point1.x; + const dy = point2.y - point1.y; + const localDistance = Math.sqrt(dx * dx + dy * dy); + + // Transform points to parent space + const parent1 = rect.toParentSpace(point1); + const parent2 = rect.toParentSpace(point2); + + // Calculate distance in parent space + const pdx = parent2.x - parent1.x; + const pdy = parent2.y - parent1.y; + const parentDistance = Math.sqrt(pdx * pdx + pdy * pdy); + + // Distances should be preserved + expect(parentDistance).toBeCloseTo(localDistance); + }); + }); + }); + + test('handles edge cases with various rotations', () => { + const testRotations = [ + 0, // No rotation + Math.PI / 2, // 90 degrees + Math.PI, // 180 degrees + (3 * Math.PI) / 2, // 270 degrees + Math.PI / 6, // 30 degrees + Math.PI / 3, // 60 degrees + (2 * Math.PI) / 3, // 120 degrees + (5 * Math.PI) / 6, // 150 degrees + ]; + + testRotations.forEach((rotation) => { + const rect = new TransformDOMRect({ + x: 100, + y: 100, + width: 100, + height: 50, + rotation, + }); + + // Test various points including corners and edges + const testPoints = [ + { x: 0, y: 0 }, // Top-left + { x: 100, y: 0 }, // Top-right + { x: 100, y: 50 }, // Bottom-right + { x: 0, y: 50 }, // Bottom-left + { x: 50, y: 25 }, // Center + { x: 50, y: 0 }, // Top middle + { x: 100, y: 25 }, // Right middle + { x: 50, y: 50 }, // Bottom middle + { x: 0, y: 25 }, // Left middle + ]; + + testPoints.forEach((localPoint) => { + const parentPoint = rect.toParentSpace(localPoint); + const backToLocal = rect.toLocalSpace(parentPoint); + expectPointClose(backToLocal, localPoint); + }); + }); + }); + + test('maintains aspect ratio through transformations', () => { + const rect = new TransformDOMRect({ + x: 100, + y: 100, + width: 200, + height: 100, + rotation: Math.PI / 3, // 60 degrees + }); + + // Test diagonal distances + const topLeft = { x: 0, y: 0 }; + const bottomRight = { x: 200, y: 100 }; + + const topLeftParent = rect.toParentSpace(topLeft); + const bottomRightParent = rect.toParentSpace(bottomRight); + + // Calculate distances + const localDiagonal = Math.sqrt(Math.pow(bottomRight.x - topLeft.x, 2) + Math.pow(bottomRight.y - topLeft.y, 2)); + const parentDiagonal = Math.sqrt( + Math.pow(bottomRightParent.x - topLeftParent.x, 2) + Math.pow(bottomRightParent.y - topLeftParent.y, 2) + ); + + // Distances should be preserved + expect(parentDiagonal).toBeCloseTo(localDiagonal); }); }); }); @@ -235,21 +415,18 @@ describe('TransformDOMRectReadonly', () => { height: 50, }); - expect(() => { - rect.x = 20; - }).toThrow(); - expect(() => { - rect.y = 30; - }).toThrow(); - expect(() => { - rect.width = 200; - }).toThrow(); - expect(() => { - rect.height = 100; - }).toThrow(); - expect(() => { - rect.rotation = Math.PI; - }).toThrow(); + rect.x = 20; + rect.y = 30; + rect.width = 200; + rect.height = 100; + rect.rotation = Math.PI; + + // Values should remain unchanged + expect(rect.x).toBe(10); + expect(rect.y).toBe(20); + expect(rect.width).toBe(100); + expect(rect.height).toBe(50); + expect(rect.rotation).toBe(0); }); test('allows reading properties', () => { diff --git a/src/common/TransformDOMRect.ts b/src/common/TransformDOMRect.ts index dd5a114..9a0f28c 100644 --- a/src/common/TransformDOMRect.ts +++ b/src/common/TransformDOMRect.ts @@ -9,18 +9,32 @@ interface TransformDOMRectInit { rotation?: number; } +/** + * Represents a rectangle with position, size, and rotation, + * capable of transforming points between local and parent coordinate spaces. + * + * **Coordinate System:** + * - The origin `(0, 0)` is at the **top-left corner**. + * - Positive `x` values extend **to the right**. + * - Positive `y` values extend **downward**. + * - Rotation is **clockwise**, in **radians**, around the rectangle's **center**. + */ export class TransformDOMRect implements DOMRect { - // Private properties - private _x: number; - private _y: number; - private _width: number; - private _height: number; - private _rotation: number; + // Private properties for position, size, and rotation + private _x: number; // X-coordinate of the top-left corner + private _y: number; // Y-coordinate of the top-left corner + private _width: number; // Width of the rectangle + private _height: number; // Height of the rectangle + private _rotation: number; // Rotation angle in radians, clockwise - // Internal matrices - #transformMatrix: Matrix; - #inverseMatrix: Matrix; + // Internal transformation matrices + #transformMatrix: Matrix; // Transforms from local to parent space + #inverseMatrix: Matrix; // Transforms from parent to local space + /** + * Constructs a new `TransformDOMRect`. + * @param init - Optional initial values. + */ constructor(init: TransformDOMRectInit = {}) { this._x = init.x ?? 0; this._y = init.y ?? 0; @@ -28,14 +42,17 @@ export class TransformDOMRect implements DOMRect { this._height = init.height ?? 0; this._rotation = init.rotation ?? 0; - // Initialize matrices + // Initialize transformation matrices this.#transformMatrix = Matrix.Identity(); this.#inverseMatrix = Matrix.Identity(); + // Update matrices based on current properties this.#updateMatrices(); } // Getters and setters for properties + + /** Gets or sets the **x-coordinate** of the top-left corner. */ get x(): number { return this._x; } @@ -44,6 +61,7 @@ export class TransformDOMRect implements DOMRect { this.#updateMatrices(); } + /** Gets or sets the **y-coordinate** of the top-left corner. */ get y(): number { return this._y; } @@ -52,6 +70,7 @@ export class TransformDOMRect implements DOMRect { this.#updateMatrices(); } + /** Gets or sets the **width** of the rectangle. */ get width(): number { return this._width; } @@ -60,6 +79,7 @@ export class TransformDOMRect implements DOMRect { this.#updateMatrices(); } + /** Gets or sets the **height** of the rectangle. */ get height(): number { return this._height; } @@ -68,6 +88,7 @@ export class TransformDOMRect implements DOMRect { this.#updateMatrices(); } + /** Gets or sets the **rotation angle** in radians, **clockwise**. */ get rotation(): number { return this._rotation; } @@ -77,35 +98,53 @@ export class TransformDOMRect implements DOMRect { } // DOMRect read-only properties + + /** The **left** coordinate of the rectangle (same as `x`). */ get left(): number { return this.x; } + /** The **top** coordinate of the rectangle (same as `y`). */ get top(): number { return this.y; } + /** The **right** coordinate of the rectangle (`x + width`). */ get right(): number { return this.x + this.width; } + /** The **bottom** coordinate of the rectangle (`y + height`). */ get bottom(): number { return this.y + this.height; } - // Updates the transformation matrices using instance functions + /** + * Updates the transformation matrices based on the current position, + * size, and rotation of the rectangle. + * + * The transformation sequence is: + * 1. **Translate** to the center of the rectangle. + * 2. **Rotate** around the center. + * 3. **Translate** back to the top-left corner. + */ #updateMatrices() { // Reset the transformMatrix to identity this.#transformMatrix.identity(); - // Compute the center point + // Compute the center point of the rectangle const centerX = this._x + this._width / 2; const centerY = this._y + this._height / 2; - // Apply transformations: translate to center, rotate, translate back - this.#transformMatrix.translate(centerX, centerY); - this.#transformMatrix.rotate(this._rotation); - this.#transformMatrix.translate(-this._width / 2, -this._height / 2); + // Apply transformations in this order: + // 1. Translate to center + // 2. Rotate around center + // 3. Translate back to position + this.#transformMatrix + .translate(centerX, centerY) + .rotate(this._rotation) + .translate(-centerX, -centerY) + .translate(this._x, this._y); // Update inverseMatrix as the inverse of transformMatrix this.#inverseMatrix = this.#transformMatrix.clone().invert(); @@ -120,33 +159,57 @@ export class TransformDOMRect implements DOMRect { return this.#inverseMatrix; } - // Converts a point from parent space to local space + /** + * Converts a point from **parent space** to **local space**. + * @param point - The point in parent coordinate space. + * @returns The point in local coordinate space. + */ toLocalSpace(point: Point): Point { return this.#inverseMatrix.applyToPoint(point); } - // Converts a point from local space to parent space + /** + * Converts a point from **local space** to **parent space**. + * @param point - The point in local coordinate space. + * @returns The point in parent coordinate space. + */ toParentSpace(point: Point): Point { return this.#transformMatrix.applyToPoint(point); } // Local space corners + + /** + * Gets the **top-left** corner of the rectangle in **local space** (before transformation). + */ get topLeft(): Point { return { x: 0, y: 0 }; } + /** + * Gets the **top-right** corner of the rectangle in **local space** (before transformation). + */ get topRight(): Point { return { x: this.width, y: 0 }; } + /** + * Gets the **bottom-right** corner of the rectangle in **local space** (before transformation). + */ get bottomRight(): Point { return { x: this.width, y: this.height }; } + /** + * Gets the **bottom-left** corner of the rectangle in **local space** (before transformation). + */ get bottomLeft(): Point { return { x: 0, y: this.height }; } + /** + * Gets the **center point** of the rectangle in **parent space**. + */ get center(): Point { return { x: this.x + this.width / 2, @@ -154,14 +217,26 @@ export class TransformDOMRect implements DOMRect { }; } + /** + * Gets the four corner vertices of the rectangle in **local space**. + * @returns An array of points in the order: top-left, top-right, bottom-right, bottom-left. + */ vertices(): Point[] { return [this.topLeft, this.topRight, this.bottomRight, this.bottomLeft]; } + /** + * Generates a CSS transform string representing the rectangle's transformation. + * @returns A string suitable for use in CSS `transform` properties. + */ toCssString(): string { return this.transformMatrix.toCssString(); } + /** + * Converts the rectangle's properties to a JSON serializable object. + * @returns An object containing the rectangle's `x`, `y`, `width`, `height`, and `rotation`. + */ toJSON() { return { x: this.x, @@ -172,42 +247,59 @@ export class TransformDOMRect implements DOMRect { }; } + /** + * Sets the **top-left** corner of the rectangle in **local space**, adjusting the position, width, and height accordingly. + * @param point - The new top-left corner point in local coordinate space. + */ setTopLeft(point: Point) { - const oldBottomRight = this.toParentSpace(this.bottomRight); - this._x = point.x; - this._y = point.y; - this._width = oldBottomRight.x - point.x; - this._height = oldBottomRight.y - point.y; + this._x += point.x; + this._y += point.y; + this._width -= point.x; + this._height -= point.y; this.#updateMatrices(); } + /** + * Sets the **top-right** corner of the rectangle in **local space**, adjusting the position, width, and height accordingly. + * @param point - The new top-right corner point in local coordinate space. + */ setTopRight(point: Point) { - const oldBottomLeft = this.toParentSpace(this.bottomLeft); - this._y = point.y; - this._width = point.x - this._x; - this._height = oldBottomLeft.y - point.y; + this._y += point.y; + this._width = point.x; + this._height -= point.y; this.#updateMatrices(); } + /** + * Sets the **bottom-right** corner of the rectangle in **local space**, adjusting the width and height accordingly. + * @param point - The new bottom-right corner point in local coordinate space. + */ setBottomRight(point: Point) { this._width = point.x; this._height = point.y; this.#updateMatrices(); } + /** + * Sets the **bottom-left** corner of the rectangle in **local space**, adjusting the position, width, and height accordingly. + * @param point - The new bottom-left corner point in local coordinate space. + */ setBottomLeft(point: Point) { - const oldTopRight = this.toParentSpace(this.topRight); - this._x = point.x; - this._width = oldTopRight.x - point.x; - this._height = point.y - this._y; + this._x += point.x; + this._width -= point.x; + this._height = point.y; this.#updateMatrices(); } + /** + * Computes the **axis-aligned bounding box** of the transformed rectangle in **parent space**. + * @returns An object representing the bounding rectangle with properties: `x`, `y`, `width`, `height`. + */ getBounds(): DOMRectInit { // Transform all vertices to parent space const transformedVertices = this.vertices().map((v) => this.toParentSpace(v)); - // Find min and max points + // Find min and max coordinates const xs = transformedVertices.map((v) => v.x); const ys = transformedVertices.map((v) => v.y); @@ -225,85 +317,98 @@ export class TransformDOMRect implements DOMRect { } } -// Read-only version of TransformDOMRect +/** + * A **read-only** version of `TransformDOMRect` that prevents modification of position, + * size, and rotation properties. + */ export class TransformDOMRectReadonly extends TransformDOMRect { constructor(init: TransformDOMRectInit = {}) { super(init); } - // Explicit getter overrides - get x(): number { + // Explicit overrides for all getters from parent class + override get x(): number { return super.x; } - get y(): number { + override get y(): number { return super.y; } - get width(): number { + override get width(): number { return super.width; } - get height(): number { + override get height(): number { return super.height; } - get rotation(): number { + override get rotation(): number { return super.rotation; } - // DOMRect property getters - get left(): number { + override get left(): number { return super.left; } - get top(): number { + override get top(): number { return super.top; } - get right(): number { + override get right(): number { return super.right; } - get bottom(): number { + override get bottom(): number { return super.bottom; } - // Override all setters to prevent modification - set x(value: number) { - throw new Error('Cannot modify readonly TransformDOMRect'); + override get transformMatrix(): Matrix { + return super.transformMatrix; } - set y(value: number) { - throw new Error('Cannot modify readonly TransformDOMRect'); + override get inverseMatrix(): Matrix { + return super.inverseMatrix; } - set width(value: number) { - throw new Error('Cannot modify readonly TransformDOMRect'); + override get topLeft(): Point { + return super.topLeft; } - set height(value: number) { - throw new Error('Cannot modify readonly TransformDOMRect'); + override get topRight(): Point { + return super.topRight; } - set rotation(value: number) { - throw new Error('Cannot modify readonly TransformDOMRect'); + override get bottomRight(): Point { + return super.bottomRight; } - // Override vertex setter methods - setTopLeft(point: Point): void { - throw new Error('Cannot modify readonly TransformDOMRect'); + override get bottomLeft(): Point { + return super.bottomLeft; } - setTopRight(point: Point): void { - throw new Error('Cannot modify readonly TransformDOMRect'); + override get center(): Point { + return super.center; } - setBottomRight(point: Point): void { - throw new Error('Cannot modify readonly TransformDOMRect'); + // Add no-op setters + override set x(value: number) { + // no-op } - setBottomLeft(point: Point): void { - throw new Error('Cannot modify readonly TransformDOMRect'); + override set y(value: number) { + // no-op + } + + override set width(value: number) { + // no-op + } + + override set height(value: number) { + // no-op + } + + override set rotation(value: number) { + // no-op } }