Repository: hustcc/jest-canvas-mock (js)

Issue:
[RFC]: Add Testing Feature ctx.__getClipRegion()
Hello,

According to...

https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/save

> The drawing state that gets saved onto a stack consists of:
> - ...
> - **The current clipping region.**
> - ...

... the clipping region is also involved in `ctx.save()` and `ctx.restore()` operations, but that seems not to be implemented in `jest-canvas-mock`.

A very easy solution to this would be to create a `_clipStack` object on the main class to keep the saved clips. Not sure though which should be the initial clip (on the bottom of the stack).

_PS: sorry to open so many issues lately, but this library is a fine piece of work even more incredible after solving those small issues 👌. Thank you!_

Pull request description:
feat(clip): add clippath
fixes #54 

Not sure if the tests are conclusive enough, but I am pretty sure this covers the niche need for testing clipping regions.

Patch:
diff --git a/README.md b/README.md
index 9cb9c93..09e7ec4 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
 [![Coverage Status](https://coveralls.io/repos/github/hustcc/jest-canvas-mock/badge.svg?branch=master)](https://coveralls.io/github/hustcc/jest-canvas-mock)
 [![npm](https://img.shields.io/npm/v/jest-canvas-mock.svg)](https://www.npmjs.com/package/jest-canvas-mock)
 [![npm](https://img.shields.io/npm/dm/jest-canvas-mock.svg)](https://www.npmjs.com/package/jest-canvas-mock)
-
+[![Mentioned in Awesome Jest](https://awesome.re/mentioned-badge.svg)](https://github.com/jest-community/awesome-jest)
 
 ## Install
 
@@ -16,7 +16,6 @@ This should only be installed as a development dependency (`devDependencies`) as
 npm i --save-dev jest-canvas-mock
 ```
 
-
 ## Setup
 
 In your `package.json` under the `jest`, create a `setupFiles` array and add `jest-canvas-mock` to the array.
@@ -41,7 +40,6 @@ If you already have a `setupFiles` attribute you can also append `jest-canvas-mo
 
 More about in [configuration section](https://facebook.github.io/jest/docs/en/configuration.html#content).
 
-
 ## Setup file
 
 Alternatively you can create a new setup file which then requires this module or
@@ -98,7 +96,7 @@ expect(() => ctx.fill(new Path2D(), "invalid!")).toThrow(TypeError);
 
 We try to follow the ECMAScript specification as closely as possible.
 
-# Snapshots
+## Snapshots
 
 There are multiple ways to validate canvas state. There are currently three `static` methods attached
 to the `CanvasRenderingContext2D` class. The first way to use this feature is by using the `__getEvents`
diff --git a/package.json b/package.json
index c6d459c..f639ed7 100644
--- a/package.json
+++ b/package.json
@@ -15,16 +15,16 @@
     "parse-color": "^1.0.0"
   },
   "devDependencies": {
-    "@babel/cli": "^7.6.4",
-    "@babel/core": "^7.6.4",
-    "@babel/plugin-proposal-class-properties": "^7.5.5",
-    "@babel/preset-env": "^7.6.3",
+    "@babel/cli": "^7.7.0",
+    "@babel/core": "^7.7.2",
+    "@babel/plugin-proposal-class-properties": "^7.7.0",
+    "@babel/preset-env": "^7.7.1",
     "@commitlint/cli": "^8.2.0",
     "@commitlint/config-angular": "^8.2.0",
     "babel-jest": "^24.9.0",
     "babel-plugin-version": "^0.2.3",
-    "coveralls": "^3.0.7",
-    "husky": "^3.0.9",
+    "coveralls": "^3.0.8",
+    "husky": "^3.1.0",
     "jest": "^24.9.0"
   },
   "commitlint": {
diff --git a/src/classes/CanvasRenderingContext2D.js b/src/classes/CanvasRenderingContext2D.js
index 325c267..73ff9ae 100644
--- a/src/classes/CanvasRenderingContext2D.js
+++ b/src/classes/CanvasRenderingContext2D.js
@@ -4,6 +4,7 @@ import parseColor from 'parse-color';
 import cssfontparser from 'cssfontparser';
 import TextMetrics from './TextMetrics';
 import createCanvasEvent from '../mock/createCanvasEvent';
+import Path2D from "./Path2D";
 
 function parseCSSColor(value) {
   const result = parseColor(value);
@@ -83,9 +84,18 @@ export default class CanvasRenderingContext2D {
       getTransformSlice(this),
       { },
     );
+    // The clipping path should start after the initial beginPath instruction
+    this._clipIndex = 1;
     this._path = [event];
   }
 
+  /**
+   * Get the current clipping region.
+   */
+  __getClippingRegion() {
+    return this._clipStack[this._stackIndex];
+  }
+
   _directionStack = ['inherit'];
   _fillStyleStack = ['#000'];
   _filterStack = ['none'];
@@ -109,6 +119,7 @@ export default class CanvasRenderingContext2D {
   _textAlignStack = ['start'];
   _textBaselineStack = ['alphabetic'];
   _transformStack = [[1, 0, 0, 1, 0, 0]];
+  _clipStack=[[]];
 
   constructor(canvas) {
     testFuncs.forEach(key => {
@@ -247,20 +258,32 @@ export default class CanvasRenderingContext2D {
     this._drawCalls.push(event);
   }
 
+  /**
+   * This index points to the next path item that should be written to the clipStack
+   * when ctx.clip() is called.
+   */
+  _clipIndex = 1;
+
   clip(path, fillRule) {
+    let clipPath;
     if (arguments.length === 0) {
       fillRule = 'nonzero';
       path = this._path.slice();
+      clipPath = path.slice(this._clipIndex);
+      this._clipIndex = path.length;
     } else {
       if (arguments.length === 1) fillRule = 'nonzero';
       if (path instanceof Path2D) {
         fillRule = String(fillRule);
         if (fillRule !== 'nonzero' && fillRule !== 'evenodd') throw new TypeError('Failed to execute \'clip\' on \'' + this.constructor.name + '\': The provided value \'' + fillRule + '\' is not a valid enum value of type CanvasFillRule.');
         path = path._path.slice();
+        clipPath = path;
       } else {
         fillRule = String(path);
         if (fillRule !== 'nonzero' && fillRule !== 'evenodd') throw new TypeError('Failed to execute \'clip\' on \'' + this.constructor.name + '\': The provided value \'' + fillRule + '\' is not a valid enum value of type CanvasFillRule.');
         path = this._path.slice();
+        clipPath = path.slice(this._clipIndex);
+        this._clipIndex = path.length;
       }
     }
 
@@ -272,6 +295,8 @@ export default class CanvasRenderingContext2D {
 
     this._path.push(event);
     this._events.push(event);
+    const currentClip = this._clipStack[this._stackIndex];
+    this._clipStack[this._stackIndex] = currentClip.concat(clipPath);
   }
 
   closePath() {
@@ -1100,29 +1125,31 @@ export default class CanvasRenderingContext2D {
   }
 
   save() {
-    this._transformStack.push(this._transformStack[this._stackIndex].slice());
-    this._directionStack.push(this._directionStack[this._stackIndex]);
-    this._fillStyleStack.push(this._fillStyleStack[this._stackIndex]);
-    this._filterStack.push(this._filterStack[this._stackIndex]);
-    this._fontStack.push(this._fontStack[this._stackIndex]);
-    this._globalAlphaStack.push(this._globalAlphaStack[this._stackIndex]);
-    this._globalCompositeOperationStack.push(this._globalCompositeOperationStack[this._stackIndex]);
-    this._imageSmoothingEnabledStack.push(this._imageSmoothingEnabledStack[this._stackIndex]);
-    this._imageSmoothingQualityStack.push(this._imageSmoothingQualityStack[this._stackIndex]);
-    this._lineCapStack.push(this._lineCapStack[this._stackIndex]);
-    this._lineDashStack.push(this._lineDashStack[this._stackIndex]);
-    this._lineDashOffsetStack.push(this._lineDashOffsetStack[this._stackIndex]);
-    this._lineJoinStack.push(this._lineJoinStack[this._stackIndex]);
-    this._lineWidthStack.push(this._lineWidthStack[this._stackIndex]);
-    this._miterLimitStack.push(this._miterLimitStack[this._stackIndex]);
-    this._shadowBlurStack.push(this._shadowBlurStack[this._stackIndex]);
-    this._shadowColorStack.push(this._shadowColorStack[this._stackIndex]);
-    this._shadowOffsetXStack.push(this._shadowOffsetXStack[this._stackIndex]);
-    this._shadowOffsetYStack.push(this._shadowOffsetYStack[this._stackIndex]);
-    this._strokeStyleStack.push(this._strokeStyleStack[this._stackIndex]);
-    this._textAlignStack.push(this._textAlignStack[this._stackIndex]);
-    this._textBaselineStack.push(this._textBaselineStack[this._stackIndex]);
-    this._stackIndex += 1;
+    const stackIndex = this._stackIndex;
+    this._transformStack.push(this._transformStack[stackIndex].slice());
+    this._directionStack.push(this._directionStack[stackIndex]);
+    this._fillStyleStack.push(this._fillStyleStack[stackIndex]);
+    this._filterStack.push(this._filterStack[stackIndex]);
+    this._fontStack.push(this._fontStack[stackIndex]);
+    this._globalAlphaStack.push(this._globalAlphaStack[stackIndex]);
+    this._globalCompositeOperationStack.push(this._globalCompositeOperationStack[stackIndex]);
+    this._imageSmoothingEnabledStack.push(this._imageSmoothingEnabledStack[stackIndex]);
+    this._imageSmoothingQualityStack.push(this._imageSmoothingQualityStack[stackIndex]);
+    this._lineCapStack.push(this._lineCapStack[stackIndex]);
+    this._lineDashStack.push(this._lineDashStack[stackIndex]);
+    this._lineDashOffsetStack.push(this._lineDashOffsetStack[stackIndex]);
+    this._lineJoinStack.push(this._lineJoinStack[stackIndex]);
+    this._lineWidthStack.push(this._lineWidthStack[stackIndex]);
+    this._miterLimitStack.push(this._miterLimitStack[stackIndex]);
+    this._shadowBlurStack.push(this._shadowBlurStack[stackIndex]);
+    this._shadowColorStack.push(this._shadowColorStack[stackIndex]);
+    this._shadowOffsetXStack.push(this._shadowOffsetXStack[stackIndex]);
+    this._shadowOffsetYStack.push(this._shadowOffsetYStack[stackIndex]);
+    this._strokeStyleStack.push(this._strokeStyleStack[stackIndex]);
+    this._textAlignStack.push(this._textAlignStack[stackIndex]);
+    this._textBaselineStack.push(this._textBaselineStack[stackIndex]);
+    this._clipStack.push(this._clipStack[stackIndex].slice());
+    this._stackIndex = stackIndex + 1;
 
     const event = createCanvasEvent(
       'save',
diff --git a/types/index.d.ts b/types/index.d.ts
index 1acca41..9882705 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -82,5 +82,13 @@ declare global {
      * `jest-canvas-mock` and should be only used for testing.
      */
     __clearPath(): void;
+
+    /**
+     * Obtains the current clipping path.
+     *
+     * This method cannot be used in a production environment, only with `jest` using
+     * `jest-canvas-mock` and should be only used for testing.
+     */
+    __getClippingRegion(): CanvasRenderingContext2DEvent[];
   }
 }


Test patch:
diff --git a/__tests__/classes/CanvasRenderingContext2D.__getClippingPath.js b/__tests__/classes/CanvasRenderingContext2D.__getClippingPath.js
new file mode 100644
index 0000000..fd2318c
--- /dev/null
+++ b/__tests__/classes/CanvasRenderingContext2D.__getClippingPath.js
@@ -0,0 +1,55 @@
+let ctx;
+beforeEach(() => {
+  // get a new context each test
+  ctx = document.createElement('canvas')
+    .getContext('2d');
+});
+
+afterEach(() => {
+  const drawCalls = ctx.__getClippingRegion();
+  expect(drawCalls).toMatchSnapshot();
+});
+
+describe('__getClippingRegion', () => {
+  it("should be empty when there are no path elements", () => {
+    ctx.clip();
+  });
+
+  it("should store the clipping region", () => {
+    ctx.rect(1, 2, 3, 4);
+    ctx.arc(1, 2, 3, 4, 5);
+    ctx.clip();
+  });
+
+  it("shouldn't store the whole clipping region twice when clip is called twice", () => {
+    ctx.rect(1, 2, 3, 4);
+    ctx.arc(1, 2, 3, 4, 5);
+    ctx.clip();
+    ctx.rect(1, 2, 3, 4);
+    ctx.arc(1, 2, 3, 4, 5);
+    ctx.clip();
+  });
+
+  it("should save the clipping region correctly when saved", () => {
+    ctx.rect(1, 2, 3, 4);
+    ctx.arc(1, 2, 3, 4, 5);
+    ctx.clip();
+    const region = ctx.__getClippingRegion();
+    ctx.save();
+    expect(region).toStrictEqual(ctx.__getClippingRegion());
+  });
+
+  it("should save the clipping region correctly when saved", () => {
+    ctx.rect(1, 2, 3, 4);
+    ctx.arc(1, 2, 3, 4, 5);
+    ctx.clip();
+    const region = ctx.__getClippingRegion();
+    ctx.save();
+    ctx.rect(1, 2, 3, 4);
+    ctx.arc(1, 2, 3, 4, 5);
+    ctx.clip();
+    expect(region).not.toStrictEqual(ctx.__getClippingRegion());
+    ctx.restore();
+    expect(region).toStrictEqual(ctx.__getClippingRegion());
+  });
+});
diff --git a/__tests__/classes/__snapshots__/CanvasRenderingContext2D.__getClippingPath.js.snap b/__tests__/classes/__snapshots__/CanvasRenderingContext2D.__getClippingPath.js.snap
new file mode 100644
index 0000000..7dd3119
--- /dev/null
+++ b/__tests__/classes/__snapshots__/CanvasRenderingContext2D.__getClippingPath.js.snap
@@ -0,0 +1,267 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`__getClippingRegion should be empty when there are no path elements 1`] = `Array []`;
+
+exports[`__getClippingRegion should save the clipping region correctly when saved 1`] = `
+Array [
+  Object {
+    "props": Object {
+      "height": 4,
+      "width": 3,
+      "x": 1,
+      "y": 2,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "rect",
+  },
+  Object {
+    "props": Object {
+      "anticlockwise": false,
+      "endAngle": 5,
+      "radius": 3,
+      "startAngle": 4,
+      "x": 1,
+      "y": 2,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "arc",
+  },
+]
+`;
+
+exports[`__getClippingRegion should save the clipping region correctly when saved 2`] = `
+Array [
+  Object {
+    "props": Object {
+      "height": 4,
+      "width": 3,
+      "x": 1,
+      "y": 2,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "rect",
+  },
+  Object {
+    "props": Object {
+      "anticlockwise": false,
+      "endAngle": 5,
+      "radius": 3,
+      "startAngle": 4,
+      "x": 1,
+      "y": 2,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "arc",
+  },
+]
+`;
+
+exports[`__getClippingRegion should store the clipping region 1`] = `
+Array [
+  Object {
+    "props": Object {
+      "height": 4,
+      "width": 3,
+      "x": 1,
+      "y": 2,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "rect",
+  },
+  Object {
+    "props": Object {
+      "anticlockwise": false,
+      "endAngle": 5,
+      "radius": 3,
+      "startAngle": 4,
+      "x": 1,
+      "y": 2,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "arc",
+  },
+]
+`;
+
+exports[`__getClippingRegion shouldn't store the whole clipping region twice when clip is called twice 1`] = `
+Array [
+  Object {
+    "props": Object {
+      "height": 4,
+      "width": 3,
+      "x": 1,
+      "y": 2,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "rect",
+  },
+  Object {
+    "props": Object {
+      "anticlockwise": false,
+      "endAngle": 5,
+      "radius": 3,
+      "startAngle": 4,
+      "x": 1,
+      "y": 2,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "arc",
+  },
+  Object {
+    "props": Object {
+      "fillRule": "nonzero",
+      "path": Array [
+        Object {
+          "props": Object {},
+          "transform": Array [
+            1,
+            0,
+            0,
+            1,
+            0,
+            0,
+          ],
+          "type": "beginPath",
+        },
+        Object {
+          "props": Object {
+            "height": 4,
+            "width": 3,
+            "x": 1,
+            "y": 2,
+          },
+          "transform": Array [
+            1,
+            0,
+            0,
+            1,
+            0,
+            0,
+          ],
+          "type": "rect",
+        },
+        Object {
+          "props": Object {
+            "anticlockwise": false,
+            "endAngle": 5,
+            "radius": 3,
+            "startAngle": 4,
+            "x": 1,
+            "y": 2,
+          },
+          "transform": Array [
+            1,
+            0,
+            0,
+            1,
+            0,
+            0,
+          ],
+          "type": "arc",
+        },
+      ],
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "clip",
+  },
+  Object {
+    "props": Object {
+      "height": 4,
+      "width": 3,
+      "x": 1,
+      "y": 2,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "rect",
+  },
+  Object {
+    "props": Object {
+      "anticlockwise": false,
+      "endAngle": 5,
+      "radius": 3,
+      "startAngle": 4,
+      "x": 1,
+      "y": 2,
+    },
+    "transform": Array [
+      1,
+      0,
+      0,
+      1,
+      0,
+      0,
+    ],
+    "type": "arc",
+  },
+]
+`;