Skip to content

Commit

Permalink
feat(progress-reporter): improve reported progress ux
Browse files Browse the repository at this point in the history
Improve the UX of the progress and progress-append-only reporters. Now showing time elapsed as well as time remaining. Not communicating seconds anymore, since the seconds don't tick each second it was giving a bad user experience.

An example:  
`Mutation testing 33% (elapsed: <1m, remaining: <1m) 1/3 tested (0 survived, 0 timed out)`
  • Loading branch information
Chris Brody authored and nicojs committed Nov 21, 2019
1 parent 29dbd00 commit d7a6f88
Show file tree
Hide file tree
Showing 5 changed files with 51 additions and 41 deletions.
2 changes: 1 addition & 1 deletion packages/core/src/reporters/ProgressAppendOnlyReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default class ProgressAppendOnlyReporter extends ProgressKeeper {

private render() {
process.stdout.write(
`Mutation testing ${this.getPercentDone()} (ETC ${this.getEtc()}) ` +
`Mutation testing ${this.getPercentDone()} (elapsed: ${this.getElapsedTime()}, remaining: ${this.getEtc()}) ` +
`${this.progress.tested}/${this.progress.total} tested (${this.progress.survived} survived, ${this.progress.timedOut} timed out)` +
os.EOL
);
Expand Down
28 changes: 13 additions & 15 deletions packages/core/src/reporters/ProgressKeeper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,32 +32,30 @@ abstract class ProgressKeeper implements Reporter {
}
}

protected getElapsedTime() {
return this.formatTime(this.timer.elapsedSeconds());
}

protected getEtc() {
const totalSecondsLeft = Math.floor((this.timer.elapsedSeconds() / this.progress.tested) * (this.progress.total - this.progress.tested));

if (isFinite(totalSecondsLeft) && totalSecondsLeft > 0) {
const hours = Math.floor(totalSecondsLeft / 3600);
const minutes = Math.floor((totalSecondsLeft / 60) % 60);
const seconds = Math.floor(totalSecondsLeft % 60);

return this.formatEtc(hours, minutes, seconds);
return this.formatTime(totalSecondsLeft);
} else {
return 'n/a';
}
}

private formatEtc(hours: number, minutes: number, seconds: number) {
let output;
private formatTime(timeInSeconds: number) {
const hours = Math.floor(timeInSeconds / 3600);

if (hours > 0) {
output = `${hours}h, ${minutes}m, ${seconds}s`;
} else if (minutes > 0) {
output = `${minutes}m, ${seconds}s`;
} else {
output = `${seconds}s`;
}
const minutes = Math.floor((timeInSeconds % 3600) / 60);

return output;
return hours > 0 // conditional time formatting
? `~${hours}h ${minutes}m`
: minutes > 0
? `~${minutes}m`
: '<1m';
}
}
export default ProgressKeeper;
5 changes: 3 additions & 2 deletions packages/core/src/reporters/ProgressReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ export default class ProgressBarReporter extends ProgressKeeper {

public onAllMutantsMatchedWithTests(matchedMutants: readonly MatchedMutant[]): void {
super.onAllMutantsMatchedWithTests(matchedMutants);
const progressBarContent = 'Mutation testing [:bar] :percent (ETC :etc) :tested/:total tested (:survived survived, :timedOut timed out)';
const progressBarContent =
'Mutation testing [:bar] :percent (elapsed: :et, remaining: :etc) :tested/:total tested (:survived survived, :timedOut timed out)';

this.progressBar = new ProgressBar(progressBarContent, {
complete: '=',
Expand All @@ -23,7 +24,7 @@ export default class ProgressBarReporter extends ProgressKeeper {
const ticksBefore = this.progress.tested;
super.onMutantTested(result);

const progressBarContent = { ...this.progress, etc: this.getEtc() };
const progressBarContent = { ...this.progress, et: this.getElapsedTime(), etc: this.getEtc() };

if (ticksBefore < this.progress.tested) {
this.tick(progressBarContent);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,46 +22,56 @@ describe('ProgressAppendOnlyReporter', () => {
sinon.stub(process.stdout, 'write');
});

describe('onAllMutantsMatchedWithTests() with 2 mutant to test', () => {
describe('onAllMutantsMatchedWithTests() with 3 mutants to test', () => {
beforeEach(() => {
sut.onAllMutantsMatchedWithTests([matchedMutant(1), matchedMutant(1)]);
sut.onAllMutantsMatchedWithTests([matchedMutant(1), matchedMutant(1), matchedMutant(1)]);
});

it('should not show show progress directly', () => {
expect(process.stdout.write).to.not.have.been.called;
});

it('should log zero progress after ten seconds without completed tests', () => {
it('should log correct info after ten seconds without completed tests', () => {
sinon.clock.tick(TEN_SECONDS);
expect(process.stdout.write).to.have.been.calledWith('Mutation testing 0% (ETC n/a) ' + `0/2 tested (0 survived, 0 timed out)${os.EOL}`);
expect(process.stdout.write).to.have.been.calledWith(
`Mutation testing 0% (elapsed: <1m, remaining: n/a) 0/3 tested (0 survived, 0 timed out)${os.EOL}`
);
});

it('should log 50% with 10s ETC after ten seconds with 1 completed test', () => {
it('should log correct info after ten seconds with 1 completed test', () => {
sut.onMutantTested(mutantResult({ status: MutantStatus.Killed }));
expect(process.stdout.write).to.not.have.been.called;
sinon.clock.tick(TEN_SECONDS);
expect(process.stdout.write).to.have.been.calledWith(`Mutation testing 50% (ETC 10s) 1/2 tested (0 survived, 0 timed out)${os.EOL}`);
expect(process.stdout.write).to.have.been.calledWith(
`Mutation testing 33% (elapsed: <1m, remaining: <1m) 1/3 tested (0 survived, 0 timed out)${os.EOL}`
);
});

it('should log 50% with "1m, 40s" ETC after hundred seconds with 1 completed test', () => {
it('should log correct info after a hundred seconds with 1 completed test', () => {
sut.onMutantTested(mutantResult({ status: MutantStatus.Killed }));
expect(process.stdout.write).to.not.have.been.called;
sinon.clock.tick(HUNDRED_SECONDS);
expect(process.stdout.write).to.have.been.calledWith(`Mutation testing 50% (ETC 1m, 40s) 1/2 tested (0 survived, 0 timed out)${os.EOL}`);
expect(process.stdout.write).to.have.been.calledWith(
`Mutation testing 33% (elapsed: ~1m, remaining: ~3m) 1/3 tested (0 survived, 0 timed out)${os.EOL}`
);
});

it('should log 50% with "10m, 40s" ETC after thousand seconds with 1 completed test', () => {
it('should log correct info after a thousand seconds with 1 completed test', () => {
sut.onMutantTested(mutantResult({ status: MutantStatus.Killed }));
expect(process.stdout.write).to.not.have.been.called;
sinon.clock.tick(THOUSAND_SECONDS);
expect(process.stdout.write).to.have.been.calledWith(`Mutation testing 50% (ETC 10m, 40s) 1/2 tested (0 survived, 0 timed out)${os.EOL}`);
expect(process.stdout.write).to.have.been.calledWith(
`Mutation testing 33% (elapsed: ~16m, remaining: ~33m) 1/3 tested (0 survived, 0 timed out)${os.EOL}`
);
});

it('should log 50% with "2h, 46m, 40s" ETC after ten thousand seconds with 1 completed test', () => {
it('should log correct info after ten thousand seconds with 1 completed test', () => {
sut.onMutantTested(mutantResult({ status: MutantStatus.TimedOut }));
expect(process.stdout.write).to.not.have.been.called;
sinon.clock.tick(TEN_THOUSAND_SECONDS);
expect(process.stdout.write).to.have.been.calledWith(`Mutation testing 50% (ETC 2h, 46m, 40s) 1/2 tested (0 survived, 1 timed out)${os.EOL}`);
expect(process.stdout.write).to.have.been.calledWith(
`Mutation testing 33% (elapsed: ~2h 46m, remaining: ~5h 33m) 1/3 tested (0 survived, 1 timed out)${os.EOL}`
);
});
});
});
23 changes: 12 additions & 11 deletions packages/core/test/unit/reporters/ProgressReporter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ describe('ProgressReporter', () => {
let sut: ProgressReporter;
let matchedMutants: MatchedMutant[];
let progressBar: Mock<ProgressBar>;
const progressBarContent = 'Mutation testing [:bar] :percent (ETC :etc) :tested/:total tested (:survived survived, :timedOut timed out)';
const progressBarContent =
'Mutation testing [:bar] :percent (elapsed: :et, remaining: :etc) :tested/:total tested (:survived survived, :timedOut timed out)';

beforeEach(() => {
sinon.useFakeTimers();
Expand Down Expand Up @@ -94,41 +95,41 @@ describe('ProgressReporter', () => {
});
});

describe('ProgressBar estimate time', () => {
describe('ProgressBar estimated time for 3 mutants', () => {
beforeEach(() => {
sut.onAllMutantsMatchedWithTests([matchedMutant(1), matchedMutant(1)]);
sut.onAllMutantsMatchedWithTests([matchedMutant(1), matchedMutant(1), matchedMutant(1)]);
});

it('should show to an estimate of "10s" in the progressBar after ten seconds and 1 mutants tested', () => {
it('should show correct time info after ten seconds and 1 mutants tested', () => {
sinon.clock.tick(TEN_SECONDS);

sut.onMutantTested(mutantResult({ status: MutantStatus.Killed }));

expect(progressBar.tick).to.have.been.calledWithMatch({ etc: '10s' });
expect(progressBar.tick).to.have.been.calledWithMatch({ et: '<1m', etc: '<1m' });
});

it('should show to an estimate of "1m, 40s" in the progressBar after hundred seconds and 1 mutants tested', () => {
it('should show correct time info after a hundred seconds and 1 mutants tested', () => {
sinon.clock.tick(HUNDRED_SECONDS);

sut.onMutantTested(mutantResult({ status: MutantStatus.Killed }));

expect(progressBar.tick).to.have.been.calledWithMatch({ etc: '1m, 40s' });
expect(progressBar.tick).to.have.been.calledWithMatch({ et: '~1m', etc: '~3m' });
});

it('should show to an estimate of "2h, 46m, 40s" in the progressBar after ten thousand seconds and 1 mutants tested', () => {
it('should show correct time info after ten thousand seconds and 1 mutants tested', () => {
sinon.clock.tick(TEN_THOUSAND_SECONDS);

sut.onMutantTested(mutantResult({ status: MutantStatus.Killed }));

expect(progressBar.tick).to.have.been.calledWithMatch({ etc: '2h, 46m, 40s' });
expect(progressBar.tick).to.have.been.calledWithMatch({ et: '~2h 46m', etc: '~5h 33m' });
});

it('should show to an estimate of "1h, 0m, 0s" in the progressBar after an hour and 1 mutants tested', () => {
it('should show correct time info after an hour and 1 mutants tested', () => {
sinon.clock.tick(ONE_HOUR);

sut.onMutantTested(mutantResult({ status: MutantStatus.Killed }));

expect(progressBar.tick).to.have.been.calledWithMatch({ etc: '1h, 0m, 0s' });
expect(progressBar.tick).to.have.been.calledWithMatch({ et: '~1h 0m', etc: '~2h 0m' });
});
});
});

0 comments on commit d7a6f88

Please sign in to comment.