diff --git a/package.json b/package.json index 80a9b224a3..bfd7577fa6 100644 --- a/package.json +++ b/package.json @@ -102,12 +102,12 @@ "browserify": "^13.0.1", "chai": "^3.5.0", "css-loader": "^0.26.0", + "devtools-license-check": "^0.2.0", "eslint": "^3.10.2", "eslint-config-google": "^0.6.0", "eslint-plugin-flowtype": "^2.30.0", "eslint-plugin-import": "^2.2.0", "eslint-plugin-react": "^6.4.0", - "devtools-license-check": "^0.2.0", "fake-indexeddb": "^1.0.12", "file-loader": "^0.9.0", "flow-bin": "^0.40.0", @@ -121,6 +121,7 @@ "lodash.clonedeep": "^4.5.0", "mkdirp": "^0.5.1", "raw-loader": "^0.5.1", + "react-test-renderer": "^15.5.4", "rimraf": "^2.5.4", "sinon": "^2.1.0", "style-loader": "^0.13.1", @@ -130,10 +131,18 @@ "webpack-hot-middleware": "^2.13.2" }, "jest": { - "collectCoverageFrom" : ["src/**/*.{js,jsx}", "!**/node_modules/**"], + "collectCoverageFrom": [ + "src/**/*.{js,jsx}", + "!**/node_modules/**" + ], "testEnvironment": "jsdom", - "moduleFileExtensions": ["js", "jsx"], - "moduleDirectories": ["node_modules"], + "moduleFileExtensions": [ + "js", + "jsx" + ], + "moduleDirectories": [ + "node_modules" + ], "moduleNameMapper": { "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/src/test/fixtures/mocks/file-mock.js", "\\.(css|less)$": "/src/test/fixtures/mocks/style-mock.js" diff --git a/src/content/components/TimelineCanvas.js b/src/content/components/TimelineCanvas.js index 727263bc19..7c0f53e816 100644 --- a/src/content/components/TimelineCanvas.js +++ b/src/content/components/TimelineCanvas.js @@ -31,6 +31,7 @@ export default class TimelineCanvas extends Component< _requestedAnimationFrame: boolean; _devicePixelRatio: 1; _ctx: CanvasRenderingContext2D; + _canvas: ?HTMLCanvasElement; constructor(props: Props) { super(props); @@ -38,6 +39,7 @@ export default class TimelineCanvas extends Component< this._devicePixelRatio = 1; this.state = { hoveredItem: null }; + (this: any)._setCanvasRef = this._setCanvasRef.bind(this); (this: any)._onMouseMove = this._onMouseMove.bind(this); (this: any)._onMouseOut = this._onMouseOut.bind(this); (this: any)._onDoubleClick = this._onDoubleClick.bind(this); @@ -55,7 +57,7 @@ export default class TimelineCanvas extends Component< this._requestedAnimationFrame = true; window.requestAnimationFrame(() => { this._requestedAnimationFrame = false; - if (this.refs.canvas) { + if (this._canvas) { timeCode(`${className} render`, () => { this._prepCanvas(); drawCanvas(this._ctx, this.state.hoveredItem); @@ -66,39 +68,41 @@ export default class TimelineCanvas extends Component< } _prepCanvas() { - const {canvas} = this.refs; + const canvas = this._canvas; const {containerWidth, containerHeight} = this.props; const {devicePixelRatio} = window; const pixelWidth: DevicePixels = containerWidth * devicePixelRatio; const pixelHeight: DevicePixels = containerHeight * devicePixelRatio; + if (!canvas) { + return; + } // Satisfy the null check for Flow. + const ctx = this._ctx || canvas.getContext('2d'); if (!this._ctx) { - this._ctx = canvas.getContext('2d'); + this._ctx = ctx; } if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { canvas.width = pixelWidth; canvas.height = pixelHeight; canvas.style.width = containerWidth + 'px'; canvas.style.height = containerHeight + 'px'; - this._ctx.scale(this._devicePixelRatio, this._devicePixelRatio); + ctx.scale(this._devicePixelRatio, this._devicePixelRatio); } if (this._devicePixelRatio !== devicePixelRatio) { // Make sure and multiply by the inverse of the previous ratio, as the scaling // operates off of the previous set scale. const scale = (1 / this._devicePixelRatio) * devicePixelRatio; - this._ctx.scale(scale, scale); + ctx.scale(scale, scale); this._devicePixelRatio = devicePixelRatio; } - return this._ctx; } _onMouseMove(event: SyntheticMouseEvent) { - const { canvas } = this.refs; - if (!canvas) { + if (!this._canvas) { return; } - const rect = canvas.getBoundingClientRect(); + const rect = this._canvas.getBoundingClientRect(); const x: CssPixels = event.pageX - rect.left; const y: CssPixels = event.pageY - rect.top; @@ -126,6 +130,10 @@ export default class TimelineCanvas extends Component< return this.props.getHoveredItemInfo(hoveredItem); } + _setCanvasRef(canvas: HTMLCanvasElement) { + this._canvas = canvas; + } + render() { const { hoveredItem } = this.state; this._scheduleDraw(); @@ -137,7 +145,7 @@ export default class TimelineCanvas extends Component< }); return (WrappedComponent: ReactClass) { class TimelineViewport extends PureComponent { - props: Props - shiftScrollId: number - zoomRangeSelectionScheduled: boolean - zoomRangeSelectionScrollDelta: number + props: Props; + shiftScrollId: number; + zoomRangeSelectionScheduled: boolean; + zoomRangeSelectionScrollDelta: number; + _container: ?HTMLElement; state: { containerWidth: CssPixels, @@ -178,14 +181,16 @@ export default function withTimelineViewport(WrappedComponent: ReactClass) } _setSize() { - const rect = this.refs.container.getBoundingClientRect(); - if (this.state.containerWidth !== rect.width || this.state.containerHeight !== rect.height) { - this.setState({ - containerWidth: rect.width, - containerHeight: rect.height, - containerLeft: rect.left, - viewportBottom: this.state.viewportTop + rect.height, - }); + if (this._container) { + const rect = this._container.getBoundingClientRect(); + if (this.state.containerWidth !== rect.width || this.state.containerHeight !== rect.height) { + this.setState({ + containerWidth: rect.width, + containerHeight: rect.height, + containerLeft: rect.left, + viewportBottom: this.state.viewportTop + rect.height, + }); + } } } @@ -224,13 +229,14 @@ export default function withTimelineViewport(WrappedComponent: ReactClass) isViewportOccluded(event: SyntheticWheelEvent): boolean { const scrollElement = this.props.getScrollElement(); - if (!scrollElement) { + const container = this._container; + if (!scrollElement || !container) { return false; } // Calculate using getBoundingClientRect to get non-rounded CssPixels. const innerScrollRect = scrollElement.children[0].getBoundingClientRect(); const scrollRect = scrollElement.getBoundingClientRect(); - const viewportRect = this.refs.container.getBoundingClientRect(); + const viewportRect = container.getBoundingClientRect(); if (event.deltaY < 0) { // ______________ viewportRect @@ -469,7 +475,9 @@ export default function withTimelineViewport(WrappedComponent: ReactClass)
+ ref={container => { + this._container = container; + }}> { + // Tie the requestAnimationFrame into jest's fake timers. + window.requestAnimationFrame = fn => setTimeout(fn, 0); + window.devicePixelRatio = 1; + const ctx = mockCanvasContext(); + + /** + * Mock out any created refs for the components with relevant information. + */ + function createNodeMock(element) { + // + if (element.type === 'canvas') { + return { + getBoundingClientRect: () => _getBoundingBox(200, 300), + getContext: () => ctx, + style: {}, + }; + } + // + if (element.props.className.split(' ').includes('timelineViewport')) { + return { + getBoundingClientRect: () => _getBoundingBox(200, 300), + }; + } + return null; + } + + const profile = getProfileWithMarkers([ + ['Marker A', 0, {startTime: 0, endTime: 10}], + ['Marker B', 0, {startTime: 0, endTime: 10}], + ['Marker C', 0, {startTime: 5, endTime: 15}], + ]); + + const timeline = renderer.create( + + + , + {createNodeMock} + ); + + // Flush any requestAnimationFrames. + jest.runAllTimers(); + + const tree = timeline.toJSON(); + const drawCalls = ctx.__flushDrawLog(); + + expect(tree).toMatchSnapshot(); + expect(drawCalls).toMatchSnapshot(); + + delete window.requestAnimationFrame; + delete window.devicePixelRatio; +}); + +function _getBoundingBox(width, height) { + return { + width, + height, + left: 0, + x: 0, + top: 0, + y: 0, + right: width, + bottom: height, + }; +} diff --git a/src/test/components/__snapshots__/TimelineMarkers.test.js.snap b/src/test/components/__snapshots__/TimelineMarkers.test.js.snap new file mode 100644 index 0000000000..628a174564 --- /dev/null +++ b/src/test/components/__snapshots__/TimelineMarkers.test.js.snap @@ -0,0 +1,184 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders TimelineMarkers correctly 1`] = ` +
+
+ + Empty + +
+
+ +
+ Zoom Timeline: + + Shift + + + Scroll + +
+
+
+`; + +exports[`renders TimelineMarkers correctly 2`] = ` +Array [ + Array [ + "scale", + 1, + 1, + ], + Array [ + "clearRect", + 0, + 0, + 200, + 300, + ], + Array [ + "measureText", + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.()< /:-_", + ], + Array [ + "measureText", + "…", + ], + Array [ + "set lineWidth", + 1, + ], + Array [ + "set lineWidth", + 1, + ], + Array [ + "set lineWidth", + 1, + ], + Array [ + "set fillStyle", + "#eee", + ], + Array [ + "fillRect", + 0, + 16, + 200, + 1, + ], + Array [ + "fillRect", + 0, + 32, + 200, + 1, + ], + Array [ + "fillRect", + 0, + 48, + 200, + 1, + ], + Array [ + "createLinearGradient", + 0, + 0, + 150, + 0, + ], + Array [ + "addColorStop", + 0, + "rgba(255, 255, 255, 0.8)", + ], + Array [ + "addColorStop", + 1, + "rgba(255, 255, 255, 0.0)", + ], + Array [ + "set fillStyle", + Object { + "addColorStop": [Function], + }, + ], + Array [ + "fillRect", + 0, + 0, + 150, + 16, + ], + Array [ + "fillRect", + 0, + 16, + 150, + 16, + ], + Array [ + "fillRect", + 0, + 32, + 150, + 16, + ], + Array [ + "set fillStyle", + "#000000", + ], + Array [ + "fillText", + "Marker A", + 5, + 11, + ], + Array [ + "fillText", + "Marker B", + 5, + 27, + ], + Array [ + "fillText", + "Marker C", + 5, + 43, + ], +] +`; diff --git a/src/test/fixtures/mocks/canvas-context.js b/src/test/fixtures/mocks/canvas-context.js new file mode 100644 index 0000000000..e816635eef --- /dev/null +++ b/src/test/fixtures/mocks/canvas-context.js @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// @flow +type MaybeFn = (any => any) | void +const identity = () => {}; + +export default function mockCanvasContext() { + const log: Array = []; + + /** + * Logs canvas operations. The log will preserve the order of operations, which is + * important for canvas rendering. + */ + function spyLog(name: string, fn: MaybeFn = identity) { + return jest.fn((...args) => { + log.push([name, ...args]); + if (fn) { + return fn.apply(null, args); + } + return undefined; + }); + } + + return new Proxy( + { + scale: spyLog('scale'), + fillRect: spyLog('fillRect'), + fillText: spyLog('fillText'), + clearRect: spyLog('clearRect'), + beginPath: spyLog('beginPath'), + closePath: spyLog('closePath'), + arc: spyLog('arc'), + measureText: spyLog('measureText', text => ({width: text.length * 5})), + createLinearGradient: spyLog('createLinearGradient', () => ({ + addColorStop: spyLog('addColorStop'), + })), + __flushDrawLog: (): Array => { + const oldLog = log.slice(); + log.splice(0, log.length); + return oldLog; + }, + }, + { + // Record what values are set on the context. + set(target, property, value) { + target[property] = value; + log.push(['set ' + property, value]); + return true; + }, + } + ); +}