| import './index'; |
| import { DotsSk } from './dots-sk'; |
| import { commits, traces } from './demo_data'; |
| import { |
| dotToCanvasX, |
| dotToCanvasY, |
| DOT_FILL_COLORS, |
| DOT_FILL_COLORS_HIGHLIGHTED, |
| DOT_RADIUS, |
| DOT_STROKE_COLORS, |
| MAX_UNIQUE_DIGESTS, |
| TRACE_LINE_COLOR, |
| } from './constants'; |
| import { eventPromise, setUpElementUnderTest } from '../../../infra-sk/modules/test_util'; |
| import { expect } from 'chai'; |
| import { Commit } from '../rpc_types'; |
| |
| describe('dots-sk constants', () => { |
| it('DOT_FILL_COLORS has the expected number of entries', () => { |
| expect(DOT_FILL_COLORS).to.have.length(MAX_UNIQUE_DIGESTS); |
| }); |
| |
| it('DOT_FILL_COLORS_HIGHLIGHTED has the expected number of entries', () => { |
| expect(DOT_FILL_COLORS_HIGHLIGHTED).to.have.length(MAX_UNIQUE_DIGESTS); |
| }); |
| |
| it('DOT_STROKE_COLORS has the expected number of entries', () => { |
| expect(DOT_STROKE_COLORS).to.have.length(MAX_UNIQUE_DIGESTS); |
| }); |
| }); |
| |
| describe('dots-sk', () => { |
| const newInstance = setUpElementUnderTest<DotsSk>('dots-sk'); |
| |
| let dotsSk: DotsSk; |
| let dotsSkCanvas: HTMLCanvasElement; |
| let dotsSkCanvasCtx: CanvasRenderingContext2D; |
| |
| beforeEach(() => { |
| dotsSk = newInstance((el) => { |
| // All test cases use the same set of traces and commits. |
| el.value = traces; |
| el.commits = commits; |
| }); |
| dotsSkCanvas = dotsSk.querySelector('canvas')!; |
| dotsSkCanvasCtx = dotsSkCanvas.getContext('2d')!; |
| }); |
| |
| it('renders correctly', () => { |
| expect(dotsSkCanvas.clientWidth).to.equal(210); |
| expect(dotsSkCanvas.clientHeight).to.equal(40); |
| // We specify the traces as an array and then join them instead of using a string literal |
| // to avoid having invisible (but important to the test) trailing spaces. |
| expect(canvasToAscii(dotsSkCanvasCtx)).to.equal([ |
| 'iihgfddeeddddccbbbaa', |
| ' bb-b-bbaa--aaaa ', |
| ' ccccbbbbbbaaaa', |
| ].join('\n')); |
| }); |
| |
| it('highlights traces when hovering', async () => { |
| // Hover over first trace. (X coordinate does not matter.) |
| await hoverOverDot(dotsSkCanvas, 0, 0); |
| expect(canvasToAscii(dotsSkCanvasCtx)).to.equal([ |
| 'IIHGFDDEEDDDDCCBBBAA', |
| ' bb-b-bbaa--aaaa ', |
| ' ccccbbbbbbaaaa', |
| ].join('\n')); |
| |
| // Hover over second trace. |
| await hoverOverDot(dotsSkCanvas, 15, 1); |
| expect(canvasToAscii(dotsSkCanvasCtx)).to.equal([ |
| 'iihgfddeeddddccbbbaa', |
| ' BB-B-BBAA--AAAA ', |
| ' ccccbbbbbbaaaa', |
| ].join('\n')); |
| |
| // Hover over third trace. |
| await hoverOverDot(dotsSkCanvas, 10, 2); |
| expect(canvasToAscii(dotsSkCanvasCtx)).to.equal([ |
| 'iihgfddeeddddccbbbaa', |
| ' bb-b-bbaa--aaaa ', |
| ' CCCCBBBBBBAAAA', |
| ].join('\n')); |
| }); |
| |
| it('emits "hover" event when a trace is hovered', async () => { |
| // Hover over first trace. (X coordinate does not matter.) |
| let traceLabel = await hoverOverDotAndCatchHoverEvent(dotsSkCanvas, 0, 0); |
| expect(traceLabel).to.equal(',alpha=first-trace,beta=hello,gamma=world,'); |
| |
| // Hover over second trace. |
| traceLabel = await hoverOverDotAndCatchHoverEvent(dotsSkCanvas, 15, 1); |
| expect(traceLabel).to.equal(',alpha=second-trace,beta=foo,gamma=bar,'); |
| |
| // Hover over third trace. |
| traceLabel = await hoverOverDotAndCatchHoverEvent(dotsSkCanvas, 10, 2); |
| expect(traceLabel).to.equal(',alpha=third-trace,beta=baz,gamma=qux,'); |
| }); |
| |
| it('emits "showblamelist" event when a dot is clicked', async () => { |
| // First trace, most recent commit. |
| let dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 19, 0); |
| expect(dotCommits).to.deep.equal([commits[19], commits[18]]); |
| |
| // First trace, middle-of-the-tile commit. |
| dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 10, 0); |
| expect(dotCommits).to.deep.equal([commits[10], commits[9]]); |
| |
| // First trace, oldest commit. |
| dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 0, 0); |
| expect(dotCommits).to.deep.equal([commits[0]]); |
| |
| // Second trace, most recent commit with data |
| dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 17, 1); |
| expect(dotCommits).to.deep.equal([commits[17], commits[16]]); |
| |
| // Second trace, middle-of-the-tile dot preceded by two missing dots. |
| dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 14, 1); |
| expect(dotCommits).to.deep.equal([commits[14], commits[13], commits[12], commits[11]]); |
| |
| // Second trace, oldest commit with data preceded by three missing dots. |
| dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 3, 1); |
| expect(dotCommits).to.deep.equal( |
| [commits[3], commits[2], commits[1], commits[0]], |
| ); |
| |
| // Third trace, most recent commit. |
| dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 19, 2); |
| expect(dotCommits).to.deep.equal([commits[19], commits[18]]); |
| |
| // Third trace, middle-of-the-tile commit. |
| dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 10, 2); |
| expect(dotCommits).to.deep.equal([commits[10], commits[9]]); |
| |
| // Third trace, oldest commit. |
| dotCommits = await clickDotAndCatchShowBlamelistEvent(dotsSkCanvas, 6, 2); |
| expect(dotCommits).to.deep.equal([ |
| commits[6], |
| commits[5], |
| commits[4], |
| commits[3], |
| commits[2], |
| commits[1], |
| commits[0], |
| ]); |
| }); |
| }); |
| |
| // Returns an ASCII-art representation of the canvas based on function |
| // dotToAscii. |
| function canvasToAscii(dotsSkCanvasCtx: CanvasRenderingContext2D): string { |
| const ascii = []; |
| for (let y = 0; y < traces.traces!.length; y++) { |
| const trace = []; |
| for (let x = 0; x < traces.traces![0].data!.length; x++) { |
| trace.push(dotToAscii(dotsSkCanvasCtx, x, y)); |
| } |
| ascii.push(trace.join('')); |
| } |
| return ascii.join('\n'); |
| } |
| |
| // Returns a character representing the dot at (x, y) in dotspace. |
| // - A trace line is represented with '-'. |
| // - A non-highlighted dot is represented with a character in {'a', 'b', ...}, |
| // where 'a' represents the dot color for the most recent commit. |
| // - A highlighted dot is represented with a character in {'A', 'B', ...}. |
| // - A blank position is represented with ' '. |
| function dotToAscii(dotsSkCanvasCtx: CanvasRenderingContext2D, x: number, y: number): string { |
| const canvasX = dotToCanvasX(x); |
| const canvasY = dotToCanvasY(y); |
| |
| // Sample a few pixels (north, east, south, west, center) from the bounding |
| // box for the potential dot at (x, y). We'll use these to determine whether |
| // there's a dot or a trace line at (x, y), what the color of the dot is, |
| // whether or not it's highlighted, etc. |
| const n = pixelAt(dotsSkCanvasCtx, canvasX, canvasY - DOT_RADIUS); |
| const e = pixelAt(dotsSkCanvasCtx, canvasX + DOT_RADIUS, canvasY); |
| const s = pixelAt(dotsSkCanvasCtx, canvasX, canvasY + DOT_RADIUS); |
| const w = pixelAt(dotsSkCanvasCtx, canvasX - DOT_RADIUS, canvasY); |
| const c = pixelAt(dotsSkCanvasCtx, canvasX, canvasY); |
| |
| // Determines whether the sampled pixels match the given expected colors. |
| const exactColorMatch = (en: string, ee: string, es: string, ew: string, ec: string) => { |
| return [n, e, s, w, c].toString() === [en, ee, es, ew, ec].toString(); |
| }; |
| |
| // Is it empty? |
| const white = '#FFFFFF'; |
| if (exactColorMatch(white, white, white, white, white)) { |
| return ' '; |
| } |
| |
| // Is it a trace line? |
| if (exactColorMatch(white, TRACE_LINE_COLOR, white, TRACE_LINE_COLOR, TRACE_LINE_COLOR)) { |
| return '-'; |
| } |
| |
| // Iterate over all possible dot colors. |
| for (let i = 0; i <= MAX_UNIQUE_DIGESTS; i++) { |
| // Is it a dot of the i-th color? Let's look at the pixels in the potential |
| // circumference of the dot. Do they match the current color? |
| // Note: we look for the closest match instead of an exact match due to |
| // canvas anti-aliasing. |
| if (closestColor(n, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i] |
| && closestColor(e, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i] |
| && closestColor(s, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i] |
| && closestColor(w, DOT_STROKE_COLORS) === DOT_STROKE_COLORS[i]) { |
| // Is it a non-highlighted dot? (In other words, is it filled with the |
| // corresponding non-highlighted color?) |
| if (c === DOT_FILL_COLORS[i]) { |
| return 'abcdefghijklmnopqrstuvwxyz'[i]; |
| } |
| |
| // Is it a highlighted dot? (In other words, is it filled with the |
| // corresponding highlighted color?) |
| if (c === DOT_FILL_COLORS_HIGHLIGHTED[i]) { |
| return 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[i]; |
| } |
| } |
| } |
| |
| throw `unrecognized dot at (${x}, ${y})`; |
| } |
| |
| // Returns the color for the pixel at (x, y) in the canvas, represented as a hex |
| // string, e.g. "#AABBCC". |
| function pixelAt(dotsSkCanvasCtx: CanvasRenderingContext2D, x: number, y: number): string { |
| const pixel = dotsSkCanvasCtx.getImageData(x, y, 1, 1).data; |
| const r = pixel[0].toString(16).padStart(2, '0'); |
| const g = pixel[1].toString(16).padStart(2, '0'); |
| const b = pixel[2].toString(16).padStart(2, '0'); |
| return `#${r}${g}${b}`.toUpperCase(); |
| } |
| |
| // Finds the color in the haystack with the minimum Euclidean distance to the |
| // needle. This is necessary for pixels in the circumference of a dot due to |
| // canvas anti-aliasing. All colors are hex strings, e.g. "#AABBCC". |
| function closestColor(needle: string, haystack: string[]): string { |
| return haystack |
| .map((color) => ({ color: color, dist: euclideanDistanceSq(needle, color) })) |
| .reduce((acc, cur) => ((acc.dist < cur.dist) ? acc : cur)) |
| .color; |
| } |
| |
| // Takes two colors represented as hex strings (e.g. "#AABBCC") and computes the |
| // squared Euclidean distance between them. |
| function euclideanDistanceSq(color1: string, color2: string): number { |
| const rgb1 = hexToRgb(color1); |
| const rgb2 = hexToRgb(color2); |
| return (rgb1[0] - rgb2[0]) ** 2 + (rgb1[1] - rgb2[1]) ** 2 + (rgb1[2] - rgb2[2]) ** 2; |
| } |
| |
| // Takes e.g. "#FF8000" and returns [256, 128, 0]. |
| function hexToRgb(hex: string): [number, number, number] { |
| // Borrowed from https://stackoverflow.com/a/5624139. |
| const res = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; |
| return [ |
| parseInt(res[1], 16), |
| parseInt(res[2], 16), |
| parseInt(res[3], 16), |
| ]; |
| } |
| |
| // Simulate hovering over a dot. |
| async function hoverOverDot(dotsSkCanvas: HTMLCanvasElement, x: number, y: number) { |
| dotsSkCanvas.dispatchEvent(new MouseEvent('mousemove', { |
| clientX: dotsSkCanvas.getBoundingClientRect().left + dotToCanvasX(x), |
| clientY: dotsSkCanvas.getBoundingClientRect().top + dotToCanvasY(y), |
| })); |
| |
| // Give mousemove event a chance to be processed. Necessary due to how |
| // mousemove events are processed in batches by dots-sk every 40 ms. |
| await new Promise((resolve) => setTimeout(resolve, 50)); |
| } |
| |
| // Simulate hovering over a dot, and return the trace label in the "hover" event details. |
| async function hoverOverDotAndCatchHoverEvent( |
| dotsSkCanvas: HTMLCanvasElement, x: number, y: number): Promise<string> { |
| // const eventPromise = dotsSkEventPromise(dotsSk, 'hover'); |
| const event = eventPromise<CustomEvent<string>>('hover'); |
| await hoverOverDot(dotsSkCanvas, x, y); |
| return (await event).detail; |
| } |
| |
| // Simulate clicking on a dot. |
| function clickDot(dotsSkCanvas: HTMLCanvasElement, x: number, y: number) { |
| dotsSkCanvas.dispatchEvent(new MouseEvent('click', { |
| clientX: dotsSkCanvas.getBoundingClientRect().left + dotToCanvasX(x), |
| clientY: dotsSkCanvas.getBoundingClientRect().top + dotToCanvasY(y), |
| })); |
| } |
| |
| // Simulate clicking on a dot, and return the list of commits in the "showblamelist" event details. |
| async function clickDotAndCatchShowBlamelistEvent( |
| dotsSkCanvas: HTMLCanvasElement, x: number, y: number): Promise<Commit[]> { |
| const event = eventPromise<CustomEvent<Commit[]>>('showblamelist'); |
| clickDot(dotsSkCanvas, x, y); |
| return (await event).detail; |
| } |