diff --git a/.gitignore b/.gitignore index 2aadd8c..3d82df1 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,4 @@ testem.log .DS_Store Thumbs.db hd-tracker/ -/hd-tracker \ No newline at end of file +/hd-tracker diff --git a/package.json b/package.json index 1397c93..752d9c8 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "build": "nx run cli:build", "test": "nx run cli:test", + "test:watch": "npm run test -- --watch", "start": "nx run cli:build && node dist/packages/cli/src/index.js" }, "private": true, diff --git a/packages/cli/jest.config.ts b/packages/cli/jest.config.ts index 9fdcaa4..f150dfe 100644 --- a/packages/cli/jest.config.ts +++ b/packages/cli/jest.config.ts @@ -7,5 +7,5 @@ export default { '^.+\\.[tj]sx?$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], }, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], - coverageDirectory: '../../coverage/packages/cli', + coverageDirectory: '../../coverage/packages/cli' }; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f41a696..a15a117 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1 +1,2 @@ +#!/usr/bin/env node export * from './lib'; diff --git a/packages/cli/src/lib/argv.ts b/packages/cli/src/lib/argv.ts new file mode 100644 index 0000000..c0e38d4 --- /dev/null +++ b/packages/cli/src/lib/argv.ts @@ -0,0 +1,21 @@ +import yargs = require('yargs'); +import { ChartConfig } from './models/chart-config'; +import { hideBin } from 'yargs/helpers'; + +interface RawArgs { + root?: string; + config?: string; + init?: boolean; + chart?: ChartConfig +} + +export interface Args extends RawArgs { + chart: ChartConfig +} + +const parsedArgv: RawArgs = yargs(hideBin(global.process.argv)).argv as RawArgs; + +// set a default chart config instance from cli options +parsedArgv.chart = new ChartConfig(parsedArgv.chart); + +export const argv = parsedArgv as Args; \ No newline at end of file diff --git a/packages/cli/src/lib/index.ts b/packages/cli/src/lib/index.ts index 34565d7..f6fbd31 100644 --- a/packages/cli/src/lib/index.ts +++ b/packages/cli/src/lib/index.ts @@ -1,18 +1,12 @@ import { resolve } from 'path'; -import * as yargs from 'yargs'; -import { hideBin } from 'yargs/helpers'; import { processConfig } from './process-config'; import { initialize } from './initialize'; -import { getData, getTheRootDirectory, readConfig, saveResults } from './util'; +import { createDataVizIn, getData, getTheRootDirectory, readConfig, saveResults } from './util'; +import { argv } from './argv'; -interface Args { - root?: string; - config?: string; - init?: boolean; -} -export async function run() { - const argv: Args = yargs(hideBin(global.process.argv)).argv as Args; +(async function run() { + const localRootDir = getTheRootDirectory(global.process.cwd()); if (argv.init) { @@ -27,9 +21,10 @@ export async function run() { saveResults(localRootDir, config.outputDir, results); + const allData = getData(localRootDir, config.outputDir); - createDataViz(allData); -} + const parentDir = resolve(localRootDir, config.outputDir); -run(); + await createDataVizIn(parentDir, allData); +}()); diff --git a/packages/cli/src/lib/models/chart-config.spec.ts b/packages/cli/src/lib/models/chart-config.spec.ts new file mode 100644 index 0000000..684998e --- /dev/null +++ b/packages/cli/src/lib/models/chart-config.spec.ts @@ -0,0 +1,46 @@ +import { argv } from '../argv'; +import { ChartConfig } from './chart-config'; + + +describe('ChartConfig', () => { + it('should be default chart configuration on argv', () => { + expect(argv.chart).toStrictEqual(new ChartConfig()); + }); + + const fakeCliOptions = { + perCategoryAndFileType: true, + perCategoryTotals: false, + width: 42, + height: 42, + bg: 'negro', + title: 'Grine-DING', + xAxisLabel: 'tomorrows', + yAxisLabel: 'weather', + overwrite: false, + outFile: 'flibbidy-giblets.png', + } as ChartConfig; + + const defaultInstance = new ChartConfig(); + + // loop over all public keys to set key differently + Object.keys(fakeCliOptions).forEach((keyToTest) => { + + const instantiatedViaCli = new ChartConfig({ [keyToTest]: fakeCliOptions[keyToTest] } as any); + + it(`should override default '${keyToTest}: ${defaultInstance[keyToTest]}' value with '${keyToTest}: ${instantiatedViaCli[keyToTest]}'`, () => { + expect(defaultInstance[keyToTest]).not.toEqual(instantiatedViaCli[keyToTest]); + }); + + // loop over all remaining keys after having set one key different + Object.keys(fakeCliOptions).forEach((otherKey) => { + if (otherKey === keyToTest) { return; } + + // testing differing keys; values should match + it(`should be default '${otherKey}: ${defaultInstance[otherKey]}' value with '${otherKey}: ${instantiatedViaCli[otherKey]}'`, () => { + expect(defaultInstance[otherKey]).toEqual(instantiatedViaCli[otherKey]); + }); + }); + }) + +}); + diff --git a/packages/cli/src/lib/models/chart-config.ts b/packages/cli/src/lib/models/chart-config.ts new file mode 100644 index 0000000..c5ec109 --- /dev/null +++ b/packages/cli/src/lib/models/chart-config.ts @@ -0,0 +1,86 @@ +export class ChartConfig { + private _perCategoryAndFileType = false; + private _perCategoryTotals = true; + private _width = 1200; + private _height = 800; + private _bg = 'white'; + private _title = 'Migration Progress LOC'; + private _xAxisLabel = 'Date'; + private _yAxisLabel = 'Totals'; + private _overwrite = true; + private _outFile = 'viz.png'; + + get perCategoryAndFileType(): boolean { + // if false, use false; false !== undefined + return this._cliOptions.perCategoryAndFileType !== undefined ? + this._cliOptions.perCategoryAndFileType : + this._perCategoryAndFileType; + } + + get perCategoryTotals(): boolean { + // if false, use false; false !== undefined + return this._cliOptions.perCategoryTotals !== undefined ? + this._cliOptions.perCategoryTotals: + this._perCategoryTotals; + } + + get width(): number { + // if set and non-numeric + if (this._cliOptions.width !== undefined && isNaN(Number(this._cliOptions.width))) { + throw Error('--chart.width must be a number'); + } + // if unset or numeric + return this._cliOptions.width !== undefined ? + this._cliOptions.width: + this._width; + } + + get height(): number { + // if set and non-numeric + if (this._cliOptions.height !== undefined && isNaN(Number(this._cliOptions.height))) { + throw Error('--chart.height must be a number'); + } + // if unset or numeric + return this._cliOptions.height !== undefined ? + this._cliOptions.height: + this._height; + } + + get bg(): string { + return this._cliOptions.bg ? + this._cliOptions.bg: + this._bg; + } + + get title(): string { + return this._cliOptions.title ? + this._cliOptions.title: + this._title; + } + + get xAxisLabel(): string { + return this._cliOptions.xAxisLabel ? + this._cliOptions.xAxisLabel: + this._xAxisLabel; + } + + get yAxisLabel(): string { + return this._cliOptions.yAxisLabel ? + this._cliOptions.yAxisLabel: + this._yAxisLabel; + } + + get overwrite(): boolean { + return this._cliOptions.overwrite !== undefined ? + this._cliOptions.overwrite: + this._overwrite; + } + + get outFile(): string { + return this._cliOptions.outFile ? + this._cliOptions.outFile: + this._outFile; + } + + constructor(private _cliOptions: ChartConfig = {} as ChartConfig) { } +} diff --git a/packages/cli/src/lib/models/viz-dataset.ts b/packages/cli/src/lib/models/viz-dataset.ts new file mode 100644 index 0000000..16100d8 --- /dev/null +++ b/packages/cli/src/lib/models/viz-dataset.ts @@ -0,0 +1,4 @@ +export type VizDataset = { + label: string; + data: number[]; +}; diff --git a/packages/cli/src/lib/models/viz-labels-datasets.ts b/packages/cli/src/lib/models/viz-labels-datasets.ts new file mode 100644 index 0000000..b7055da --- /dev/null +++ b/packages/cli/src/lib/models/viz-labels-datasets.ts @@ -0,0 +1,6 @@ +import { VizDataset } from './viz-dataset' + +export type VizLabelsDatasets = { + labels: string[], + datasets: VizDataset[], +} diff --git a/packages/cli/src/lib/tracker-chart.spec.ts b/packages/cli/src/lib/tracker-chart.spec.ts new file mode 100644 index 0000000..886c617 --- /dev/null +++ b/packages/cli/src/lib/tracker-chart.spec.ts @@ -0,0 +1,89 @@ +import { argv } from './argv'; +import { ChartConfig } from './models/chart-config'; +import { TrackerChart } from './tracker-chart'; + + +describe('TrackerChart', () => { + + describe('ctor', () => { + let chart; + beforeEach(() => { + chart = new TrackerChart(argv.chart, [], 'foo') + }); + + it('should have default _config', () => { + expect((chart as any)._config).toStrictEqual(new ChartConfig()); + }); + + it('should have empty _allProcessResults', () => { + expect((chart as any)._allProcessResults).toStrictEqual([]); + }); + + it('should have foo _dateFormat', () => { + expect((chart as any)._dateFormat).toStrictEqual('foo'); + }); + }); + + describe('private methods', () => { + let spyChart: TrackerChart; + let ogGetDataAndLabels; + let getDataAndLabelsSpy: jest.SpyInstance; + let getTotalsPerCategorySpy: jest.SpyInstance; + let getTotalsPerFileTypePerCategorySpy: jest.SpyInstance; + let generateGraphImageFileSpy: jest.SpyInstance; + const noop = () => { undefined }; + beforeEach(() => { + spyChart = new TrackerChart(argv.chart, [1] as any, 'foo'); + ogGetDataAndLabels = (spyChart as any).getDataAndLabels; + getDataAndLabelsSpy = jest.spyOn((spyChart as any), 'getDataAndLabels').mockImplementation(noop); + getTotalsPerCategorySpy = jest.spyOn((spyChart as any), 'getTotalsPerCategory').mockImplementation(noop); + getTotalsPerFileTypePerCategorySpy = jest.spyOn((spyChart as any), 'getTotalsPerFileTypePerCategory').mockImplementation(noop); + generateGraphImageFileSpy = jest.spyOn((spyChart as any), 'generateGraphImageFile').mockImplementation(noop); + + }); + + afterEach(() => { + getDataAndLabelsSpy.mockReset(); + getTotalsPerCategorySpy.mockReset(); + getTotalsPerFileTypePerCategorySpy.mockReset(); + generateGraphImageFileSpy.mockReset(); + }); + + it('calls getDataAndLabels inside writeTo', () => { + spyChart.writeTo(''); + expect(getDataAndLabelsSpy).toHaveBeenCalledWith([1], 'total'); + }); + + it('calls generateGraphImageFileSpy inside writeTo', () => { + spyChart.writeTo(''); + expect(generateGraphImageFileSpy).toHaveBeenCalledWith('', undefined); + }); + + it('calls getTotalsPerCategory inside getDataLabels', () => { + (spyChart as any).getDataAndLabels = ogGetDataAndLabels; + (spyChart as any).getDataAndLabels([1], 'total'); + expect(getTotalsPerCategorySpy).toHaveBeenCalledWith([1], 'total'); + (spyChart as any).getDataAndLabels = getDataAndLabelsSpy; + }); + + it('calls getTotalsPerFileTypePerCategory inside getDataLabels', () => { + (spyChart as any)._config._perCategoryTotals = false; + (spyChart as any)._config._perCategoryAndFileType = true; + (spyChart as any).getDataAndLabels = ogGetDataAndLabels; + (spyChart as any).getDataAndLabels([1], 'total'); + expect(getTotalsPerFileTypePerCategorySpy).toHaveBeenCalledWith([1], 'total'); + (spyChart as any).getDataAndLabels = getDataAndLabelsSpy; + }); + + it('calls getTotalsPerCategory inside getDataLabels by default', () => { + (spyChart as any)._config._perCategoryTotals = undefined; + (spyChart as any)._config._perCategoryAndFileType = undefined; + (spyChart as any).getDataAndLabels = ogGetDataAndLabels; + (spyChart as any).getDataAndLabels([1], 'total'); + expect(getTotalsPerCategorySpy).toHaveBeenCalledWith([1], 'total'); + (spyChart as any).getDataAndLabels = getDataAndLabelsSpy; + }); + + }); + +}); diff --git a/packages/cli/src/lib/tracker-chart.ts b/packages/cli/src/lib/tracker-chart.ts new file mode 100644 index 0000000..6fd40ee --- /dev/null +++ b/packages/cli/src/lib/tracker-chart.ts @@ -0,0 +1,177 @@ +import { join } from 'path'; +import { AggregateResult } from './models/aggregate-result'; +import { ChartConfig } from './models/chart-config'; +import { ProcessResult } from './models/process-result'; +import { VizDataset } from './models/viz-dataset'; +import { VizLabelsDatasets } from './models/viz-labels-datasets'; +import { existsSync, rmSync, writeFileSync } from 'fs'; +import { format, parse } from 'date-fns'; +import { ChartJSNodeCanvas } from 'chartjs-node-canvas'; +import * as ChartDataLabels from 'chartjs-plugin-datalabels'; +import * as autocolors from 'chartjs-plugin-autocolors'; +import { ChartConfiguration } from 'chart.js'; + +export class TrackerChart { + + constructor(private _config: ChartConfig, private _allProcessResults: ProcessResult[], private _dateFormat: string) { } + + private getDataAndLabels(allJsonData, propName: string): VizLabelsDatasets { + if (this._config.perCategoryTotals) { + return this.getTotalsPerCategory(allJsonData, propName); + } + + if (this._config.perCategoryAndFileType) { + return this.getTotalsPerFileTypePerCategory(allJsonData, propName); + } + + return this.getTotalsPerCategory(allJsonData, propName); + } + + private getTotalsPerFileTypePerCategory(allJsonData, propName: string): VizLabelsDatasets { + const runs = { }; + const allTypes = { }; + allJsonData.forEach((jsonData: ProcessResult, i) => { + runs[jsonData.hash] = jsonData.timestamp; + jsonData.categories.forEach((category) => { + category.fileTypes.forEach((t) => { + const label = `${category.name}: ${t.fileType}`; + if (!allTypes[label]) { + allTypes[label] = { + label, + data: [] + } + } + allTypes[label].data[i] = t[propName]; + }); + }); + }); + + const labels = Object.values(runs) as string[]; + const datasets = Object.values(allTypes).map((t: VizDataset) => { + return { + ...t, + fill: false, + tension: .1 + } + }) as VizDataset[]; + + return { + labels, + datasets, + }; + } + + private getTotalsPerCategory(allJsonData, propName: string): VizLabelsDatasets { + const runs = { }; + const allTypes = { }; + allJsonData.forEach((jsonData: ProcessResult, i) => { + runs[jsonData.hash] = jsonData.timestamp; + jsonData.categories.forEach((category) => { + const label = category.name; + if (!allTypes[label]) { + allTypes[label] = { + label, + data: [] + }; + } + + allTypes[label].data[i] = category.totals[propName]; + }); + }); + + const labels = Object.values(runs) as string[]; + const datasets = Object.values(allTypes).map((t: VizDataset) => { + return { + ...t, + fill: false, + tension: .1 + } + }) as VizDataset[]; + + return { + labels, + datasets, + }; + } + + private async generateGraphImageFile(parentDirectory: string, vizData: VizLabelsDatasets): Promise { + const outFile = join(parentDirectory, this._config.outFile); + + if (this._config.overwrite && existsSync(outFile)) { rmSync(outFile); } + + const dateFmt = this._dateFormat; + const configuration = { + type: 'line', + data: vizData, + options: { + elements: { + point:{ + radius: 0 + } + }, + plugins: { + title: { + display: true, + text: this._config.title + }, + autocolors: { + enabled: true, + mode: 'data', + }, + scales: { + x: { + type: 'timeseries', + time: { + minUnit: 'week', + }, + parsing: false, + title: { + display: true, + text: this._config.xAxisLabel + }, + ticks: { + source: 'data', + callback: function(val) { + return format( + parse(this.getLabelForValue(val), dateFmt, new Date()), + 'yyyy-MM-dd' + ); + } + } + }, + y: { + title: { + display: true, + text: this._config.yAxisLabel + } + } + } + } + }, + }; + + try { + const pieChart = new ChartJSNodeCanvas({ + width: this._config.width, + height: this._config.height, + backgroundColour: this._config.bg, + plugins: { + modern: [ChartDataLabels, autocolors], + } + }); + + const result = await pieChart.renderToBuffer(configuration as ChartConfiguration); + + writeFileSync(outFile, result); + } catch (exc) { + console.error(exc); + } + } + + writeTo(parentDirectory: string, graphablePropertyName: keyof AggregateResult = 'total'): Promise { + + const vizData = this.getDataAndLabels(this._allProcessResults, graphablePropertyName); + + return this.generateGraphImageFile(parentDirectory, vizData); + } +} diff --git a/packages/cli/src/lib/util.spec.ts b/packages/cli/src/lib/util.spec.ts index 801db21..7679eaf 100644 --- a/packages/cli/src/lib/util.spec.ts +++ b/packages/cli/src/lib/util.spec.ts @@ -1,5 +1,10 @@ +import { trace } from 'console'; +import { argv } from './argv'; +import { ChartConfig } from './models/chart-config'; import { ProcessResult } from './models/process-result'; +import { TrackerChart } from './tracker-chart'; import { + createDataVizIn, getGitCommit, getTheRootDirectory, readConfig, @@ -17,6 +22,8 @@ jest.mock('fs', () => { return true; case '/x/z/data.json': return true; + case '/x/z/viz.png': + return true; default: return false; } @@ -35,6 +42,7 @@ jest.mock('fs', () => { return JSON.stringify([]); } }), + rmSync: jest.fn(), writeFileSync: jest.fn(), }; }); @@ -127,4 +135,18 @@ describe('util', () => { ); }); }); + + describe('createDataVizIn', () => { + + const writeToSpy = jest.spyOn(TrackerChart.prototype, 'writeTo'); + + beforeEach(() => { + writeToSpy.mockReset(); + }); + + it('should use default outFile', async () => { + await createDataVizIn('', [] as any, 'total'); + expect(writeToSpy).toHaveBeenCalled(); + }) + }) }); diff --git a/packages/cli/src/lib/util.ts b/packages/cli/src/lib/util.ts index 14322ca..a19b4cf 100644 --- a/packages/cli/src/lib/util.ts +++ b/packages/cli/src/lib/util.ts @@ -4,29 +4,63 @@ import { format } from 'date-fns'; import { Commit, getLastCommit } from 'git-last-commit'; import { Config } from './models/config'; import { ProcessResult } from './models/process-result'; +import { AggregateResult } from './models/aggregate-result'; +import { argv } from './argv'; +import { TrackerChart } from './tracker-chart'; -export function getTheRootDirectory(directory: string): string { - if (existsSync(join(directory, 'package.json'))) { - return directory; - } - return getTheRootDirectory(resolve(join(directory, '..'))); -} +const DATE_FORMAT = 'yyyy-MM-dd-HH-mm-ss-SSS'; -export function readConfig( - rootDirectory: string, - optionsPath?: string -): Config { - const path = - optionsPath && existsSync(join(rootDirectory, optionsPath)) - ? join(rootDirectory, optionsPath) - : join(rootDirectory, 'hd-tracker', 'data.json'); - const contents = readFileSync(path).toString('utf-8'); +/** + * + * + * + * internal alphabetized helper functions -> + * + */ - return JSON.parse(contents); +function formatDate(date: Date): string { + return format(date, DATE_FORMAT); } +function getDataFilePath( + localRootDir: string, + outputDir: string, +) { + return resolve(join(localRootDir, outputDir, 'data.json')); +} +function getLastCommitAsPromise(): Promise { + return new Promise((resolve, reject) => { + getLastCommit((err, commit) => { + if (err) { + reject(err); + } + resolve(commit); + }); + }); +} + +function getGitDate(date: string): Date { + return new Date(+date * 1000); +} + +/** + * + * + * + * exported alphabetized util functions -> + * + */ + +export async function createDataVizIn( + parentDirectory: string, + allJsonData: ProcessResult[], + graphablePropertyName: keyof AggregateResult = 'total' +): Promise { + const chart = new TrackerChart(argv.chart, allJsonData, DATE_FORMAT); + return chart.writeTo(parentDirectory, graphablePropertyName); +} export function getData( localRootDir: string, @@ -40,23 +74,6 @@ export function getData( return contents === '' ? [] : JSON.parse(contents); } -export function saveResults( - localRootDir: string, - outputDir: string, - results: ProcessResult -): void { - console.log('Outputting file'); - const output: ProcessResult[] = getData(localRootDir, outputDir); - if (!Array.isArray(output)) { - console.error('Invalid output file format'); - } - output.push(results); - const outputPath = getDataFilePath(localRootDir, outputDir); - const outputText = JSON.stringify(output, null, 2); - writeFileSync(outputPath, outputText); - console.log(`Output written to: ${outputPath}`); -} - export async function getGitCommit(): Promise<{ hash: string; timestamp: string; @@ -68,61 +85,40 @@ export async function getGitCommit(): Promise<{ }; } -export function createDataViz(allJsonData: ProcessResult[]): void { - // const labels = Utils.months({count: 7}); - allJsonData.forEach((jsonData: ProcessResult) => { - - }); - - const datasets = allJsonData.map((jsonData: ProcessResult) => { - - return { - - } - return jsonData.categories.map((category) => { - return { - - - } - return category.fileTypes.map - const label = `${category.name}: ${}` - label: - }) - }) - const data = { - labels: [], - datasets: [{ - label: 'My First Dataset', - data: [65, 59, 80, 81, 56, 55, 40], - fill: false, - tension: 0.1 - }] +export function getTheRootDirectory(directory: string): string { + if (existsSync(join(directory, 'package.json'))) { + return directory; } - return; + return getTheRootDirectory(resolve(join(directory, '..'))); } -function getLastCommitAsPromise(): Promise { - return new Promise((resolve, reject) => { - getLastCommit((err, commit) => { - if (err) { - reject(err); - } - resolve(commit); - }); - }); -} +export function readConfig( + rootDirectory: string, + optionsPath?: string +): Config { + const path = + optionsPath && existsSync(join(rootDirectory, optionsPath)) + ? join(rootDirectory, optionsPath) + : join(rootDirectory, 'hd-tracker', 'data.json'); -function getGitDate(date: string): Date { - return new Date(+date * 1000); -} + const contents = readFileSync(path).toString('utf-8'); -function formatDate(date: Date): string { - return format(date, 'yyyy-MM-dd-HH-mm-ss-SSS'); + return JSON.parse(contents); } -function getDataFilePath( +export function saveResults( localRootDir: string, outputDir: string, -) { - return resolve(join(localRootDir, outputDir, 'data.json')); + results: ProcessResult +): void { + console.log('Outputting file'); + const output: ProcessResult[] = getData(localRootDir, outputDir); + if (!Array.isArray(output)) { + console.error('Invalid output file format'); + } + output.push(results); + const outputPath = getDataFilePath(localRootDir, outputDir); + const outputText = JSON.stringify(output, null, 2); + writeFileSync(outputPath, outputText); + console.log(`Output written to: ${outputPath}`); }