├── .github
└── workflows
│ └── go.yml
├── .gitignore
├── .golangci.json
├── LICENSE
├── Makefile
├── README.md
├── benchart.go
├── benchart_test.go
├── go.mod
├── go.sum
├── template.html
└── testdata
├── etalon.html
└── input.csv
/.github/workflows/go.yml:
--------------------------------------------------------------------------------
1 | name: Go
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | jobs:
10 |
11 | build:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v3
15 |
16 | - name: Set up Go
17 | uses: actions/setup-go@v3
18 | with:
19 | go-version: 1.18
20 |
21 | - name: Lint
22 | run: make lint
23 |
24 | - name: Test
25 | run: make test
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | dump/
3 | coverage.out
4 | result.html
5 |
--------------------------------------------------------------------------------
/.golangci.json:
--------------------------------------------------------------------------------
1 | {
2 | "linters-settings": {
3 | "govet": {
4 | "check-shadowing": true
5 | },
6 | "nakedret": {
7 | "lines": 1
8 | },
9 | "godox": {
10 | "keywords": [
11 | "NOCOMMIT",
12 | "NOCOMIT"
13 | ]
14 | }
15 | },
16 | "issues": {
17 | "exclude-rules": [
18 | {
19 | "path": "_test.go",
20 | "linters": [
21 | "dupl",
22 | "goconst",
23 | "gomnd",
24 | "structcheck",
25 | "unused",
26 | "gochecknoglobals",
27 | "gosec",
28 | "gocognit"
29 | ]
30 | }
31 | ]
32 | },
33 | "linters": {
34 | "enable": [
35 | "asciicheck",
36 | "bidichk",
37 | "bodyclose",
38 | "bodyclose",
39 | "deadcode",
40 | "decorder",
41 | "depguard",
42 | "dogsled",
43 | "dupl",
44 | "durationcheck",
45 | "errcheck",
46 | "errchkjson",
47 | "errname",
48 | "errorlint",
49 | "execinquery",
50 | "exhaustive",
51 | "exhaustruct",
52 | "exportloopref",
53 | "exportloopref",
54 | "forcetypeassert",
55 | "gci",
56 | "gochecknoglobals",
57 | "gochecknoinits",
58 | "gocognit",
59 | "goconst",
60 | "gocritic",
61 | "gocyclo",
62 | "godot",
63 | "godox",
64 | "goerr113",
65 | "gofmt",
66 | "gofumpt",
67 | "goheader",
68 | "goimports",
69 | "gomnd",
70 | "gomoddirectives",
71 | "gomodguard",
72 | "goprintffuncname",
73 | "gosec",
74 | "gosimple",
75 | "govet",
76 | "grouper",
77 | "ineffassign",
78 | "ireturn",
79 | "lll",
80 | "misspell",
81 | "nakedret",
82 | "nestif",
83 | "nilerr",
84 | "noctx",
85 | "nolintlint",
86 | "nonamedreturns",
87 | "prealloc",
88 | "revive",
89 | "rowserrcheck",
90 | "staticcheck",
91 | "structcheck",
92 | "stylecheck",
93 | "tagliatelle",
94 | "tenv",
95 | "thelper",
96 | "tparallel",
97 | "typecheck",
98 | "unconvert",
99 | "unparam",
100 | "unused",
101 | "varcheck",
102 | "whitespace",
103 | "wsl"
104 | ]
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Bohdan Storozhuk
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | coverage: test
2 | go tool cover -html=coverage.out
3 |
4 | test: clean format
5 | go test -race -coverprofile coverage.out ./...
6 |
7 | lint: clean
8 | go run github.com/golangci/golangci-lint/cmd/golangci-lint@v1.46.2 run .
9 |
10 | clean:
11 | @go clean
12 | @rm -f profile.out
13 | @rm -f coverage.out
14 | @rm -f result.html
15 |
16 | format:
17 | go run mvdan.cc/gofumpt@v0.3.1 -l -w .
18 |
19 | help:
20 | @awk '$$1 ~ /^.*:/ {print substr($$1, 0, length($$1)-1)}' Makefile
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # benchart [](https://github.com/storozhukBM/benchart/actions) [](https://goreportcard.com/report/github.com/storozhukBM/benchart) [](https://pkg.go.dev/github.com/storozhukBM/benchart)
2 |
3 | benchart is a tool that takes [benchstat -csv](https://pkg.go.dev/golang.org/x/perf/cmd/benchstat#section-readme)
4 | output as an input and plots results of your benchmark in a html file.
5 | All you need to do is format your benchmark's name properly.
6 |
7 |
8 |
9 |
10 | All measurement attributes that benchart parses should be split by `;` and every attribute consists of key and value
11 | separated by `:`. Every measurement should have at least 2 attributes: one it `type` that will be used to label your
12 | chart and one additional attribute that will act as `x-axis` by default the last attribute will be interpreted
13 | as `x-axis`.
14 |
15 | ```
16 | name,time/op (ns/op),±
17 | Hash/type:crc32;bytes:8-8,4.47967E+00,0%
18 | |________| |_____|
19 | mandatory x-axis
20 | attribute
21 | ```
22 |
23 | You can also provide additional chart option sets. Every chart option set should have chart name at the beginning and then set
24 | of options separated by `;`. Every option in a set should have key and value separated by `=`.
25 | Supported options:
26 | - `title` - string start will be used as a graph title instead of name from csv file
27 | - `xAxisName` - overrides x-axis name on graph
28 | - `xAxisType` - changes type of x-axis, by default we use linear scale, but you can specify `log` scale
29 | - `yAxisType` - changes type of y-axis, by default we use linear scale, but you can specify `log` scale
30 |
31 | Example of a command with options specified
32 |
33 | > benchart 'Hash;title=Benchmark of hash functions;xAxisName=bytes size;xAxisType=log' input.csv result.html
34 |
35 | Other examples of benchart usage are in the testdata directory.
36 |
37 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/benchart.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "crypto/sha256"
7 | _ "embed"
8 | "encoding/hex"
9 | "encoding/json"
10 | "fmt"
11 | "os"
12 | "strconv"
13 | "strings"
14 | )
15 |
16 | //go:embed "template.html"
17 | var template []byte
18 |
19 | type BenchartError string
20 |
21 | func (b BenchartError) Error() string {
22 | return string(b)
23 | }
24 |
25 | const (
26 | ErrCantCloseOutputFile BenchartError = "can't close output file"
27 | ErrCantFlushOutputFile BenchartError = "can't flush output file"
28 | ErrCantMarshalChartBenchmarkResults BenchartError = "can't marshal chart benchmarkResults"
29 | ErrCantOpenInputFile BenchartError = "can't open input file"
30 | ErrCantOpenOrCreateOutputFile BenchartError = "can't open or create output file"
31 | ErrCantParseBenchmarkName BenchartError = "can't parse benchmark name"
32 | ErrCantParseChartOptions BenchartError = "can't parse chart options"
33 | ErrCantParseErrorRate BenchartError = "can't parse error rate"
34 | ErrCantParseMeasurementAttributes BenchartError = "can't parse measurement attributes"
35 | ErrCantParseOption BenchartError = "can't parse option"
36 | ErrCantParseYValue BenchartError = "can't parse y value"
37 | ErrCantWriteOutputFile BenchartError = "can't write output file"
38 | ErrMeasurementLineHasNoTypeAttribute BenchartError = "measurement line has no 'type' attribute"
39 | ErrNotEnoughColumns BenchartError = "not enough columns"
40 | ErrNotEnoughInputArguments BenchartError = "not enough input arguments"
41 | ErrOptionChartNameNotFound BenchartError = "option chart name not found"
42 | ErrOptionIsNotSupported BenchartError = "option is not supported"
43 | ErrOptionTypeIsWrong BenchartError = "option type is wrong"
44 | )
45 |
46 | type (
47 | ChartName string
48 | ChartOption string
49 | ChartOptionType string
50 | ChartOptionTypeSet map[ChartOptionType]struct{}
51 | )
52 |
53 | type Point struct {
54 | X string
55 | Y string
56 | Error string
57 | }
58 |
59 | type BenchmarkResults struct {
60 | ID string
61 | Name ChartName
62 | YAxisLabel string
63 | Cases map[string][]Point
64 | Options map[ChartOption]string
65 | }
66 |
67 | func main() {
68 | errOutput := RunCommand(os.Args)
69 | if errOutput != nil {
70 | fmt.Println("Command finished with error: " + errOutput.Error())
71 | os.Exit(-1)
72 | }
73 | }
74 |
75 | //nolint:nonamedreturns // here named return required to propagate error from defer
76 | func RunCommand(arguments []string) (errResponse error) {
77 | inputFilePath, outputFilePath, options, errOptionsParsing := parseOptions(arguments)
78 | if errOptionsParsing != nil {
79 | return errOptionsParsing
80 | }
81 |
82 | inputFile, errFileOpen := os.Open(inputFilePath)
83 | if errFileOpen != nil {
84 | return fmt.Errorf("%w: %v: %v", ErrCantOpenInputFile, inputFilePath, errFileOpen)
85 | }
86 |
87 | benchmarkResults, errParsingResults := parseBenchmarkResultsFromInputFile(inputFile, options)
88 | if errParsingResults != nil {
89 | return errParsingResults
90 | }
91 |
92 | f, errOutputFileCreation := os.Create(outputFilePath)
93 | if errOutputFileCreation != nil {
94 | return fmt.Errorf("%w: %v: %v", ErrCantOpenOrCreateOutputFile, outputFilePath, errOutputFileCreation)
95 | }
96 |
97 | defer func(f *os.File) {
98 | errCloseOutputFile := f.Close()
99 | if errCloseOutputFile != nil {
100 | errResponse = fmt.Errorf("%w: %v: %v", ErrCantCloseOutputFile, outputFilePath, errCloseOutputFile)
101 | return
102 | }
103 | }(f)
104 |
105 | benchmarkResultsJSON, errMarshalData := json.MarshalIndent(&benchmarkResults, "", " ")
106 | if errMarshalData != nil {
107 | return fmt.Errorf("%w: %v", ErrCantMarshalChartBenchmarkResults, errMarshalData)
108 | }
109 |
110 | outputWriter := bufio.NewWriter(f)
111 | defer func(outputWriter *bufio.Writer) {
112 | errFileFlush := outputWriter.Flush()
113 | if errFileFlush != nil {
114 | errResponse = fmt.Errorf("%w: %v", ErrCantFlushOutputFile, errFileFlush)
115 | return
116 | }
117 | }(outputWriter)
118 |
119 | _, errWriteFile := outputWriter.Write(bytes.Replace(template, []byte("goCLIInput"), benchmarkResultsJSON, 1))
120 | if errWriteFile != nil {
121 | return fmt.Errorf("%w: %v", ErrCantWriteOutputFile, errWriteFile)
122 | }
123 |
124 | return nil
125 | }
126 |
127 | func parseBenchmarkResultsFromInputFile(
128 | inputFile *os.File, options map[ChartName]map[ChartOption]string,
129 | ) ([]*BenchmarkResults, error) {
130 | inputScanner := bufio.NewScanner(inputFile)
131 | inputScanner.Split(bufio.ScanLines)
132 |
133 | benchmarkToCases := make(map[ChartName]*BenchmarkResults)
134 | benchmarkNamesOrder := make([]ChartName, 0)
135 | firstLine := ""
136 | yAxisLabel := ""
137 |
138 | lineCount := 0
139 | for inputScanner.Scan() {
140 | lineCount++
141 |
142 | line := strings.TrimSpace(inputScanner.Text())
143 | if len(line) == 0 {
144 | continue
145 | }
146 |
147 | if strings.HasPrefix(line, "name") && firstLine != "" {
148 | break // TODO support separate graphs for allocations
149 | }
150 |
151 | const minNumberOfCellsInOneRow = 3
152 |
153 | if firstLine == "" {
154 | firstLine = line
155 |
156 | cells := strings.Split(line, ",")
157 | if len(cells) < minNumberOfCellsInOneRow {
158 | return nil, fmt.Errorf("%w: on line [%v]: `%v`", ErrNotEnoughColumns, lineCount, line)
159 | }
160 |
161 | yAxisLabel = cells[1]
162 |
163 | continue
164 | }
165 |
166 | cells := strings.Split(line, ",")
167 | if len(cells) < minNumberOfCellsInOneRow {
168 | return nil, fmt.Errorf("%w: on line [%v]: `%v`", ErrNotEnoughColumns, lineCount, line)
169 | }
170 |
171 | chartName, caseName, xValueStr, xAxisName, errParsingAttributes := parseAttributes(cells[0], options)
172 | if errParsingAttributes != nil {
173 | return nil, fmt.Errorf("%w: on line [%v]: `%v`", errParsingAttributes, lineCount, line)
174 | }
175 |
176 | yValueStr, errorValueStr := cells[1], cells[2]
177 |
178 | point, errPointParsing := parsePoint(xValueStr, yValueStr, errorValueStr)
179 | if errPointParsing != nil {
180 | return nil, fmt.Errorf("%w: on line [%v]: `%v`", errPointParsing, lineCount, line)
181 | }
182 |
183 | cases, ok := benchmarkToCases[chartName]
184 | if !ok {
185 | benchmarkNamesOrder = append(benchmarkNamesOrder, chartName)
186 | cases = initBenchResult(chartName, xAxisName, yAxisLabel)
187 | }
188 |
189 | cases.Cases[caseName] = append(cases.Cases[caseName], point)
190 | benchmarkToCases[chartName] = cases
191 | }
192 |
193 | errApplyingOption := applyOptionsToResults(options, benchmarkToCases)
194 | if errApplyingOption != nil {
195 | return nil, errApplyingOption
196 | }
197 |
198 | benchmarkResults := make([]*BenchmarkResults, 0, len(benchmarkToCases))
199 | for _, benchmarkName := range benchmarkNamesOrder {
200 | benchmarkResults = append(benchmarkResults, benchmarkToCases[benchmarkName])
201 | }
202 |
203 | return benchmarkResults, nil
204 | }
205 |
206 | func applyOptionsToResults(
207 | options map[ChartName]map[ChartOption]string, benchmarkToCases map[ChartName]*BenchmarkResults,
208 | ) error {
209 | for optionsBenchName, options := range options {
210 | optionApplied := false
211 |
212 | for benchName, cases := range benchmarkToCases {
213 | if strings.HasPrefix(string(benchName), string(optionsBenchName)) {
214 | optionApplied = true
215 |
216 | for optionKey, optionValue := range options {
217 | cases.Options[optionKey] = optionValue
218 | }
219 | }
220 | }
221 |
222 | if !optionApplied {
223 | return fmt.Errorf(
224 | "%w: %v: `%v`, %v",
225 | ErrOptionChartNameNotFound, "you've passed options for chart with name",
226 | optionsBenchName, "but we didn't find such benchmark within input file",
227 | )
228 | }
229 | }
230 |
231 | return nil
232 | }
233 |
234 | func initBenchResult(chartName ChartName, xAxisName string, yAxisLabel string) *BenchmarkResults {
235 | nameHash := sha256.Sum256([]byte(chartName))
236 |
237 | return &BenchmarkResults{
238 | ID: hex.EncodeToString(nameHash[:]),
239 | Name: chartName,
240 | Options: map[ChartOption]string{"xAxisName": xAxisName},
241 | YAxisLabel: yAxisLabel,
242 | Cases: make(map[string][]Point),
243 | }
244 | }
245 |
246 | func parsePoint(xValueStr string, yValueStr string, errorValueStr string) (Point, error) {
247 | const percents float64 = 100
248 |
249 | yValue, errParsingYValue := parseYValue(yValueStr)
250 | if errParsingYValue != nil {
251 | return Point{}, errParsingYValue
252 | }
253 |
254 | errorRate, errParsingErrorRate := parseErrorRate(errorValueStr)
255 | if errParsingErrorRate != nil {
256 | return Point{}, errParsingErrorRate
257 | }
258 |
259 | point := Point{
260 | X: xValueStr, Y: yValueStr, Error: fmt.Sprintf("%v", yValue*(errorRate/percents)),
261 | }
262 |
263 | return point, nil
264 | }
265 |
266 | func parseAttributes(
267 | firstCell string, options map[ChartName]map[ChartOption]string,
268 | ) (ChartName, string, string, string, error) {
269 | benchmarkName, restOfTheCell, ok := strings.Cut(firstCell, "/")
270 | if !ok {
271 | return "", "", "", "", fmt.Errorf("%w: `%v`", ErrCantParseBenchmarkName, firstCell)
272 | }
273 |
274 | measurementAttributesString, _, ok := strings.Cut(restOfTheCell, "-")
275 | if !ok {
276 | return "", "", "", "", fmt.Errorf("%w: `%v`", ErrCantParseMeasurementAttributes, restOfTheCell)
277 | }
278 |
279 | caseName := ""
280 | xValue := ""
281 | xAxisName, xAxisNameDefined := options[ChartName(benchmarkName)]["xAxisName"]
282 | benchmarkAttributes := []string{benchmarkName}
283 | measurementAttributes := strings.Split(measurementAttributesString, ";")
284 |
285 | for i, attribute := range measurementAttributes {
286 | attributeKeyAndValue := strings.Split(attribute, ":")
287 | if xAxisNameDefined && xAxisName == attributeKeyAndValue[0] {
288 | xValue = attributeKeyAndValue[1]
289 | continue
290 | }
291 |
292 | if i == len(measurementAttributes)-1 {
293 | xAxisName, xValue = attributeKeyAndValue[0], attributeKeyAndValue[1]
294 | continue
295 | }
296 |
297 | if attributeKeyAndValue[0] == "type" {
298 | caseName = attributeKeyAndValue[1]
299 | continue
300 | }
301 |
302 | benchmarkAttributes = append(benchmarkAttributes, attributeKeyAndValue[0]+"="+attributeKeyAndValue[1])
303 | }
304 |
305 | if caseName == "" {
306 | return "", "", "", "", fmt.Errorf(
307 | "%w: `%v`", ErrMeasurementLineHasNoTypeAttribute, measurementAttributesString,
308 | )
309 | }
310 |
311 | chartName := ChartName(strings.Join(benchmarkAttributes, " "))
312 |
313 | return chartName, caseName, xValue, xAxisName, nil
314 | }
315 |
316 | func parseYValue(secondCell string) (float64, error) {
317 | bitSize := 64
318 |
319 | yValue, errYValue := strconv.ParseFloat(secondCell, bitSize)
320 | if errYValue != nil {
321 | return 0, fmt.Errorf("%w: `%v`", ErrCantParseYValue, secondCell)
322 | }
323 |
324 | return yValue, nil
325 | }
326 |
327 | func parseErrorRate(thirdCell string) (float64, error) {
328 | bitSize := 64
329 |
330 | if len(thirdCell) < 2 || thirdCell[len(thirdCell)-1] != '%' {
331 | return 0, fmt.Errorf("%w: error rate cell should end with percent symbol `%v`", ErrCantParseErrorRate, thirdCell)
332 | }
333 |
334 | errorRateString := thirdCell[:len(thirdCell)-1]
335 |
336 | errorRate, errParsingErrorRate := strconv.ParseFloat(errorRateString, bitSize)
337 | if errParsingErrorRate != nil {
338 | return 0, fmt.Errorf("%w: `%v`", ErrCantParseErrorRate, thirdCell)
339 | }
340 |
341 | return errorRate, nil
342 | }
343 |
344 | const helpMessage = `
345 | Not enough arguments. specify input file path first and output file path second
346 | example:
347 | > benchart input.csv result.html
348 |
349 | you can also specify some options for charts with name of the chart at the beginning:
350 | > benchart 'PoolOverhead;title=Overhead;xAxisName=Number of tasks;xAxisType=log;yAxisType=log' input.csv result.html
351 |
352 | list of supported chart options: `
353 |
354 | func parseOptions(cliArguments []string) (string, string, map[ChartName]map[ChartOption]string, error) {
355 | supportedChartOptions := map[ChartOption]ChartOptionTypeSet{
356 | "title": {"string": struct{}{}},
357 | "xAxisName": {"string": struct{}{}},
358 | "xAxisType": {"log": struct{}{}},
359 | "yAxisType": {"log": struct{}{}},
360 | }
361 |
362 | minArgumentsCount := 3
363 | if len(cliArguments) < minArgumentsCount {
364 | return "", "", nil, fmt.Errorf("%w: %v%v", ErrNotEnoughInputArguments, helpMessage, supportedChartOptions)
365 | }
366 |
367 | inputFilePath := cliArguments[len(cliArguments)-2]
368 | outputFilePath := cliArguments[len(cliArguments)-1]
369 | options := make(map[ChartName]map[ChartOption]string)
370 |
371 | if len(cliArguments) == minArgumentsCount {
372 | return inputFilePath, outputFilePath, options, nil
373 | }
374 |
375 | optionArguments := cliArguments[1 : len(cliArguments)-2]
376 | for _, optionsString := range optionArguments {
377 | chartName, otherOptions, ok := strings.Cut(optionsString, ";")
378 | if !ok {
379 | return "", "", nil, fmt.Errorf("%w: %+v", ErrCantParseChartOptions, optionsString)
380 | }
381 |
382 | chartOptions, ok := options[ChartName(chartName)]
383 | if !ok {
384 | chartOptions = make(map[ChartOption]string)
385 | }
386 |
387 | for _, optionString := range strings.Split(otherOptions, ";") {
388 | optionSlice := strings.Split(optionString, "=")
389 | optionPartsCount := 2
390 |
391 | if len(optionSlice) != optionPartsCount {
392 | return "", "", nil, fmt.Errorf("%w: %+v", ErrCantParseOption, optionString)
393 | }
394 |
395 | chartOption := ChartOption(optionSlice[0])
396 |
397 | optionTypeSet, ok := supportedChartOptions[chartOption]
398 | if !ok {
399 | return "", "", nil, fmt.Errorf(
400 | "%w: %+v; List of suported options: %v",
401 | ErrOptionIsNotSupported, optionString, supportedChartOptions,
402 | )
403 | }
404 |
405 | chartOptionValue := optionSlice[1]
406 | _, stringAllowed := optionTypeSet["string"]
407 | _, thisOptionAllowed := optionTypeSet[ChartOptionType(chartOptionValue)]
408 |
409 | if stringAllowed || thisOptionAllowed {
410 | chartOptions[chartOption] = chartOptionValue
411 | continue
412 | } else {
413 | return "", "", nil, fmt.Errorf(
414 | "%w: option %v allows only %v",
415 | ErrOptionTypeIsWrong, chartOption, optionTypeSet,
416 | )
417 | }
418 | }
419 |
420 | options[ChartName(chartName)] = chartOptions
421 | }
422 |
423 | return inputFilePath, outputFilePath, options, nil
424 | }
425 |
--------------------------------------------------------------------------------
/benchart_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bufio"
5 | "errors"
6 | "fmt"
7 | "hash/crc64"
8 | "io"
9 | "os"
10 | "testing"
11 | )
12 |
13 | var filesToClean []string
14 |
15 | func TestMain(m *testing.M) {
16 | exitVal := m.Run()
17 |
18 | for _, filePath := range filesToClean {
19 | errFileDeletion := os.Remove(filePath)
20 | if errFileDeletion != nil {
21 | fmt.Printf("Can't delete test file: %v: %v\n", filePath, errFileDeletion)
22 | }
23 | }
24 |
25 | os.Exit(exitVal)
26 | }
27 |
28 | func Test_RunCommand(t *testing.T) {
29 | errCommand := RunCommand([]string{
30 | "benchart",
31 | "Hash;xAxisName=bytes size;title=Benchmark of hash functions",
32 | "PoolOverhead;xAxisType=log;yAxisType=log",
33 | "RateLimiter;xAxisType=log;xAxisName=goroutines",
34 | "testdata/input.csv", "result.html",
35 | })
36 | if errCommand != nil {
37 | t.Fatal(errCommand)
38 | }
39 |
40 | resultChecksum := checksumFromFile(t, "result.html")
41 | expectedChecksum := checksumFromFile(t, "testdata/etalon.html")
42 |
43 | if resultChecksum != expectedChecksum {
44 | t.Fatal("testdata/etalon.html is different from result.html")
45 | }
46 | }
47 |
48 | func checksumFromFile(t *testing.T, filepath string) uint64 {
49 | t.Helper()
50 |
51 | file, errOpenResultFile := os.Open(filepath)
52 | if errOpenResultFile != nil {
53 | t.Fatal(errOpenResultFile)
54 | }
55 |
56 | defer func(resultFile *os.File) {
57 | errClose := resultFile.Close()
58 | if errClose != nil {
59 | t.Fatal(errClose)
60 | }
61 | }(file)
62 |
63 | hashCrc64 := crc64.New(crc64.MakeTable(crc64.ISO))
64 |
65 | _, errCopyResultFile := io.Copy(hashCrc64, file)
66 | if errCopyResultFile != nil {
67 | t.Fatal(errCopyResultFile)
68 | }
69 |
70 | return hashCrc64.Sum64()
71 | }
72 |
73 | func Test_RunCommand_Negative(t *testing.T) {
74 | type args struct {
75 | arguments []string
76 | }
77 |
78 | input := func(input ...string) args {
79 | return args{append([]string{"benchart"}, input...)}
80 | }
81 |
82 | tests := []struct {
83 | name string
84 | args args
85 | expectedError error
86 | }{
87 | {
88 | "ErrCantOpenInputFile",
89 | input("nonExistentInputFile", "someOutput"),
90 | ErrCantOpenInputFile,
91 | },
92 | {
93 | "ErrCantOpenOrCreateOutputFile",
94 | input("testdata/input.csv", "/nonExistentOutputPathFile/nonExistentOutputFile.html"),
95 | ErrCantOpenOrCreateOutputFile,
96 | },
97 | {
98 | "ErrNotEnoughInputArguments",
99 | input("notEnoughInputArguments"),
100 | ErrNotEnoughInputArguments,
101 | },
102 | {
103 | "ErrCantParseChartOptions",
104 | input("", "testdata/input.csv", "etalon.html"),
105 | ErrCantParseChartOptions,
106 | },
107 | {
108 | "ErrCantParseOption",
109 | input(";", "testdata/input.csv", "etalon.html"),
110 | ErrCantParseOption,
111 | },
112 | {
113 | "ErrOptionIsNotSupported",
114 | input(";someOption=", "testdata/input.csv", "etalon.html"),
115 | ErrOptionIsNotSupported,
116 | },
117 | {
118 | "ErrOptionTypeIsWrong",
119 | input(";xAxisType=", "testdata/input.csv", "etalon.html"),
120 | ErrOptionTypeIsWrong,
121 | },
122 | {
123 | "ErrOptionTypeIsWrong",
124 | input(";xAxisType=exp", "testdata/input.csv", "etalon.html"),
125 | ErrOptionTypeIsWrong,
126 | },
127 | {
128 | "ErrNotEnoughColumns",
129 | input(
130 | ";xAxisType=log",
131 | caseFile(t, `
132 | name,time/op (ns/op)±
133 | Hash/type:crc32;bytes:4-8,4.13067E+00,1%
134 | `),
135 | "etalon.html",
136 | ),
137 | ErrNotEnoughColumns,
138 | },
139 | {
140 | "ErrNotEnoughColumns",
141 | input(
142 | ";xAxisType=log",
143 | caseFile(t, `
144 | name,time/op (ns/op),±
145 | Hash/type:crc32;bytes:4-8,4.13067E+00|1%
146 | `),
147 | "etalon.html",
148 | ),
149 | ErrNotEnoughColumns,
150 | },
151 | {
152 | "ErrCantParseBenchmarkName",
153 | input(
154 | ";xAxisType=log",
155 | caseFile(t, `
156 | name,time/op (ns/op),±
157 | Hash\type:crc32;bytes:4-8,4.13067E+00,1%
158 | `),
159 | "etalon.html",
160 | ),
161 | ErrCantParseBenchmarkName,
162 | },
163 | {
164 | "ErrCantParseMeasurementAttributes",
165 | input(
166 | ";xAxisType=log",
167 | caseFile(t, `
168 | name,time/op (ns/op),±
169 | Hash/type:crc32;bytes:4|8,4.13067E+00,1%
170 | `),
171 | "etalon.html",
172 | ),
173 | ErrCantParseMeasurementAttributes,
174 | },
175 | {
176 | "ErrMeasurementLineHasNoTypeAttribute",
177 | input(
178 | ";xAxisType=log",
179 | caseFile(t, `
180 | name,time/op (ns/op),±
181 | Hash/types:crc32;bytes:4-8,4.13067E+00,1%
182 | `),
183 | "etalon.html",
184 | ),
185 | ErrMeasurementLineHasNoTypeAttribute,
186 | },
187 | {
188 | "ErrCantParseYValue",
189 | input(
190 | ";xAxisType=log",
191 | caseFile(t, `
192 | name,time/op (ns/op),±
193 | Hash/type:crc32;bytes:4-8,4.GG13067E+00,1%
194 | `),
195 | "etalon.html",
196 | ),
197 | ErrCantParseYValue,
198 | },
199 | {
200 | "ErrCantParseErrorRate",
201 | input(
202 | ";xAxisType=log",
203 | caseFile(t, `
204 | name,time/op (ns/op),±
205 | Hash/type:crc32;bytes:4-8,4.13067E+00,1
206 | `),
207 | "etalon.html",
208 | ),
209 | ErrCantParseErrorRate,
210 | },
211 | {
212 | "ErrCantParseErrorRate",
213 | input(
214 | ";xAxisType=log",
215 | caseFile(t, `
216 | name,time/op (ns/op),±
217 | Hash/type:crc32;bytes:4-8,4.13067E+00,1GG%
218 | `),
219 | "etalon.html",
220 | ),
221 | ErrCantParseErrorRate,
222 | },
223 | {
224 | "ErrOptionChartNameNotFound",
225 | input(
226 | "HashesBench;xAxisType=log",
227 | caseFile(t, `
228 | name,time/op (ns/op),±
229 | Hash/type:crc32;bytes:4-8,4.13067E+00,1%
230 | `),
231 | "etalon.html",
232 | ),
233 | ErrOptionChartNameNotFound,
234 | },
235 | }
236 |
237 | for _, tt := range tests {
238 | t.Run(tt.name, func(t *testing.T) {
239 | errCommand := RunCommand(tt.args.arguments)
240 | if tt.expectedError != nil && !errors.Is(errCommand, tt.expectedError) {
241 | t.Fatalf("\nExp: `%T(%+v)`\nAct: `%T(%+v)`\n", tt.expectedError, tt.expectedError, errCommand, errCommand)
242 | }
243 | })
244 | }
245 | }
246 |
247 | func caseFile(t *testing.T, body string) string {
248 | t.Helper()
249 |
250 | file, errFileCreation := os.CreateTemp("", "input-*.csv")
251 | if errFileCreation != nil {
252 | t.Fatalf(errFileCreation.Error())
253 | }
254 |
255 | filesToClean = append(filesToClean, file.Name())
256 |
257 | writer := bufio.NewWriter(file)
258 | defer func(writer *bufio.Writer) {
259 | errFlushWrite := writer.Flush()
260 | if errFlushWrite != nil {
261 | t.Fatalf(errFlushWrite.Error())
262 | }
263 | }(writer)
264 |
265 | _, errWriteFile := writer.Write([]byte(body))
266 | if errWriteFile != nil {
267 | t.Fatalf(errWriteFile.Error())
268 | }
269 |
270 | return file.Name()
271 | }
272 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/storozhukBM/benchart
2 |
3 | go 1.18
4 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storozhukBM/benchart/7fe4adfb58af1d2ad3d9244322096c2837aac7f8/go.sum
--------------------------------------------------------------------------------
/template.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |