blob: 8eeaa84c865e691d51108e576036976540161758 [file] [log] [blame]
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as sinon from 'sinon';
import '../../../test/common-test-setup';
import {
GrDiffLineType,
Side,
TokenHighlightEventDetails,
} from '../../../api/diff';
import {GrDiffLine} from '../gr-diff/gr-diff-line';
import {HOVER_DELAY_MS, TokenHighlightLayer} from './token-highlight-layer';
import {GrAnnotationImpl} from '../gr-diff-highlight/gr-annotation';
import {html, render} from 'lit';
import {_testOnly_allTasks} from '../../../utils/async-util';
import {queryAndAssert} from '../../../test/test-utils';
import {assert} from '@open-wc/testing';
function dispatchMouseEvent(type: string, node: Element) {
const props = {
bubbles: true,
cancellable: true,
composed: true,
clientX: 100,
clientY: 100,
buttons: 0,
};
node.dispatchEvent(new MouseEvent(type, props));
}
class MockListener {
private results: any[][] = [];
notify(...args: any[]) {
this.results.push(args);
}
shift() {
return this.results.shift();
}
flush() {
this.results = [];
}
get pending(): number {
return this.results.length;
}
}
suite('token-highlight-layer', () => {
let container: HTMLElement;
let listener: MockListener;
let highlighter: TokenHighlightLayer;
let tokenHighlightingCalls: {details?: TokenHighlightEventDetails}[] = [];
function tokenHighlightListener(
highlightDetails?: TokenHighlightEventDetails
) {
tokenHighlightingCalls.push({details: highlightDetails});
listener.notify({details: highlightDetails});
}
setup(async () => {
listener = new MockListener();
tokenHighlightingCalls = [];
container = document.createElement('div');
document.body.appendChild(container);
highlighter = new TokenHighlightLayer(container, tokenHighlightListener);
});
teardown(() => {
document.body.removeChild(container);
});
function annotate(el: HTMLElement, side: Side = Side.LEFT, line = 1) {
const diffLine = new GrDiffLine(GrDiffLineType.BOTH);
diffLine.afterNumber = line;
diffLine.beforeNumber = line;
highlighter.annotate(el, document.createElement('div'), diffLine, side);
return el;
}
let uniqueId = 0;
function createLineId() {
uniqueId++;
return `line-${uniqueId.toString()}`;
}
function createLine(text: string, line = 1): HTMLElement {
const lineId = createLineId();
const template = html`
<div class="line">
<div data-value=${line} class="lineNum right"></div>
<div class="content">
<div id=${lineId} class="contentText">${text}</div>
</div>
</div>
`;
const div = document.createElement('div');
render(template, div);
container.appendChild(div);
const el = queryAndAssert(container, `#${lineId}`);
return el as HTMLElement;
}
suite('annotate', () => {
function assertAnnotation(
args: any[],
expected: {
parent: HTMLElement;
offset: number;
length: number;
cssClass: string;
}
) {
assert.equal(args[0], expected.parent);
assert.equal(args[1], expected.offset);
assert.equal(args[2], expected.length);
assert.equal(args[3], expected.cssClass);
}
test('annotate adds css token', () => {
const annotateElementStub = sinon.stub(
GrAnnotationImpl,
'annotateElement'
);
const el = createLine('these are words');
annotate(el);
assert.equal(annotateElementStub.callCount, 3);
assertAnnotation(annotateElementStub.args[0], {
parent: el,
offset: 0,
length: 5,
cssClass: 'tk-text-these tk-index-0 token ',
});
assertAnnotation(annotateElementStub.args[1], {
parent: el,
offset: 6,
length: 3,
cssClass: 'tk-text-are tk-index-6 token ',
});
assertAnnotation(annotateElementStub.args[2], {
parent: el,
offset: 10,
length: 5,
cssClass: 'tk-text-words tk-index-10 token ',
});
});
test('annotate adds css tokens w/ emojis', () => {
const annotateElementStub = sinon.stub(
GrAnnotationImpl,
'annotateElement'
);
const el = createLine('these ๐Ÿ’ฉ are ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ words');
annotate(el);
assert.isTrue(annotateElementStub.calledThrice);
assertAnnotation(annotateElementStub.args[0], {
parent: el,
offset: 0,
length: 5,
cssClass: 'tk-text-these tk-index-0 token ',
});
assertAnnotation(annotateElementStub.args[1], {
parent: el,
offset: 8,
length: 3,
cssClass: 'tk-text-are tk-index-8 token ',
});
assertAnnotation(annotateElementStub.args[2], {
parent: el,
offset: 20,
length: 5,
cssClass: 'tk-text-words tk-index-20 token ',
});
});
test('annotate adds mouse handlers', () => {
const el = createLine('these are words');
const addEventListenerStub = sinon.stub(el, 'addEventListener');
annotate(el);
assert.isTrue(addEventListenerStub.calledTwice);
assert.equal(addEventListenerStub.args[0][0], 'mouseover');
assert.equal(addEventListenerStub.args[1][0], 'mouseout');
});
test('annotate does not add mouse handlers without words', () => {
const el = createLine(' ');
const addEventListenerStub = sinon.stub(el, 'addEventListener');
annotate(el);
assert.isFalse(addEventListenerStub.called);
});
test('annotate adds mouse handlers for longest word', () => {
const el = createLine('w'.repeat(100));
const addEventListenerStub = sinon.stub(el, 'addEventListener');
annotate(el);
assert.isTrue(addEventListenerStub.called);
});
test('annotate does not add mouse handlers for long words', () => {
const el = createLine('w'.repeat(101));
const addEventListenerStub = sinon.stub(el, 'addEventListener');
annotate(el);
assert.isFalse(addEventListenerStub.called);
});
});
suite('highlight', () => {
test('highlighting hover delay', async () => {
const clock = sinon.useFakeTimers();
const line1 = createLine('two words');
annotate(line1);
const line2 = createLine('three words');
annotate(line2, Side.RIGHT, 2);
const words1 = queryAndAssert(line1, '.tk-text-words');
assert.isTrue(words1.classList.contains('token'));
dispatchMouseEvent('mouseover', words1);
assert.equal(listener.pending, 0);
assert.equal(_testOnly_allTasks.size, 1);
// Too early for hover behavior to trigger.
clock.tick(100);
assert.equal(listener.pending, 0);
assert.equal(_testOnly_allTasks.size, 1);
// After a total of HOVER_DELAY_MS ms the hover behavior should trigger.
clock.tick(HOVER_DELAY_MS - 100);
assert.equal(listener.pending, 1);
assert.equal(_testOnly_allTasks.size, 0);
});
test('highlighting spans many lines', async () => {
const clock = sinon.useFakeTimers();
const line1 = createLine('two words');
annotate(line1);
const line2 = createLine('three words');
annotate(line2, Side.RIGHT, 1000);
const words1 = queryAndAssert(line1, '.tk-text-words');
assert.isTrue(words1.classList.contains('token'));
dispatchMouseEvent('mouseover', words1);
assert.equal(listener.pending, 0);
// After a total of HOVER_DELAY_MS ms the hover behavior should trigger.
clock.tick(HOVER_DELAY_MS);
assert.equal(listener.pending, 1);
assert.equal(_testOnly_allTasks.size, 0);
assert.deepEqual(tokenHighlightingCalls[0].details, {
token: 'words',
side: Side.RIGHT,
element: words1,
range: {start_line: 1, start_column: 5, end_line: 1, end_column: 9},
});
});
test('highlighting mouse out before delay', async () => {
const clock = sinon.useFakeTimers();
const line1 = createLine('two words');
annotate(line1);
const line2 = createLine('three words', 2);
annotate(line2, Side.RIGHT, 2);
const words1 = queryAndAssert(line1, '.tk-text-words');
assert.isTrue(words1.classList.contains('token'));
dispatchMouseEvent('mouseover', words1);
assert.equal(listener.pending, 0);
clock.tick(100);
// Mouse out after 100ms but before hover delay.
dispatchMouseEvent('mouseout', words1);
assert.equal(listener.pending, 0);
clock.tick(HOVER_DELAY_MS - 100);
assert.equal(listener.pending, 0);
assert.equal(_testOnly_allTasks.size, 0);
});
test('triggers listener for applying and clearing highlighting', async () => {
const clock = sinon.useFakeTimers();
const line1 = createLine('two words');
annotate(line1);
const line2 = createLine('three words', 2);
annotate(line2, Side.RIGHT, 2);
const words1 = queryAndAssert(line1, '.tk-text-words');
assert.isTrue(words1.classList.contains('token'));
dispatchMouseEvent('mouseover', words1);
assert.equal(tokenHighlightingCalls.length, 0);
clock.tick(HOVER_DELAY_MS);
assert.equal(tokenHighlightingCalls.length, 1);
assert.deepEqual(tokenHighlightingCalls[0].details, {
token: 'words',
side: Side.RIGHT,
element: words1,
range: {start_line: 1, start_column: 5, end_line: 1, end_column: 9},
});
assert.isTrue(words1.classList.contains('token-highlight'));
container.click();
assert.equal(tokenHighlightingCalls.length, 2);
assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
assert.isFalse(words1.classList.contains('token-highlight'));
});
test('triggers listener on token with single occurrence', async () => {
const clock = sinon.useFakeTimers();
const line1 = createLine('a tokenWithSingleOccurence');
const line2 = createLine('can be highlighted', 2);
annotate(line1);
annotate(line2, Side.RIGHT, 2);
const tokenNode = queryAndAssert(
line1,
'.tk-text-tokenWithSingleOccurence'
);
assert.isTrue(tokenNode.classList.contains('token'));
dispatchMouseEvent('mouseover', tokenNode);
assert.equal(tokenHighlightingCalls.length, 0);
clock.tick(HOVER_DELAY_MS);
assert.equal(tokenHighlightingCalls.length, 1);
assert.deepEqual(tokenHighlightingCalls[0].details, {
token: 'tokenWithSingleOccurence',
side: Side.RIGHT,
element: tokenNode,
range: {start_line: 1, start_column: 3, end_line: 1, end_column: 26},
});
container.click();
assert.equal(tokenHighlightingCalls.length, 2);
assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
});
test('clicking clears highlight', async () => {
const clock = sinon.useFakeTimers();
const line1 = createLine('two words');
annotate(line1);
const line2 = createLine('three words', 2);
annotate(line2, Side.RIGHT, 2);
const words1 = queryAndAssert(line1, '.tk-text-words');
assert.isTrue(words1.classList.contains('token'));
dispatchMouseEvent('mouseover', words1);
assert.equal(listener.pending, 0);
clock.tick(HOVER_DELAY_MS);
assert.equal(listener.pending, 1);
listener.flush();
assert.equal(listener.pending, 0);
assert.isTrue(words1.classList.contains('token-highlight'));
container.click();
assert.equal(listener.pending, 1);
assert.isFalse(words1.classList.contains('token-highlight'));
});
test('clicking on word does not clear highlight', async () => {
const clock = sinon.useFakeTimers();
const line1 = createLine('two words');
annotate(line1);
const line2 = createLine('three words', 2);
annotate(line2, Side.RIGHT, 2);
const words1 = queryAndAssert<HTMLDivElement>(line1, '.tk-text-words');
assert.isTrue(words1.classList.contains('token'));
dispatchMouseEvent('mouseover', words1);
assert.equal(listener.pending, 0);
clock.tick(HOVER_DELAY_MS);
assert.equal(listener.pending, 1);
listener.flush();
assert.equal(listener.pending, 0);
assert.isTrue(words1.classList.contains('token-highlight'));
words1.click();
assert.equal(listener.pending, 0);
assert.isTrue(words1.classList.contains('token-highlight'));
});
test('query based highlighting', async () => {
highlighter = new TokenHighlightLayer(
container,
tokenHighlightListener,
/* getTokenQueryContainer=*/ () => container
);
const clock = sinon.useFakeTimers();
const line1 = createLine('two words');
annotate(line1);
const line2 = createLine('three words', 2);
annotate(line2, Side.RIGHT, 2);
// Invalidate pointers.
for (const child of line1.childNodes) {
line1.replaceChild(child.cloneNode(), child);
}
for (const child of line2.childNodes) {
line2.replaceChild(child.cloneNode(), child);
}
const words1 = queryAndAssert(line1, '.tk-text-words');
assert.isTrue(words1.classList.contains('token'));
dispatchMouseEvent('mouseover', words1);
assert.equal(tokenHighlightingCalls.length, 0);
clock.tick(HOVER_DELAY_MS);
assert.equal(tokenHighlightingCalls.length, 1);
assert.deepEqual(tokenHighlightingCalls[0].details, {
token: 'words',
side: Side.RIGHT,
element: words1,
range: {start_line: 1, start_column: 5, end_line: 1, end_column: 9},
});
assert.isTrue(words1.classList.contains('token-highlight'));
container.click();
assert.equal(tokenHighlightingCalls.length, 2);
assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
assert.isFalse(words1.classList.contains('token-highlight'));
});
});
});