diff --git a/src/util/taskReporter.js b/src/util/taskReporter.js new file mode 100644 index 0000000..ebeaa04 --- /dev/null +++ b/src/util/taskReporter.js @@ -0,0 +1,87 @@ +// @flow + +const chalk = require("chalk"); + +type TaskId = string; + +type MsSinceEpoch = number; +type ConsoleLog = (string) => void; +type GetTime = () => MsSinceEpoch; + +/** + * This class is a lightweight utility for reporting task progress to the + * command line. + * + * - When a task is started, it's printed to the CLI with a " GO " label. + * - When it's finished, it's printed with a "DONE" label, along with the time + * elapsed. + * - Tasks are tracked and represented by string id. + * - The same task id may be re-used after the first task with that id is + * finished. + */ +export class TaskReporter { + // Maps the task to the time + activeTasks: Map; + _consoleLog: ConsoleLog; + _getTime: GetTime; + + constructor(consoleLog?: ConsoleLog, getTime?: GetTime) { + this._consoleLog = consoleLog || console.log; + this._getTime = + getTime || + function() { + return +new Date(); + }; + this.activeTasks = new Map(); + } + + start(taskId: TaskId) { + if (this.activeTasks.has(taskId)) { + throw new Error(`task ${taskId} already registered`); + } + this.activeTasks.set(taskId, this._getTime()); + this._consoleLog(startMessage(taskId)); + return this; + } + + finish(taskId: TaskId) { + const startTime = this.activeTasks.get(taskId); + if (startTime == null) { + throw new Error(`task ${taskId} not registered`); + } + const elapsedTime = this._getTime() - startTime; + this._consoleLog(finishMessage(taskId, elapsedTime)); + this.activeTasks.delete(taskId); + return this; + } +} + +export function formatTimeElapsed(elapsed: MsSinceEpoch): string { + if (elapsed < 0) { + throw new Error("nonegative time expected"); + } + if (elapsed < 1000) { + return `${elapsed}ms`; + } + const seconds = Math.round(elapsed / 1000); + const minutes = Math.floor(seconds / 60); + if (minutes == 0) return `${seconds}s`; + const hours = Math.floor(minutes / 60); + if (hours == 0) return `${minutes}m ${seconds % 60}s`; + const days = Math.floor(hours / 24); + if (days == 0) return `${hours}h ${minutes % 60}m`; + return `${days}d ${hours % 24}h`; +} + +export function startMessage(taskId: string): string { + const label = chalk.bgBlue.bold.white(" GO "); + const message = `${label} ${taskId}`; + return message; +} + +export function finishMessage(taskId: string, elapsedTimeMs: number): string { + const elapsed = formatTimeElapsed(elapsedTimeMs); + const label = chalk.bgGreen.bold.white(" DONE "); + const message = `${label} ${taskId}: ${elapsed}`; + return message; +} diff --git a/src/util/taskReporter.test.js b/src/util/taskReporter.test.js new file mode 100644 index 0000000..4893742 --- /dev/null +++ b/src/util/taskReporter.test.js @@ -0,0 +1,114 @@ +// @flow + +import { + TaskReporter, + formatTimeElapsed, + startMessage, + finishMessage, +} from "./taskReporter"; + +describe("util/taskReporter", () => { + describe("formatTimeElapsed", () => { + function tc(expected, ms) { + it(`works for ${expected}`, () => { + expect(formatTimeElapsed(ms)).toEqual(expected); + }); + } + tc("0ms", 0); + tc("50ms", 50); + tc("999ms", 999); + const secs = 1000; + tc("1s", 1 * secs + 400); + tc("2s", 1 * secs + 600); + tc("59s", 59 * secs); + const mins = secs * 60; + tc("1m 3s", mins + 3 * secs); + tc("59m 59s", 59 * mins + 59 * secs); + const hours = mins * 60; + tc("1h 0m", hours); + tc("1h 3m", hours + mins * 3); + tc("23h 59m", 23 * hours + 59 * mins); + const days = 24 * hours; + tc("1d 0h", days); + tc("555d 23h", 555 * days + 23 * hours); + }); + + describe("TaskReporter", () => { + class TestCase { + _time: number; + messages: string[]; + taskReporter: TaskReporter; + + constructor() { + this._time = 0; + this.messages = []; + const logMock = (x) => { + this.messages.push(x); + }; + const timeMock = () => this._time; + this.taskReporter = new TaskReporter(logMock, timeMock); + } + start(task: string) { + this.taskReporter.start(task); + return this; + } + finish(task: string) { + this.taskReporter.finish(task); + return this; + } + time(t: number) { + this._time = t; + return this; + } + } + + it("errors when finishing an unregistered task", () => { + const fail = () => new TestCase().finish("foo"); + expect(fail).toThrowError("task foo not registered"); + }); + it("errors when starting a task twice", () => { + const fail = () => new TestCase().start("foo").start("foo"); + expect(fail).toThrowError("task foo already registered"); + }); + it("errors when finishing a task twice", () => { + const fail = () => + new TestCase() + .start("foo") + .finish("foo") + .finish("foo"); + expect(fail).toThrowError("task foo not registered"); + }); + + it("works for a task that immediately finishes", () => { + const {messages} = new TestCase().start("foo").finish("foo"); + expect(messages).toEqual([startMessage("foo"), finishMessage("foo", 0)]); + }); + + it("works when two tasks are started, then one finishes", () => { + const {messages} = new TestCase() + .start("foo") + .start("bar") + .time(200) + .finish("foo"); + expect(messages).toEqual([ + startMessage("foo"), + startMessage("bar"), + finishMessage("foo", 200), + ]); + }); + it("works when a task is started, finished, and re-started", () => { + const {messages} = new TestCase() + .start("foo") + .finish("foo") + .start("foo") + .time(200) + .finish("foo"); + expect(messages).toEqual([ + startMessage("foo"), + finishMessage("foo", 0), + startMessage("foo"), + finishMessage("foo", 200), + ]); + }); + }); +});