| /** | |
| * @license | |
| * Copyright 2019 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import {Side} from '../../../api/diff'; | |
| import { | |
| CoverageRange, | |
| CoverageType, | |
| DiffLayer, | |
| DiffLayerListener, | |
| } from '../../../types/types'; | |
| const TOOLTIP_MAP = new Map([ | |
| [CoverageType.COVERED, 'Covered by tests.'], | |
| [CoverageType.NOT_COVERED, 'Not covered by tests.'], | |
| [CoverageType.PARTIALLY_COVERED, 'Partially covered by tests.'], | |
| [CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'], | |
| ]); | |
| // Ranges are considered half-open: [start, end) | |
| export type Range = {start: number; end: number}; | |
| export function mergeRanges(ranges: Range[]): Range[] { | |
| ranges.sort((a, b) => a.start - b.start); | |
| if (ranges.length <= 1) { | |
| return ranges; | |
| } | |
| const stack: Range[] = []; | |
| stack.push(ranges[0]); | |
| for (let j = 1; j < ranges.length; j++) { | |
| const interval = ranges[j]; | |
| const top = stack[stack.length - 1]; | |
| if (top.end < interval.start) { | |
| stack.push(interval); | |
| } else if (top.end < interval.end) { | |
| top.end = interval.end; | |
| } | |
| } | |
| return stack; | |
| } | |
| export class GrCoverageLayer implements DiffLayer { | |
| /** | |
| * Must be sorted by code_range.start_line. | |
| * Must only contain ranges that match the side. | |
| */ | |
| private coverageRanges: CoverageRange[] = []; | |
| /** | |
| * We keep track of the line number from the previous annotate() call, | |
| * and also of the index of the coverage range that had matched. | |
| * annotate() calls are coming in with increasing line numbers and | |
| * coverage ranges are sorted by line number. So this is a very simple | |
| * and efficient way for finding the coverage range that matches a given | |
| * line number. | |
| */ | |
| private lastLineNumber = 0; | |
| /** | |
| * See `lastLineNumber` comment. | |
| */ | |
| private index = 0; | |
| /** | |
| * Has any line been annotated already in the lifetime of this layer? | |
| * If not, then `setRanges()` does not have to call `notify()` and thus | |
| * trigger re-rendering of the affected diff rows. | |
| */ | |
| // visible for testing | |
| annotated = false; | |
| private listeners: DiffLayerListener[] = []; | |
| constructor(private readonly side: Side) {} | |
| addListener(listener: DiffLayerListener) { | |
| this.listeners.push(listener); | |
| } | |
| removeListener(listener: DiffLayerListener) { | |
| this.listeners = this.listeners.filter(f => f !== listener); | |
| } | |
| /** | |
| * Must be sorted by code_range.start_line. | |
| * Must only contain ranges that match the side. | |
| */ | |
| setRanges(ranges: CoverageRange[]) { | |
| const oldRanges = this.coverageRanges; | |
| if (oldRanges.length === 0 && ranges.length === 0) return; | |
| this.coverageRanges = ranges; | |
| // If ranges are set before any diff row was rendered, then great, no need | |
| // to notify and re-render. | |
| if (this.annotated) this.notify([...oldRanges, ...ranges]); | |
| } | |
| /** | |
| * Notify listeners (should be just gr-diff triggering a re-render). | |
| * | |
| * We are optimizing the notification calls by converting the coverange ranges | |
| * to an array of [start, end) ranges and then merging them to non-overlapping | |
| * set of ranges. | |
| */ | |
| private notify(ranges: CoverageRange[]) { | |
| const notifyRanges = mergeRanges( | |
| ranges.map(r => { | |
| return {start: r.code_range.start_line, end: r.code_range.end_line + 1}; | |
| }) | |
| ); | |
| for (const r of notifyRanges) { | |
| for (const l of this.listeners) l(r.start, r.end - 1, this.side); | |
| } | |
| } | |
| /** | |
| * Layer method to add annotations to a line. | |
| * | |
| * @param _el Not used for this layer. (unused parameter) | |
| * @param lineNumberEl The <td> element with the line number. | |
| * @param line Not used for this layer. | |
| */ | |
| annotate(_el: HTMLElement, lineNumberEl: HTMLElement) { | |
| if ( | |
| !this.side || | |
| !lineNumberEl || | |
| !lineNumberEl.classList.contains(this.side) | |
| ) { | |
| return; | |
| } | |
| let elementLineNumber; | |
| const dataValue = lineNumberEl.getAttribute('data-value'); | |
| if (dataValue) { | |
| elementLineNumber = Number(dataValue); | |
| } | |
| if (!elementLineNumber || elementLineNumber < 1) return; | |
| // If the line number is smaller than before, then we have to reset our | |
| // algorithm and start searching the coverage ranges from the beginning. | |
| // That happens for example when you expand diff sections. | |
| if (elementLineNumber < this.lastLineNumber) { | |
| this.index = 0; | |
| } | |
| this.lastLineNumber = elementLineNumber; | |
| this.annotated = true; | |
| // We simply loop through all the coverage ranges until we find one that | |
| // matches the line number. | |
| while (this.index < this.coverageRanges.length) { | |
| const coverageRange = this.coverageRanges[this.index]; | |
| // If the line number has moved past the current coverage range, then | |
| // try the next coverage range. | |
| if (this.lastLineNumber > coverageRange.code_range.end_line) { | |
| this.index++; | |
| continue; | |
| } | |
| // If the line number has not reached the next coverage range (and the | |
| // range before also did not match), then this line has not been | |
| // instrumented. Nothing to do for this line. | |
| if (this.lastLineNumber < coverageRange.code_range.start_line) { | |
| return; | |
| } | |
| // The line number is within the current coverage range. Style it! | |
| lineNumberEl.classList.add(coverageRange.type); | |
| lineNumberEl.title = TOOLTIP_MAP.get(coverageRange.type) || ''; | |
| return; | |
| } | |
| } | |
| } |