Skip to content

Commit

Permalink
feat(reporter): add "% score of covered" column to the clear text rep…
Browse files Browse the repository at this point in the history
…ort (#4945)

Co-authored-by: prasheelg <[email protected]>
Co-authored-by: Nico Jansen <[email protected]>
Co-authored-by: vivganes <[email protected]>
  • Loading branch information
4 people authored Sep 27, 2024
1 parent 6aa30de commit 3b511ed
Show file tree
Hide file tree
Showing 4 changed files with 220 additions and 111 deletions.
2 changes: 1 addition & 1 deletion e2e/test/reporters-e2e/verify/verify.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,4 @@ describe('Verify stryker has ran correctly', () => {
const createTestsRegex = () => /All tests\s*add\.spec\.js\s*\s*✓ Add should be able to add two numbers \(killed 2\)/;
const createNoCoverageMutantRegex = () => /\[NoCoverage\]/;
const createSurvivedMutantRegex = () => /\[Survived\]/;
const createClearTextTableSummaryRowRegex = () => /All files\s*\|\s*64\.00\s*\|\s*16\s*\|\s*0\s*\|\s*1\s*\|\s*8\s*\|\s*0\s*\|/;
const createClearTextTableSummaryRowRegex = () => /All files\s*\|\s*64\.00\s*\|\s*94\.12\s*\|\s*16\s*\|\s*0\s*\|\s*1\s*\|\s*8\s*\|\s*0\s*\|/;
227 changes: 166 additions & 61 deletions packages/core/src/reporters/clear-text-score-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,138 +6,236 @@ import { MetricsResult } from 'mutation-testing-metrics';

import chalk from 'chalk';

import emojiRegex from 'emoji-regex';

import { stringWidth } from '../utils/string-utils.js';

const FILES_ROOT_NAME = 'All files';

const emojiRe = emojiRegex();

type TableCellValueFactory = (row: MetricsResult, ancestorCount: number) => string;

const repeat = (char: string, nTimes: number) => new Array(nTimes > -1 ? nTimes + 1 : 0).join(char);
const spaces = (n: number) => repeat(' ', n);
const dots = (n: number) => repeat('.', n);

const determineContentWidth = (row: MetricsResult, valueFactory: TableCellValueFactory, ancestorCount = 0): number => {
return Math.max(
valueFactory(row, ancestorCount).length,
...row.childResults.map((child) => determineContentWidth(child, valueFactory, ancestorCount + 1)),
);
};

/**
* Represents a column in the clear text table
* A base class for single columns and grouped columns
*/
class Column {
protected width: number;
private readonly emojiMatchInHeader: RegExpExecArray | null;

abstract class Column {
/**
* @param header The title of the column
* @param netWidth The width of the column (excl 2 spaces padding)
* @param isFirstColumn Whether or not this is the first column in the table. If it is the first column, it should not have a space in front of it.
*/
constructor(
public header: string,
public valueFactory: TableCellValueFactory,
public rows: MetricsResult,
) {
this.emojiMatchInHeader = emojiRe.exec(this.header);
const maxContentSize = this.determineValueSize();
this.width = this.pad(dots(maxContentSize)).length;
}

private determineValueSize(row: MetricsResult = this.rows, ancestorCount = 0): number {
const valueWidths = row.childResults.map((child) => this.determineValueSize(child, ancestorCount + 1));
valueWidths.push(this.headerLength);
valueWidths.push(this.valueFactory(row, ancestorCount).length);
return Math.max(...valueWidths);
}

private get headerLength() {
return stringWidth(this.header);
}
protected readonly header: string,
public netWidth: number,
public readonly isFirstColumn: boolean,
) {}

/**
* Adds padding (spaces) to the front and end of a value
* @param input The string input
*/
protected pad(input: string): string {
return `${spaces(this.width - stringWidth(input) - 2)} ${input} `;
protected pad(input = ''): string {
return `${spaces(this.netWidth - stringWidth(input))}${this.isFirstColumn ? '' : ' '}${input} `;
}

public drawLine(): string {
return repeat('-', this.width);
}

public drawTableCell(score: MetricsResult, ancestorCount: number) {
return this.color(score)(this.pad(this.valueFactory(score, ancestorCount)));
}

public drawHeader() {
return this.pad(this.header);
}

abstract drawTableCell(score: MetricsResult, ancestorCount: number): string;

/**
* The gross width of the column (including padding)
*/
get width() {
return this.netWidth + (this.isFirstColumn ? 1 : 2);
}
}

/**
* Represents a single column in the clear text table (no group)
*/
class SingleColumn extends Column {
constructor(
header: string,
isFirstColumn: boolean,
public valueFactory: TableCellValueFactory,
public rows: MetricsResult,
) {
const maxContentSize = determineContentWidth(rows, valueFactory);
super(header, Math.max(maxContentSize, stringWidth(header)), isFirstColumn);
}

public drawTableCell(score: MetricsResult, ancestorCount: number): string {
return this.color(score)(this.pad(this.valueFactory(score, ancestorCount)));
}

protected color(_score: MetricsResult) {
return (input: string) => input;
}
}

class MutationScoreColumn extends Column {
class MutationScoreColumn extends SingleColumn {
constructor(
rows: MetricsResult,
private readonly thresholds: MutationScoreThresholds,
private readonly scoreType: 'total' | 'covered',
) {
super('% score', (row) => (isNaN(row.metrics.mutationScore) ? 'n/a' : row.metrics.mutationScore.toFixed(2)), rows);
super(
scoreType,
false,
(row) => {
const score = scoreType === 'total' ? row.metrics.mutationScore : row.metrics.mutationScoreBasedOnCoveredCode;
return isNaN(score) ? 'n/a' : score.toFixed(2);
},
rows,
);
}
protected color(metricsResult: MetricsResult) {
const { mutationScore: score } = metricsResult.metrics;

if (isNaN(score)) {
const { mutationScore: score, mutationScoreBasedOnCoveredCode: coveredScore } = metricsResult.metrics;
const scoreToUse = this.scoreType === 'total' ? score : coveredScore;
if (isNaN(scoreToUse)) {
return chalk.grey;
} else if (score >= this.thresholds.high) {
} else if (scoreToUse >= this.thresholds.high) {
return chalk.green;
} else if (score >= this.thresholds.low) {
} else if (scoreToUse >= this.thresholds.low) {
return chalk.yellow;
} else {
return chalk.red;
}
}
}

class FileColumn extends Column {
class FileColumn extends SingleColumn {
constructor(rows: MetricsResult) {
super('File', (row, ancestorCount) => spaces(ancestorCount) + (ancestorCount === 0 ? FILES_ROOT_NAME : row.name), rows);
super('File', true, (row, ancestorCount) => spaces(ancestorCount) + (ancestorCount === 0 ? FILES_ROOT_NAME : row.name), rows);
}
protected override pad(input: string): string {
return `${input} ${spaces(this.width - stringWidth(input) - 1)}`;
// Align left
return `${input}${spaces(this.width - stringWidth(input))}`;
}
}

class GroupColumn extends Column {
columns: SingleColumn[];
constructor(groupName: string, ...columns: SingleColumn[]) {
// Calculate the width of the columns, use the `width`, since the gross width is included in this grouped column. Subtract 2 for the padding.
const { isFirstColumn } = columns[0];
const columnsWidth = columns.reduce((acc, cur) => acc + cur.width, 0) - (isFirstColumn ? 1 : 2);
const groupNameWidth = stringWidth(groupName);
super(groupName, Math.max(groupNameWidth, columnsWidth), isFirstColumn);
this.columns = columns;
if (this.netWidth > columnsWidth + 1) {
// Resize the first column to fill the gap
columns[0].netWidth += this.netWidth - columnsWidth - 1;
}
}

drawColumnHeaders() {
return this.columns.map((column) => column.drawHeader()).join('|');
}

drawColumnLines() {
return this.columns.map((column) => column.drawLine()).join('|');
}

drawTableCell(score: MetricsResult, ancestorCount: number): string {
return this.columns.map((column) => column.drawTableCell(score, ancestorCount)).join('|');
}
}

/**
* Represents a clear text table for mutation score
*/
export class ClearTextScoreTable {
private readonly columns: Column[];
private readonly columns: GroupColumn[];

constructor(
private readonly metricsResult: MetricsResult,
private readonly options: StrykerOptions,
) {
this.columns = [
new FileColumn(metricsResult),
new MutationScoreColumn(metricsResult, options.thresholds),
new Column(`${options.clearTextReporter.allowEmojis ? '✅' : '#'} killed`, (row) => row.metrics.killed.toString(), metricsResult),
new Column(`${options.clearTextReporter.allowEmojis ? '⌛️' : '#'} timeout`, (row) => row.metrics.timeout.toString(), metricsResult),
new Column(`${options.clearTextReporter.allowEmojis ? '👽' : '#'} survived`, (row) => row.metrics.survived.toString(), metricsResult),
new Column(`${options.clearTextReporter.allowEmojis ? '🙈' : '#'} no cov`, (row) => row.metrics.noCoverage.toString(), metricsResult),
new Column(
`${options.clearTextReporter.allowEmojis ? '💥' : '#'} errors`,
(row) => (row.metrics.runtimeErrors + row.metrics.compileErrors).toString(),
metricsResult,
new GroupColumn('', new FileColumn(metricsResult)),
new GroupColumn(
'% Mutation score',
new MutationScoreColumn(metricsResult, options.thresholds, 'total'),
new MutationScoreColumn(metricsResult, options.thresholds, 'covered'),
),
new GroupColumn(
'',
new SingleColumn(
`${options.clearTextReporter.allowEmojis ? '✅' : '#'} killed`,
false,
(row) => row.metrics.killed.toString(),
metricsResult,
),
),
new GroupColumn(
'',
new SingleColumn(
`${options.clearTextReporter.allowEmojis ? '⌛️' : '#'} timeout`,
false,
(row) => row.metrics.timeout.toString(),
metricsResult,
),
),
new GroupColumn(
'',
new SingleColumn(
`${options.clearTextReporter.allowEmojis ? '👽' : '#'} survived`,
false,
(row) => row.metrics.survived.toString(),
metricsResult,
),
),
new GroupColumn(
'',
new SingleColumn(
`${options.clearTextReporter.allowEmojis ? '🙈' : '#'} no cov`,
false,
(row) => row.metrics.noCoverage.toString(),
metricsResult,
),
),
new GroupColumn(
'',
new SingleColumn(
`${options.clearTextReporter.allowEmojis ? '💥' : '#'} errors`,
false,
(row) => (row.metrics.runtimeErrors + row.metrics.compileErrors).toString(),
metricsResult,
),
),
];
}

private drawBorder() {
private drawGroupHeader() {
return this.drawRow((column) => column.drawHeader());
}

private drawGroupLine() {
return this.drawRow((column) => column.drawLine());
}
private drawLine() {
return this.drawRow((column) => column.drawColumnLines());
}

private drawHeader() {
return this.drawRow((c) => c.drawHeader());
private drawColumnHeader() {
return this.drawRow((c) => c.drawColumnHeaders());
}

private drawRow(toDraw: (col: Column) => string) {
private drawRow(toDraw: (col: GroupColumn) => string) {
return this.columns.map(toDraw).join('|') + '|';
}

Expand All @@ -154,6 +252,13 @@ export class ClearTextScoreTable {
* Returns a string with the score results drawn in a table.
*/
public draw(): string {
return [this.drawBorder(), this.drawHeader(), this.drawBorder(), this.drawTableBody().join(os.EOL), this.drawBorder()].join(os.EOL);
return [
this.drawGroupLine(),
this.drawGroupHeader(),
this.drawColumnHeader(),
this.drawLine(),
this.drawTableBody().join(os.EOL),
this.drawLine(),
].join(os.EOL);
}
}
Loading

0 comments on commit 3b511ed

Please sign in to comment.