Codebase list golang-gomega / 8f2dfbf
gmeasure provides BETA support for benchmarking (#447) gmeasure is a new gomega subpackage intended to provide measurement and benchmarking support for durations and values. gmeasure replaces Ginkgo V1s deprecated Measure nodes and provides a migration path for users migrating to Ginkgo V2. gmeasure is organized around an Experiment metaphor. Experiments can record several different Measurements, with each Measurement comprised of multiple data points. Measurements can hold time.Durations and float64 values and gmeasure includes support measuring the duraiton of callback functions and for sampling functions repeatedly to build an ensemble of data points. In addition, gmeasure introduces a Stopwatch abtraction for easily measuring and recording durations of code segments. Once measured, users can readily generate Stats for Measurements to capture their key statistics and these stats can be ranked using a Ranking and associated RankingCriteria. Experiments can be Cached to disk to speed up subsequent runs. Experiments are cached by name and version number which makes it easy to manage and bust the cache. Finally, gmeasure integrates with Ginkgo V2 via the new ReportEntry abstraction. Experiments, Measurements, and Rankings can all be registered via AddReportEntry. Doing so generates colorful reports as part of Ginkgo's test output. gmeasure is currently in beta and will go GA around when Ginkgo V2 goes GA. Onsi Fakhouri authored 2 years ago GitHub committed 2 years ago
15 changed file(s) with 2869 addition(s) and 0 deletion(s). Raw diff Collapse all Expand all
0 package gmeasure
1
2 import (
3 "crypto/md5"
4 "encoding/json"
5 "fmt"
6 "io/ioutil"
7 "os"
8 "path/filepath"
9 )
10
11 const CACHE_EXT = ".gmeasure-cache"
12
13 /*
14 ExperimentCache provides a director-and-file based cache of experiments
15 */
16 type ExperimentCache struct {
17 Path string
18 }
19
20 /*
21 NewExperimentCache creates and initializes a new cache. Path must point to a directory (if path does not exist, NewExperimentCache will create a directory at path).
22
23 Cached Experiments are stored as separate files in the cache directory - the filename is a hash of the Experiment name. Each file contains two JSON-encoded objects - a CachedExperimentHeader that includes the experiment's name and cache version number, and then the Experiment itself.
24 */
25 func NewExperimentCache(path string) (ExperimentCache, error) {
26 stat, err := os.Stat(path)
27 if os.IsNotExist(err) {
28 err := os.MkdirAll(path, 0777)
29 if err != nil {
30 return ExperimentCache{}, err
31 }
32 } else if !stat.IsDir() {
33 return ExperimentCache{}, fmt.Errorf("%s is not a directory", path)
34 }
35
36 return ExperimentCache{
37 Path: path,
38 }, nil
39 }
40
41 /*
42 CachedExperimentHeader captures the name of the Cached Experiment and its Version
43 */
44 type CachedExperimentHeader struct {
45 Name string
46 Version int
47 }
48
49 func (cache ExperimentCache) hashOf(name string) string {
50 return fmt.Sprintf("%x", md5.Sum([]byte(name)))
51 }
52
53 func (cache ExperimentCache) readHeader(filename string) (CachedExperimentHeader, error) {
54 out := CachedExperimentHeader{}
55 f, err := os.Open(filepath.Join(cache.Path, filename))
56 if err != nil {
57 return out, err
58 }
59 defer f.Close()
60 err = json.NewDecoder(f).Decode(&out)
61 return out, err
62 }
63
64 /*
65 List returns a list of all Cached Experiments found in the cache.
66 */
67 func (cache ExperimentCache) List() ([]CachedExperimentHeader, error) {
68 out := []CachedExperimentHeader{}
69 infos, err := ioutil.ReadDir(cache.Path)
70 if err != nil {
71 return out, err
72 }
73 for _, info := range infos {
74 if filepath.Ext(info.Name()) != CACHE_EXT {
75 continue
76 }
77 header, err := cache.readHeader(info.Name())
78 if err != nil {
79 return out, err
80 }
81 out = append(out, header)
82 }
83 return out, nil
84 }
85
86 /*
87 Clear empties out the cache - this will delete any and all detected cache files in the cache directory. Use with caution!
88 */
89 func (cache ExperimentCache) Clear() error {
90 infos, err := ioutil.ReadDir(cache.Path)
91 if err != nil {
92 return err
93 }
94 for _, info := range infos {
95 if filepath.Ext(info.Name()) != CACHE_EXT {
96 continue
97 }
98 err := os.Remove(filepath.Join(cache.Path, info.Name()))
99 if err != nil {
100 return err
101 }
102 }
103 return nil
104 }
105
106 /*
107 Load fetches an experiment from the cache. Lookup occurs by name. Load requires that the version numer in the cache is equal to or greater than the passed-in version.
108
109 If an experiment with corresponding name and version >= the passed-in version is found, it is unmarshaled and returned.
110
111 If no experiment is found, or the cached version is smaller than the passed-in version, Load will return nil.
112
113 When paired with Ginkgo you can cache experiments and prevent potentially expensive recomputation with this pattern:
114
115 const EXPERIMENT_VERSION = 1 //bump this to bust the cache and recompute _all_ experiments
116
117 Describe("some experiments", func() {
118 var cache gmeasure.ExperimentCache
119 var experiment *gmeasure.Experiment
120
121 BeforeEach(func() {
122 cache = gmeasure.NewExperimentCache("./gmeasure-cache")
123 name := CurrentSpecReport().LeafNodeText
124 experiment = cache.Load(name, EXPERIMENT_VERSION)
125 if experiment != nil {
126 AddReportEntry(experiment)
127 Skip("cached")
128 }
129 experiment = gmeasure.NewExperiment(name)
130 AddReportEntry(experiment)
131 })
132
133 It("foo runtime", func() {
134 experiment.SampleDuration("runtime", func() {
135 //do stuff
136 }, gmeasure.SamplingConfig{N:100})
137 })
138
139 It("bar runtime", func() {
140 experiment.SampleDuration("runtime", func() {
141 //do stuff
142 }, gmeasure.SamplingConfig{N:100})
143 })
144
145 AfterEach(func() {
146 if !CurrentSpecReport().State.Is(types.SpecStateSkipped) {
147 cache.Save(experiment.Name, EXPERIMENT_VERSION, experiment)
148 }
149 })
150 })
151 */
152 func (cache ExperimentCache) Load(name string, version int) *Experiment {
153 path := filepath.Join(cache.Path, cache.hashOf(name)+CACHE_EXT)
154 f, err := os.Open(path)
155 if err != nil {
156 return nil
157 }
158 defer f.Close()
159 dec := json.NewDecoder(f)
160 header := CachedExperimentHeader{}
161 dec.Decode(&header)
162 if header.Version < version {
163 return nil
164 }
165 out := NewExperiment("")
166 err = dec.Decode(out)
167 if err != nil {
168 return nil
169 }
170 return out
171 }
172
173 /*
174 Save stores the passed-in experiment to the cache with the passed-in name and version.
175 */
176 func (cache ExperimentCache) Save(name string, version int, experiment *Experiment) error {
177 path := filepath.Join(cache.Path, cache.hashOf(name)+CACHE_EXT)
178 f, err := os.Create(path)
179 if err != nil {
180 return err
181 }
182 defer f.Close()
183 enc := json.NewEncoder(f)
184 err = enc.Encode(CachedExperimentHeader{
185 Name: name,
186 Version: version,
187 })
188 if err != nil {
189 return err
190 }
191 return enc.Encode(experiment)
192 }
193
194 /*
195 Delete removes the experiment with the passed-in name from the cache
196 */
197 func (cache ExperimentCache) Delete(name string) error {
198 path := filepath.Join(cache.Path, cache.hashOf(name)+CACHE_EXT)
199 return os.Remove(path)
200 }
0 package gmeasure_test
1
2 import (
3 "fmt"
4 "os"
5
6 . "github.com/onsi/ginkgo"
7 . "github.com/onsi/gomega"
8 "github.com/onsi/gomega/gmeasure"
9 )
10
11 var _ = Describe("Cache", func() {
12 var path string
13 var cache gmeasure.ExperimentCache
14 var e1, e2 *gmeasure.Experiment
15
16 BeforeEach(func() {
17 var err error
18 path = fmt.Sprintf("./cache-%d", GinkgoParallelNode())
19 cache, err = gmeasure.NewExperimentCache(path)
20 Ω(err).ShouldNot(HaveOccurred())
21 e1 = gmeasure.NewExperiment("Experiment-1")
22 e1.RecordValue("foo", 32)
23 e2 = gmeasure.NewExperiment("Experiment-2")
24 e2.RecordValue("bar", 64)
25 })
26
27 AfterEach(func() {
28 Ω(os.RemoveAll(path)).Should(Succeed())
29 })
30
31 Describe("when creating a cache that points to a file", func() {
32 It("errors", func() {
33 f, err := os.Create("cache-temp-file")
34 Ω(err).ShouldNot(HaveOccurred())
35 f.Close()
36 cache, err := gmeasure.NewExperimentCache("cache-temp-file")
37 Ω(err).Should(MatchError("cache-temp-file is not a directory"))
38 Ω(cache).Should(BeZero())
39 Ω(os.RemoveAll("cache-temp-file")).Should(Succeed())
40 })
41 })
42
43 Describe("the happy path", func() {
44 It("can save, load, list, delete, and clear the cache", func() {
45 Ω(cache.Save("e1", 1, e1)).Should(Succeed())
46 Ω(cache.Save("e2", 7, e2)).Should(Succeed())
47
48 Ω(cache.Load("e1", 1)).Should(Equal(e1))
49 Ω(cache.Load("e2", 7)).Should(Equal(e2))
50
51 Ω(cache.List()).Should(ConsistOf(
52 gmeasure.CachedExperimentHeader{"e1", 1},
53 gmeasure.CachedExperimentHeader{"e2", 7},
54 ))
55
56 Ω(cache.Delete("e2")).Should(Succeed())
57 Ω(cache.Load("e1", 1)).Should(Equal(e1))
58 Ω(cache.Load("e2", 7)).Should(BeNil())
59 Ω(cache.List()).Should(ConsistOf(
60 gmeasure.CachedExperimentHeader{"e1", 1},
61 ))
62
63 Ω(cache.Clear()).Should(Succeed())
64 Ω(cache.List()).Should(BeEmpty())
65 Ω(cache.Load("e1", 1)).Should(BeNil())
66 Ω(cache.Load("e2", 7)).Should(BeNil())
67 })
68 })
69
70 Context("with an empty cache", func() {
71 It("should list nothing", func() {
72 Ω(cache.List()).Should(BeEmpty())
73 })
74
75 It("should not error when clearing", func() {
76 Ω(cache.Clear()).Should(Succeed())
77 })
78
79 It("returs nil when loading a non-existing experiment", func() {
80 Ω(cache.Load("floop", 17)).Should(BeNil())
81 })
82 })
83
84 Describe("version management", func() {
85 BeforeEach(func() {
86 Ω(cache.Save("e1", 7, e1)).Should(Succeed())
87 })
88
89 Context("when the cached version is older than the requested version", func() {
90 It("returns nil", func() {
91 Ω(cache.Load("e1", 8)).Should(BeNil())
92 })
93 })
94
95 Context("when the cached version equals the requested version", func() {
96 It("returns the cached version", func() {
97 Ω(cache.Load("e1", 7)).Should(Equal(e1))
98 })
99 })
100
101 Context("when the cached version is newer than the requested version", func() {
102 It("returns the cached version", func() {
103 Ω(cache.Load("e1", 6)).Should(Equal(e1))
104 })
105 })
106 })
107
108 })
0 package gmeasure
1
2 import "encoding/json"
3
4 type enumSupport struct {
5 toString map[uint]string
6 toEnum map[string]uint
7 maxEnum uint
8 }
9
10 func newEnumSupport(toString map[uint]string) enumSupport {
11 toEnum, maxEnum := map[string]uint{}, uint(0)
12 for k, v := range toString {
13 toEnum[v] = k
14 if maxEnum < k {
15 maxEnum = k
16 }
17 }
18 return enumSupport{toString: toString, toEnum: toEnum, maxEnum: maxEnum}
19 }
20
21 func (es enumSupport) String(e uint) string {
22 if e > es.maxEnum {
23 return es.toString[0]
24 }
25 return es.toString[e]
26 }
27
28 func (es enumSupport) UnmarshalJSON(b []byte) (uint, error) {
29 var dec string
30 if err := json.Unmarshal(b, &dec); err != nil {
31 return 0, err
32 }
33 out := es.toEnum[dec] // if we miss we get 0 which is what we want anyway
34 return out, nil
35 }
36
37 func (es enumSupport) MarshalJSON(e uint) ([]byte, error) {
38 if e == 0 || e > es.maxEnum {
39 return json.Marshal(nil)
40 }
41 return json.Marshal(es.toString[e])
42 }
0 /*
1 Package gomega/gmeasure provides support for benchmarking and measuring code. It is intended as a more robust replacement for Ginkgo V1's Measure nodes.
2
3 **gmeasure IS CURRENTLY IN BETA - THE API MAY CHANGE IN THE NEAR-FUTURE. gmeasure WILL BE CONSIDERED GA WHEN Ginkgo V2 IS GA.
4
5 gmeasure is organized around the metaphor of an Experiment that can record multiple Measurements. A Measurement is a named collection of data points and gmeasure supports
6 measuring Values (of type float64) and Durations (of type time.Duration).
7
8 Experiments allows the user to record Measurements directly by passing in Values (i.e. float64) or Durations (i.e. time.Duration)
9 or to measure measurements by passing in functions to measure. When measuring functions Experiments take care of timing the duration of functions (for Duration measurements)
10 and/or recording returned values (for Value measurements). Experiments also support sampling functions - when told to sample Experiments will run functions repeatedly
11 and measure and record results. The sampling behavior is configured by passing in a SamplingConfig that can control the maximum number of samples, the maximum duration for sampling (or both)
12 and the number of concurrent samples to take.
13
14 Measurements can be decorated with additional information. This is supported by passing in special typed decorators when recording measurements. These include:
15
16 - Units("any string") - to attach units to a Value Measurement (Duration Measurements always have units of "duration")
17 - Style("any Ginkgo color style string") - to attach styling to a Measurement. This styling is used when rendering console information about the measurement in reports. Color style strings are documented at TODO.
18 - Precision(integer or time.Duration) - to attach precision to a Measurement. This controls how many decimal places to show for Value Measurements and how to round Duration Measurements when rendering them to screen.
19
20 In addition, individual data points in a Measurement can be annotated with an Annotation("any string"). The annotation is associated with the individual data point and is intended to convey additional context about the data point.
21
22 Once measurements are complete, an Experiment can generate a comprehensive report by calling its String() or ColorableString() method.
23
24 Users can also access and analyze the resulting Measurements directly. Use Experiment.Get(NAME) to fetch the Measurement named NAME. This returned struct will have fields containing
25 all the data points and annotations recorded by the experiment. You can subsequently fetch the Measurement.Stats() to get a Stats struct that contains basic statistical information about the
26 Measurement (min, max, median, mean, standard deviation). You can order these Stats objects using RankStats() to identify best/worst performers across multpile experiments or measurements.
27
28 gmeasure also supports caching Experiments via an ExperimentCache. The cache supports storing and retreiving experiments by name and version. This allows you to rerun code without
29 repeating expensive experiments that may not have changed (which can be controlled by the cache version number). It also enables you to compare new experiment runs with older runs to detect
30 variations in performance/behavior.
31
32 When used with Ginkgo, you can emit experiment reports and encode them in test reports easily using Ginkgo V2's support for Report Entries.
33 Simply pass your experiment to AddReportEntry to get a report every time the tests run. You can also use AddReportEntry with Measurements to emit all the captured data
34 and Rankings to emit measurement summaries in rank order.
35
36 Finally, Experiments provide an additional mechanism to measure durations called a Stopwatch. The Stopwatch makes it easy to pepper code with statements that measure elapsed time across
37 different sections of code and can be useful when debugging or evaluating bottlenecks in a given codepath.
38 */
39 package gmeasure
40
41 import (
42 "fmt"
43 "math"
44 "reflect"
45 "sync"
46 "time"
47
48 "github.com/onsi/gomega/gmeasure/table"
49 )
50
51 /*
52 SamplingConfig configures the Sample family of experiment methods.
53 These methods invoke passed-in functions repeatedly to sample and record a given measurement.
54 SamplingConfig is used to control the maximum number of samples or time spent sampling (or both). When both are specified sampling ends as soon as one of the conditions is met.
55 SamplingConfig can also enable concurrent sampling.
56 */
57 type SamplingConfig struct {
58 // N - the maximum number of samples to record
59 N int
60 // Duration - the maximum amount of time to spend recording samples
61 Duration time.Duration
62 // NumParallel - the number of parallel workers to spin up to record samples.
63 NumParallel int
64 }
65
66 // The Units decorator allows you to specify units (an arbitrary string) when recording values. It is ignored when recording durations.
67 //
68 // e := gmeasure.NewExperiment("My Experiment")
69 // e.RecordValue("length", 3.141, gmeasure.Units("inches"))
70 //
71 // Units are only set the first time a value of a given name is recorded. In the example above any subsequent calls to e.RecordValue("length", X) will maintain the "inches" units even if a new set of Units("UNIT") are passed in later.
72 type Units string
73
74 // The Annotation decorator allows you to attach an annotation to a given recorded data-point:
75 //
76 // For example:
77 //
78 // e := gmeasure.NewExperiment("My Experiment")
79 // e.RecordValue("length", 3.141, gmeasure.Annotation("bob"))
80 // e.RecordValue("length", 2.71, gmeasure.Annotation("jane"))
81 //
82 // ...will result in a Measurement named "length" that records two values )[3.141, 2.71]) annotation with (["bob", "jane"])
83 type Annotation string
84
85 // The Style decorator allows you to associate a style with a measurement. This is used to generate colorful console reports using Ginkgo V2's
86 // console formatter. Styles are strings in curly brackets that correspond to a color or style.
87 //
88 // For example:
89 //
90 // e := gmeasure.NewExperiment("My Experiment")
91 // e.RecordValue("length", 3.141, gmeasure.Style("{{blue}}{{bold}}"))
92 // e.RecordValue("length", 2.71)
93 // e.RecordDuration("cooking time", 3 * time.Second, gmeasure.Style("{{red}}{{underline}}"))
94 // e.RecordDuration("cooking time", 2 * time.Second)
95 //
96 // will emit a report with blue bold entries for the length measurement and red underlined entries for the cooking time measurement.
97 //
98 // Units are only set the first time a value or duration of a given name is recorded. In the example above any subsequent calls to e.RecordValue("length", X) will maintain the "{{blue}}{{bold}}" style even if a new Style is passed in later.
99 type Style string
100
101 // The PrecisionBundle decorator controls the rounding of value and duration measurements. See Precision().
102 type PrecisionBundle struct {
103 Duration time.Duration
104 ValueFormat string
105 }
106
107 // Precision() allows you to specify the precision of a value or duration measurement - this precision is used when rendering the measurement to screen.
108 //
109 // To control the precision of Value measurements, pass Precision an integer. This will denote the number of decimal places to render (equivalen to the format string "%.Nf")
110 // To control the precision of Duration measurements, pass Precision a time.Duration. Duration measurements will be rounded oo the nearest time.Duration when rendered.
111 //
112 // For example:
113 //
114 // e := gmeasure.NewExperiment("My Experiment")
115 // e.RecordValue("length", 3.141, gmeasure.Precision(2))
116 // e.RecordValue("length", 2.71)
117 // e.RecordDuration("cooking time", 3214 * time.Millisecond, gmeasure.Precision(100*time.Millisecond))
118 // e.RecordDuration("cooking time", 2623 * time.Millisecond)
119 func Precision(p interface{}) PrecisionBundle {
120 out := DefaultPrecisionBundle
121 switch reflect.TypeOf(p) {
122 case reflect.TypeOf(time.Duration(0)):
123 out.Duration = p.(time.Duration)
124 case reflect.TypeOf(int(0)):
125 out.ValueFormat = fmt.Sprintf("%%.%df", p.(int))
126 default:
127 panic("invalid precision type, must be time.Duration or int")
128 }
129 return out
130 }
131
132 // DefaultPrecisionBundle captures the default precisions for Vale and Duration measurements.
133 var DefaultPrecisionBundle = PrecisionBundle{
134 Duration: 100 * time.Microsecond,
135 ValueFormat: "%.3f",
136 }
137
138 type extractedDecorations struct {
139 annotation Annotation
140 units Units
141 precisionBundle PrecisionBundle
142 style Style
143 }
144
145 func extractDecorations(args []interface{}) extractedDecorations {
146 var out extractedDecorations
147 out.precisionBundle = DefaultPrecisionBundle
148
149 for _, arg := range args {
150 switch reflect.TypeOf(arg) {
151 case reflect.TypeOf(out.annotation):
152 out.annotation = arg.(Annotation)
153 case reflect.TypeOf(out.units):
154 out.units = arg.(Units)
155 case reflect.TypeOf(out.precisionBundle):
156 out.precisionBundle = arg.(PrecisionBundle)
157 case reflect.TypeOf(out.style):
158 out.style = arg.(Style)
159 default:
160 panic(fmt.Sprintf("unrecognized argument %#v", arg))
161 }
162 }
163
164 return out
165 }
166
167 /*
168 Experiment is gmeasure's core data type. You use experiments to record Measurements and generate reports.
169 Experiments are thread-safe and all methods can be called from multiple goroutines.
170 */
171 type Experiment struct {
172 Name string
173
174 // Measurements includes all Measurements recorded by this experiment. You should access them by name via Get() and GetStats()
175 Measurements Measurements
176 lock *sync.Mutex
177 }
178
179 /*
180 NexExperiment creates a new experiment with the passed-in name.
181
182 When using Ginkgo we recommend immediately registering the experiment as a ReportEntry:
183
184 experiment = NewExperiment("My Experiment")
185 AddReportEntry(experiment.Name, experiment)
186
187 this will ensure an experiment report is emitted as part of the test output and exported with any test reports.
188 */
189 func NewExperiment(name string) *Experiment {
190 experiment := &Experiment{
191 Name: name,
192 lock: &sync.Mutex{},
193 }
194 return experiment
195 }
196
197 func (e *Experiment) report(enableStyling bool) string {
198 t := table.NewTable()
199 t.TableStyle.EnableTextStyling = enableStyling
200 t.AppendRow(table.R(
201 table.C("Name"), table.C("N"), table.C("Min"), table.C("Median"), table.C("Mean"), table.C("StdDev"), table.C("Max"),
202 table.Divider("="),
203 "{{bold}}",
204 ))
205
206 for _, measurement := range e.Measurements {
207 r := table.R(measurement.Style)
208 t.AppendRow(r)
209 switch measurement.Type {
210 case MeasurementTypeNote:
211 r.AppendCell(table.C(measurement.Note))
212 case MeasurementTypeValue, MeasurementTypeDuration:
213 name := measurement.Name
214 if measurement.Units != "" {
215 name += " [" + measurement.Units + "]"
216 }
217 r.AppendCell(table.C(name))
218 r.AppendCell(measurement.Stats().cells()...)
219 }
220 }
221
222 out := e.Name + "\n"
223 if enableStyling {
224 out = "{{bold}}" + out + "{{/}}"
225 }
226 out += t.Render()
227 return out
228 }
229
230 /*
231 ColorableString returns a Ginkgo formatted summary of the experiment and all its Measurements.
232 It is called automatically by Ginkgo's reporting infrastructure when the Experiment is registered as a ReportEntry via AddReportEntry.
233 */
234 func (e *Experiment) ColorableString() string {
235 return e.report(true)
236 }
237
238 /*
239 ColorableString returns an unformatted summary of the experiment and all its Measurements.
240 */
241 func (e *Experiment) String() string {
242 return e.report(false)
243 }
244
245 /*
246 RecordNote records a Measurement of type MeasurementTypeNote - this is simply a textual note to annotate the experiment. It will be emitted in any experiment reports.
247
248 RecordNote supports the Style() decoration.
249 */
250 func (e *Experiment) RecordNote(note string, args ...interface{}) {
251 decorations := extractDecorations(args)
252
253 e.lock.Lock()
254 defer e.lock.Unlock()
255 e.Measurements = append(e.Measurements, Measurement{
256 ExperimentName: e.Name,
257 Type: MeasurementTypeNote,
258 Note: note,
259 Style: string(decorations.style),
260 })
261 }
262
263 /*
264 RecordDuration records the passed-in duration on a Duration Measurement with the passed-in name. If the Measurement does not exist it is created.
265
266 RecordDuration supports the Style(), Precision(), and Annotation() decorations.
267 */
268 func (e *Experiment) RecordDuration(name string, duration time.Duration, args ...interface{}) {
269 decorations := extractDecorations(args)
270 e.recordDuration(name, duration, decorations)
271 }
272
273 /*
274 MeasureDuration runs the passed-in callback and times how long it takes to complete. The resulting duration is recorded on a Duration Measurement with the passed-in name. If the Measurement does not exist it is created.
275
276 MeasureDuration supports the Style(), Precision(), and Annotation() decorations.
277 */
278 func (e *Experiment) MeasureDuration(name string, callback func(), args ...interface{}) time.Duration {
279 t := time.Now()
280 callback()
281 duration := time.Since(t)
282 e.RecordDuration(name, duration, args...)
283 return duration
284 }
285
286 /*
287 SampleDuration samples the passed-in callback and times how long it takes to complete each sample.
288 The resulting durations are recorded on a Duration Measurement with the passed-in name. If the Measurement does not exist it is created.
289
290 The callback is given a zero-based index that increments by one between samples. The Sampling is configured via the passed-in SamplingConfig
291
292 SampleDuration supports the Style(), Precision(), and Annotation() decorations. When passed an Annotation() the same annotation is applied to all sample measurements.
293 */
294 func (e *Experiment) SampleDuration(name string, callback func(idx int), samplingConfig SamplingConfig, args ...interface{}) {
295 decorations := extractDecorations(args)
296 e.Sample(func(idx int) {
297 t := time.Now()
298 callback(idx)
299 duration := time.Since(t)
300 e.recordDuration(name, duration, decorations)
301 }, samplingConfig)
302 }
303
304 /*
305 SampleDuration samples the passed-in callback and times how long it takes to complete each sample.
306 The resulting durations are recorded on a Duration Measurement with the passed-in name. If the Measurement does not exist it is created.
307
308 The callback is given a zero-based index that increments by one between samples. The callback must return an Annotation - this annotation is attached to the measured duration.
309
310 The Sampling is configured via the passed-in SamplingConfig
311
312 SampleAnnotatedDuration supports the Style() and Precision() decorations.
313 */
314 func (e *Experiment) SampleAnnotatedDuration(name string, callback func(idx int) Annotation, samplingConfig SamplingConfig, args ...interface{}) {
315 decorations := extractDecorations(args)
316 e.Sample(func(idx int) {
317 t := time.Now()
318 decorations.annotation = callback(idx)
319 duration := time.Since(t)
320 e.recordDuration(name, duration, decorations)
321 }, samplingConfig)
322 }
323
324 func (e *Experiment) recordDuration(name string, duration time.Duration, decorations extractedDecorations) {
325 e.lock.Lock()
326 defer e.lock.Unlock()
327 idx := e.Measurements.IdxWithName(name)
328 if idx == -1 {
329 measurement := Measurement{
330 ExperimentName: e.Name,
331 Type: MeasurementTypeDuration,
332 Name: name,
333 Units: "duration",
334 Durations: []time.Duration{duration},
335 PrecisionBundle: decorations.precisionBundle,
336 Style: string(decorations.style),
337 Annotations: []string{string(decorations.annotation)},
338 }
339 e.Measurements = append(e.Measurements, measurement)
340 } else {
341 if e.Measurements[idx].Type != MeasurementTypeDuration {
342 panic(fmt.Sprintf("attempting to record duration with name '%s'. That name is already in-use for recording values.", name))
343 }
344 e.Measurements[idx].Durations = append(e.Measurements[idx].Durations, duration)
345 e.Measurements[idx].Annotations = append(e.Measurements[idx].Annotations, string(decorations.annotation))
346 }
347 }
348
349 /*
350 NewStopwatch() returns a stopwatch configured to record duration measurements with this experiment.
351 */
352 func (e *Experiment) NewStopwatch() *Stopwatch {
353 return newStopwatch(e)
354 }
355
356 /*
357 RecordValue records the passed-in value on a Value Measurement with the passed-in name. If the Measurement does not exist it is created.
358
359 RecordValue supports the Style(), Units(), Precision(), and Annotation() decorations.
360 */
361 func (e *Experiment) RecordValue(name string, value float64, args ...interface{}) {
362 decorations := extractDecorations(args)
363 e.recordValue(name, value, decorations)
364 }
365
366 /*
367 MeasureValue runs the passed-in callback and records the return value on a Value Measurement with the passed-in name. If the Measurement does not exist it is created.
368
369 MeasureValue supports the Style(), Units(), Precision(), and Annotation() decorations.
370 */
371 func (e *Experiment) MeasureValue(name string, callback func() float64, args ...interface{}) float64 {
372 value := callback()
373 e.RecordValue(name, value, args...)
374 return value
375 }
376
377 /*
378 SampleValue samples the passed-in callback and records the return value on a Value Measurement with the passed-in name. If the Measurement does not exist it is created.
379
380 The callback is given a zero-based index that increments by one between samples. The callback must return a float64. The Sampling is configured via the passed-in SamplingConfig
381
382 SampleValue supports the Style(), Units(), Precision(), and Annotation() decorations. When passed an Annotation() the same annotation is applied to all sample measurements.
383 */
384 func (e *Experiment) SampleValue(name string, callback func(idx int) float64, samplingConfig SamplingConfig, args ...interface{}) {
385 decorations := extractDecorations(args)
386 e.Sample(func(idx int) {
387 value := callback(idx)
388 e.recordValue(name, value, decorations)
389 }, samplingConfig)
390 }
391
392 /*
393 SampleAnnotatedValue samples the passed-in callback and records the return value on a Value Measurement with the passed-in name. If the Measurement does not exist it is created.
394
395 The callback is given a zero-based index that increments by one between samples. The callback must return a float64 and an Annotation - the annotation is attached to the recorded value.
396
397 The Sampling is configured via the passed-in SamplingConfig
398
399 SampleValue supports the Style(), Units(), and Precision() decorations.
400 */
401 func (e *Experiment) SampleAnnotatedValue(name string, callback func(idx int) (float64, Annotation), samplingConfig SamplingConfig, args ...interface{}) {
402 decorations := extractDecorations(args)
403 e.Sample(func(idx int) {
404 var value float64
405 value, decorations.annotation = callback(idx)
406 e.recordValue(name, value, decorations)
407 }, samplingConfig)
408 }
409
410 func (e *Experiment) recordValue(name string, value float64, decorations extractedDecorations) {
411 e.lock.Lock()
412 defer e.lock.Unlock()
413 idx := e.Measurements.IdxWithName(name)
414 if idx == -1 {
415 measurement := Measurement{
416 ExperimentName: e.Name,
417 Type: MeasurementTypeValue,
418 Name: name,
419 Style: string(decorations.style),
420 Units: string(decorations.units),
421 PrecisionBundle: decorations.precisionBundle,
422 Values: []float64{value},
423 Annotations: []string{string(decorations.annotation)},
424 }
425 e.Measurements = append(e.Measurements, measurement)
426 } else {
427 if e.Measurements[idx].Type != MeasurementTypeValue {
428 panic(fmt.Sprintf("attempting to record value with name '%s'. That name is already in-use for recording durations.", name))
429 }
430 e.Measurements[idx].Values = append(e.Measurements[idx].Values, value)
431 e.Measurements[idx].Annotations = append(e.Measurements[idx].Annotations, string(decorations.annotation))
432 }
433 }
434
435 /*
436 Sample samples the passed-in callback repeatedly. The sampling is governed by the passed in SamplingConfig.
437
438 The SamplingConfig can limit the total number of samples and/or the total time spent sampling the callback.
439 The SamplingConfig can also instruct Sample to run with multiple concurrent workers.
440
441 The callback is called with a zero-based index that incerements by one between samples.
442 */
443 func (e *Experiment) Sample(callback func(idx int), samplingConfig SamplingConfig) {
444 if samplingConfig.N == 0 && samplingConfig.Duration == 0 {
445 panic("you must specify at least one of SamplingConfig.N and SamplingConfig.Duration")
446 }
447 maxTime := time.Now().Add(100000 * time.Hour)
448 if samplingConfig.Duration > 0 {
449 maxTime = time.Now().Add(samplingConfig.Duration)
450 }
451 maxN := math.MaxInt64
452 if samplingConfig.N > 0 {
453 maxN = samplingConfig.N
454 }
455 numParallel := 1
456 if samplingConfig.NumParallel > numParallel {
457 numParallel = samplingConfig.NumParallel
458 }
459
460 work := make(chan int)
461 if numParallel > 1 {
462 for worker := 0; worker < numParallel; worker++ {
463 go func() {
464 for idx := range work {
465 callback(idx)
466 }
467 }()
468 }
469 }
470
471 idx := 0
472 var avgDt time.Duration
473 for {
474 t := time.Now()
475 if numParallel > 1 {
476 work <- idx
477 } else {
478 callback(idx)
479 }
480 dt := time.Since(t)
481 if idx >= numParallel {
482 avgDt = (avgDt*time.Duration(idx-numParallel) + dt) / time.Duration(idx-numParallel+1)
483 }
484 idx += 1
485 if idx >= maxN {
486 return
487 }
488 if time.Now().Add(avgDt).After(maxTime) {
489 return
490 }
491 }
492 }
493
494 /*
495 Get returns the Measurement with the associated name. If no Measurement is found a zero Measurement{} is returned.
496 */
497 func (e *Experiment) Get(name string) Measurement {
498 e.lock.Lock()
499 defer e.lock.Unlock()
500 idx := e.Measurements.IdxWithName(name)
501 if idx == -1 {
502 return Measurement{}
503 }
504 return e.Measurements[idx]
505 }
506
507 /*
508 GetStats returns the Stats for the Measurement with the associated name. If no Measurement is found a zero Stats{} is returned.
509
510 experiment.GetStats(name) is equivalent to experiment.Get(name).Stats()
511 */
512 func (e *Experiment) GetStats(name string) Stats {
513 measurement := e.Get(name)
514 e.lock.Lock()
515 defer e.lock.Unlock()
516 return measurement.Stats()
517 }
0 package gmeasure_test
1
2 import (
3 "fmt"
4 "strings"
5 "sync"
6 "time"
7
8 . "github.com/onsi/ginkgo"
9 . "github.com/onsi/gomega"
10
11 "github.com/onsi/gomega/gmeasure"
12 )
13
14 var _ = Describe("Experiment", func() {
15 var e *gmeasure.Experiment
16 BeforeEach(func() {
17 e = gmeasure.NewExperiment("Test Experiment")
18 })
19
20 Describe("Recording Notes", func() {
21 It("creates a note Measurement", func() {
22 e.RecordNote("I'm a note", gmeasure.Style("{{blue}}"))
23 measurement := e.Measurements[0]
24 Ω(measurement.Type).Should(Equal(gmeasure.MeasurementTypeNote))
25 Ω(measurement.ExperimentName).Should(Equal("Test Experiment"))
26 Ω(measurement.Note).Should(Equal("I'm a note"))
27 Ω(measurement.Style).Should(Equal("{{blue}}"))
28 })
29 })
30
31 Describe("Recording Durations", func() {
32 commonMeasurementAssertions := func() gmeasure.Measurement {
33 measurement := e.Get("runtime")
34 Ω(measurement.Type).Should(Equal(gmeasure.MeasurementTypeDuration))
35 Ω(measurement.ExperimentName).Should(Equal("Test Experiment"))
36 Ω(measurement.Name).Should(Equal("runtime"))
37 Ω(measurement.Units).Should(Equal("duration"))
38 Ω(measurement.Style).Should(Equal("{{red}}"))
39 Ω(measurement.PrecisionBundle.Duration).Should(Equal(time.Millisecond))
40 return measurement
41 }
42
43 BeforeEach(func() {
44 e.RecordDuration("runtime", time.Second, gmeasure.Annotation("first"), gmeasure.Style("{{red}}"), gmeasure.Precision(time.Millisecond), gmeasure.Units("ignored"))
45 })
46
47 Describe("RecordDuration", func() {
48 It("generates a measurement and records the passed-in duration along with any relevant decorations", func() {
49 e.RecordDuration("runtime", time.Minute, gmeasure.Annotation("second"))
50 measurement := commonMeasurementAssertions()
51 Ω(measurement.Durations).Should(Equal([]time.Duration{time.Second, time.Minute}))
52 Ω(measurement.Annotations).Should(Equal([]string{"first", "second"}))
53 })
54 })
55
56 Describe("MeasureDuration", func() {
57 It("measure the duration of the passed-in function", func() {
58 e.MeasureDuration("runtime", func() {
59 time.Sleep(200 * time.Millisecond)
60 }, gmeasure.Annotation("second"))
61 measurement := commonMeasurementAssertions()
62 Ω(measurement.Durations[0]).Should(Equal(time.Second))
63 Ω(measurement.Durations[1]).Should(BeNumerically("~", 200*time.Millisecond, 20*time.Millisecond))
64 Ω(measurement.Annotations).Should(Equal([]string{"first", "second"}))
65 })
66 })
67
68 Describe("SampleDuration", func() {
69 It("samples the passed-in function according to SampleConfig and records the measured durations", func() {
70 e.SampleDuration("runtime", func(_ int) {
71 time.Sleep(100 * time.Millisecond)
72 }, gmeasure.SamplingConfig{N: 3}, gmeasure.Annotation("sampled"))
73 measurement := commonMeasurementAssertions()
74 Ω(measurement.Durations[0]).Should(Equal(time.Second))
75 Ω(measurement.Durations[1]).Should(BeNumerically("~", 100*time.Millisecond, 20*time.Millisecond))
76 Ω(measurement.Durations[2]).Should(BeNumerically("~", 100*time.Millisecond, 20*time.Millisecond))
77 Ω(measurement.Durations[3]).Should(BeNumerically("~", 100*time.Millisecond, 20*time.Millisecond))
78 Ω(measurement.Annotations).Should(Equal([]string{"first", "sampled", "sampled", "sampled"}))
79 })
80 })
81
82 Describe("SampleAnnotatedDuration", func() {
83 It("samples the passed-in function according to SampleConfig and records the measured durations and returned annotations", func() {
84 e.SampleAnnotatedDuration("runtime", func(idx int) gmeasure.Annotation {
85 time.Sleep(100 * time.Millisecond)
86 return gmeasure.Annotation(fmt.Sprintf("sampled-%d", idx+1))
87 }, gmeasure.SamplingConfig{N: 3}, gmeasure.Annotation("ignored"))
88 measurement := commonMeasurementAssertions()
89 Ω(measurement.Durations[0]).Should(Equal(time.Second))
90 Ω(measurement.Durations[1]).Should(BeNumerically("~", 100*time.Millisecond, 20*time.Millisecond))
91 Ω(measurement.Durations[2]).Should(BeNumerically("~", 100*time.Millisecond, 20*time.Millisecond))
92 Ω(measurement.Durations[3]).Should(BeNumerically("~", 100*time.Millisecond, 20*time.Millisecond))
93 Ω(measurement.Annotations).Should(Equal([]string{"first", "sampled-1", "sampled-2", "sampled-3"}))
94 })
95 })
96 })
97
98 Describe("Stopwatch Support", func() {
99 It("can generate a new stopwatch tied to the experiment", func() {
100 s := e.NewStopwatch()
101 time.Sleep(50 * time.Millisecond)
102 s.Record("runtime", gmeasure.Annotation("first")).Reset()
103 time.Sleep(100 * time.Millisecond)
104 s.Record("runtime", gmeasure.Annotation("second")).Reset()
105 time.Sleep(150 * time.Millisecond)
106 s.Record("runtime", gmeasure.Annotation("third"))
107 measurement := e.Get("runtime")
108 Ω(measurement.Durations[0]).Should(BeNumerically("~", 50*time.Millisecond, 20*time.Millisecond))
109 Ω(measurement.Durations[1]).Should(BeNumerically("~", 100*time.Millisecond, 20*time.Millisecond))
110 Ω(measurement.Durations[2]).Should(BeNumerically("~", 150*time.Millisecond, 20*time.Millisecond))
111 Ω(measurement.Annotations).Should(Equal([]string{"first", "second", "third"}))
112 })
113 })
114
115 Describe("Recording Values", func() {
116 commonMeasurementAssertions := func() gmeasure.Measurement {
117 measurement := e.Get("sprockets")
118 Ω(measurement.Type).Should(Equal(gmeasure.MeasurementTypeValue))
119 Ω(measurement.ExperimentName).Should(Equal("Test Experiment"))
120 Ω(measurement.Name).Should(Equal("sprockets"))
121 Ω(measurement.Units).Should(Equal("widgets"))
122 Ω(measurement.Style).Should(Equal("{{yellow}}"))
123 Ω(measurement.PrecisionBundle.ValueFormat).Should(Equal("%.0f"))
124 return measurement
125 }
126
127 BeforeEach(func() {
128 e.RecordValue("sprockets", 3.2, gmeasure.Annotation("first"), gmeasure.Style("{{yellow}}"), gmeasure.Precision(0), gmeasure.Units("widgets"))
129 })
130
131 Describe("RecordValue", func() {
132 It("generates a measurement and records the passed-in value along with any relevant decorations", func() {
133 e.RecordValue("sprockets", 17.4, gmeasure.Annotation("second"))
134 measurement := commonMeasurementAssertions()
135 Ω(measurement.Values).Should(Equal([]float64{3.2, 17.4}))
136 Ω(measurement.Annotations).Should(Equal([]string{"first", "second"}))
137 })
138 })
139
140 Describe("MeasureValue", func() {
141 It("records the value returned by the passed-in function", func() {
142 e.MeasureValue("sprockets", func() float64 {
143 return 17.4
144 }, gmeasure.Annotation("second"))
145 measurement := commonMeasurementAssertions()
146 Ω(measurement.Values).Should(Equal([]float64{3.2, 17.4}))
147 Ω(measurement.Annotations).Should(Equal([]string{"first", "second"}))
148 })
149 })
150
151 Describe("SampleValue", func() {
152 It("samples the passed-in function according to SampleConfig and records the resulting values", func() {
153 e.SampleValue("sprockets", func(idx int) float64 {
154 return 17.4 + float64(idx)
155 }, gmeasure.SamplingConfig{N: 3}, gmeasure.Annotation("sampled"))
156 measurement := commonMeasurementAssertions()
157 Ω(measurement.Values).Should(Equal([]float64{3.2, 17.4, 18.4, 19.4}))
158 Ω(measurement.Annotations).Should(Equal([]string{"first", "sampled", "sampled", "sampled"}))
159 })
160 })
161
162 Describe("SampleAnnotatedValue", func() {
163 It("samples the passed-in function according to SampleConfig and records the returned values and annotations", func() {
164 e.SampleAnnotatedValue("sprockets", func(idx int) (float64, gmeasure.Annotation) {
165 return 17.4 + float64(idx), gmeasure.Annotation(fmt.Sprintf("sampled-%d", idx+1))
166 }, gmeasure.SamplingConfig{N: 3}, gmeasure.Annotation("ignored"))
167 measurement := commonMeasurementAssertions()
168 Ω(measurement.Values).Should(Equal([]float64{3.2, 17.4, 18.4, 19.4}))
169 Ω(measurement.Annotations).Should(Equal([]string{"first", "sampled-1", "sampled-2", "sampled-3"}))
170 })
171 })
172 })
173
174 Describe("Sampling", func() {
175 var indices []int
176 BeforeEach(func() {
177 indices = []int{}
178 })
179
180 ints := func(n int) []int {
181 out := []int{}
182 for i := 0; i < n; i++ {
183 out = append(out, i)
184 }
185 return out
186 }
187
188 It("calls the function repeatedly passing in an index", func() {
189 e.Sample(func(idx int) {
190 indices = append(indices, idx)
191 }, gmeasure.SamplingConfig{N: 3})
192
193 Ω(indices).Should(Equal(ints(3)))
194 })
195
196 It("can cap the maximum number of samples", func() {
197 e.Sample(func(idx int) {
198 indices = append(indices, idx)
199 }, gmeasure.SamplingConfig{N: 10, Duration: time.Minute})
200
201 Ω(indices).Should(Equal(ints(10)))
202 })
203
204 It("can cap the maximum sample time", func() {
205 e.Sample(func(idx int) {
206 indices = append(indices, idx)
207 time.Sleep(10 * time.Millisecond)
208 }, gmeasure.SamplingConfig{N: 100, Duration: 100 * time.Millisecond})
209
210 Ω(len(indices)).Should(BeNumerically("~", 10, 3))
211 Ω(indices).Should(Equal(ints(len(indices))))
212 })
213
214 It("can run samples in parallel", func() {
215 lock := &sync.Mutex{}
216
217 e.Sample(func(idx int) {
218 lock.Lock()
219 indices = append(indices, idx)
220 lock.Unlock()
221 time.Sleep(10 * time.Millisecond)
222 }, gmeasure.SamplingConfig{N: 100, Duration: 100 * time.Millisecond, NumParallel: 3})
223
224 Ω(len(indices)).Should(BeNumerically("~", 30, 10))
225 Ω(indices).Should(ConsistOf(ints(len(indices))))
226 })
227
228 It("panics if the SamplingConfig is misconfigured", func() {
229 Expect(func() {
230 e.Sample(func(_ int) {}, gmeasure.SamplingConfig{})
231 }).To(PanicWith("you must specify at least one of SamplingConfig.N and SamplingConfig.Duration"))
232 })
233 })
234
235 Describe("recording multiple entries", func() {
236 It("always appends to the correct measurement (by name)", func() {
237 e.RecordDuration("alpha", time.Second)
238 e.RecordDuration("beta", time.Minute)
239 e.RecordValue("gamma", 1)
240 e.RecordValue("delta", 2.71)
241 e.RecordDuration("alpha", 2*time.Second)
242 e.RecordDuration("beta", 2*time.Minute)
243 e.RecordValue("gamma", 2)
244 e.RecordValue("delta", 3.141)
245
246 Ω(e.Measurements).Should(HaveLen(4))
247 Ω(e.Get("alpha").Durations).Should(Equal([]time.Duration{time.Second, 2 * time.Second}))
248 Ω(e.Get("beta").Durations).Should(Equal([]time.Duration{time.Minute, 2 * time.Minute}))
249 Ω(e.Get("gamma").Values).Should(Equal([]float64{1, 2}))
250 Ω(e.Get("delta").Values).Should(Equal([]float64{2.71, 3.141}))
251 })
252
253 It("panics if you incorrectly mix types", func() {
254 e.RecordDuration("runtime", time.Second)
255 Ω(func() {
256 e.RecordValue("runtime", 3.141)
257 }).Should(PanicWith("attempting to record value with name 'runtime'. That name is already in-use for recording durations."))
258
259 e.RecordValue("sprockets", 2)
260 Ω(func() {
261 e.RecordDuration("sprockets", time.Minute)
262 }).Should(PanicWith("attempting to record duration with name 'sprockets'. That name is already in-use for recording values."))
263 })
264 })
265
266 Describe("Decorators", func() {
267 It("uses the default precisions when none is specified", func() {
268 e.RecordValue("sprockets", 2)
269 e.RecordDuration("runtime", time.Minute)
270
271 Ω(e.Get("sprockets").PrecisionBundle.ValueFormat).Should(Equal("%.3f"))
272 Ω(e.Get("runtime").PrecisionBundle.Duration).Should(Equal(100 * time.Microsecond))
273 })
274
275 It("panics if an unsupported type is passed into Precision", func() {
276 Ω(func() {
277 gmeasure.Precision("aardvark")
278 }).Should(PanicWith("invalid precision type, must be time.Duration or int"))
279 })
280
281 It("panics if an unrecognized argumnet is passed in", func() {
282 Ω(func() {
283 e.RecordValue("sprockets", 2, "boom")
284 }).Should(PanicWith(`unrecognized argument "boom"`))
285 })
286 })
287
288 Describe("Getting Measurements", func() {
289 Context("when the Measurement does not exist", func() {
290 It("returns the zero Measurement", func() {
291 Ω(e.Get("not here")).Should(BeZero())
292 })
293 })
294 })
295
296 Describe("Getting Stats", func() {
297 It("returns the Measurement's Stats", func() {
298 e.RecordValue("alpha", 1)
299 e.RecordValue("alpha", 2)
300 e.RecordValue("alpha", 3)
301 Ω(e.GetStats("alpha")).Should(Equal(e.Get("alpha").Stats()))
302 })
303 })
304
305 Describe("Generating Reports", func() {
306 BeforeEach(func() {
307 e.RecordNote("A note")
308 e.RecordValue("sprockets", 7, gmeasure.Units("widgets"), gmeasure.Precision(0), gmeasure.Style("{{yellow}}"), gmeasure.Annotation("sprockets-1"))
309 e.RecordDuration("runtime", time.Second, gmeasure.Precision(100*time.Millisecond), gmeasure.Style("{{red}}"), gmeasure.Annotation("runtime-1"))
310 e.RecordNote("A blue note", gmeasure.Style("{{blue}}"))
311 e.RecordValue("gear ratio", 10.3, gmeasure.Precision(2), gmeasure.Style("{{green}}"), gmeasure.Annotation("ratio-1"))
312
313 e.RecordValue("sprockets", 8, gmeasure.Annotation("sprockets-2"))
314 e.RecordValue("sprockets", 9, gmeasure.Annotation("sprockets-3"))
315
316 e.RecordDuration("runtime", 2*time.Second, gmeasure.Annotation("runtime-2"))
317 e.RecordValue("gear ratio", 13.758, gmeasure.Precision(2), gmeasure.Annotation("ratio-2"))
318 })
319
320 It("emits a nicely formatted table", func() {
321 expected := strings.Join([]string{
322 "Test Experiment",
323 "Name | N | Min | Median | Mean | StdDev | Max ",
324 "=============================================================================",
325 "A note ",
326 "-----------------------------------------------------------------------------",
327 "sprockets [widgets] | 3 | 7 | 8 | 8 | 1 | 9 ",
328 " | | sprockets-1 | | | | sprockets-3",
329 "-----------------------------------------------------------------------------",
330 "runtime [duration] | 2 | 1s | 1.5s | 1.5s | 500ms | 2s ",
331 " | | runtime-1 | | | | runtime-2 ",
332 "-----------------------------------------------------------------------------",
333 "A blue note ",
334 "-----------------------------------------------------------------------------",
335 "gear ratio | 2 | 10.30 | 12.03 | 12.03 | 1.73 | 13.76 ",
336 " | | ratio-1 | | | | ratio-2 ",
337 "",
338 }, "\n")
339 Ω(e.String()).Should(Equal(expected))
340 })
341
342 It("can also emit a styled table", func() {
343 expected := strings.Join([]string{
344 "{{bold}}Test Experiment",
345 "{{/}}{{bold}}Name {{/}} | {{bold}}N{{/}} | {{bold}}Min {{/}} | {{bold}}Median{{/}} | {{bold}}Mean {{/}} | {{bold}}StdDev{{/}} | {{bold}}Max {{/}}",
346 "=============================================================================",
347 "A note ",
348 "-----------------------------------------------------------------------------",
349 "{{yellow}}sprockets [widgets]{{/}} | {{yellow}}3{{/}} | {{yellow}}7 {{/}} | {{yellow}}8 {{/}} | {{yellow}}8 {{/}} | {{yellow}}1 {{/}} | {{yellow}}9 {{/}}",
350 " | | {{yellow}}sprockets-1{{/}} | | | | {{yellow}}sprockets-3{{/}}",
351 "-----------------------------------------------------------------------------",
352 "{{red}}runtime [duration] {{/}} | {{red}}2{{/}} | {{red}}1s {{/}} | {{red}}1.5s {{/}} | {{red}}1.5s {{/}} | {{red}}500ms {{/}} | {{red}}2s {{/}}",
353 " | | {{red}}runtime-1 {{/}} | | | | {{red}}runtime-2 {{/}}",
354 "-----------------------------------------------------------------------------",
355 "{{blue}}A blue note {{/}}",
356 "-----------------------------------------------------------------------------",
357 "{{green}}gear ratio {{/}} | {{green}}2{{/}} | {{green}}10.30 {{/}} | {{green}}12.03 {{/}} | {{green}}12.03{{/}} | {{green}}1.73 {{/}} | {{green}}13.76 {{/}}",
358 " | | {{green}}ratio-1 {{/}} | | | | {{green}}ratio-2 {{/}}",
359 "",
360 }, "\n")
361 Ω(e.ColorableString()).Should(Equal(expected))
362 })
363 })
364 })
0 package gmeasure_test
1
2 import (
3 "testing"
4
5 . "github.com/onsi/ginkgo"
6 . "github.com/onsi/gomega"
7 )
8
9 func TestGmeasure(t *testing.T) {
10 RegisterFailHandler(Fail)
11 RunSpecs(t, "Gmeasure Suite")
12 }
0 package gmeasure
1
2 import (
3 "fmt"
4 "math"
5 "sort"
6 "time"
7
8 "github.com/onsi/gomega/gmeasure/table"
9 )
10
11 type MeasurementType uint
12
13 const (
14 MeasurementTypeInvalid MeasurementType = iota
15 MeasurementTypeNote
16 MeasurementTypeDuration
17 MeasurementTypeValue
18 )
19
20 var letEnumSupport = newEnumSupport(map[uint]string{uint(MeasurementTypeInvalid): "INVALID LOG ENTRY TYPE", uint(MeasurementTypeNote): "Note", uint(MeasurementTypeDuration): "Duration", uint(MeasurementTypeValue): "Value"})
21
22 func (s MeasurementType) String() string { return letEnumSupport.String(uint(s)) }
23 func (s *MeasurementType) UnmarshalJSON(b []byte) error {
24 out, err := letEnumSupport.UnmarshalJSON(b)
25 *s = MeasurementType(out)
26 return err
27 }
28 func (s MeasurementType) MarshalJSON() ([]byte, error) { return letEnumSupport.MarshalJSON(uint(s)) }
29
30 /*
31 Measurement records all captured data for a given measurement. You generally don't make Measurements directly - but you can fetch them from Experiments using Get().
32
33 When using Ginkgo, you can register Measurements as Report Entries via AddReportEntry. This will emit all the captured data points when Ginkgo generates the report.
34 */
35 type Measurement struct {
36 // Type is the MeasurementType - one of MeasurementTypeNote, MeasurementTypeDuration, or MeasurementTypeValue
37 Type MeasurementType
38
39 // ExperimentName is the name of the experiment that this Measurement is associated with
40 ExperimentName string
41
42 // If Type is MeasurementTypeNote, Note is populated with the note text.
43 Note string
44
45 // If Type is MeasurementTypeDuration or MeasurementTypeValue, Name is the name of the recorded measurement
46 Name string
47
48 // Style captures the styling information (if any) for this Measurement
49 Style string
50
51 // Units capture the units (if any) for this Measurement. Units is set to "duration" if the Type is MeasurementTypeDuration
52 Units string
53
54 // PrecisionBundle captures the precision to use when rendering data for this Measurement.
55 // If Type is MeasurementTypeDuration then PrecisionBundle.Duration is used to round any durations before presentation.
56 // If Type is MeasurementTypeValue then PrecisionBundle.ValueFormat is used to format any values before presentation
57 PrecisionBundle PrecisionBundle
58
59 // If Type is MeasurementTypeDuration, Durations will contain all durations recorded for this measurement
60 Durations []time.Duration
61
62 // If Type is MeasurementTypeValue, Values will contain all float64s recorded for this measurement
63 Values []float64
64
65 // If Type is MeasurementTypeDuration or MeasurementTypeValue then Annotations will include string annotations for all recorded Durations or Values.
66 // If the user does not pass-in an Annotation() decoration for a particular value or duration, the corresponding entry in the Annotations slice will be the empty string ""
67 Annotations []string
68 }
69
70 type Measurements []Measurement
71
72 func (m Measurements) IdxWithName(name string) int {
73 for idx, measurement := range m {
74 if measurement.Name == name {
75 return idx
76 }
77 }
78
79 return -1
80 }
81
82 func (m Measurement) report(enableStyling bool) string {
83 out := ""
84 style := m.Style
85 if !enableStyling {
86 style = ""
87 }
88 switch m.Type {
89 case MeasurementTypeNote:
90 out += fmt.Sprintf("%s - Note\n%s\n", m.ExperimentName, m.Note)
91 if style != "" {
92 out = style + out + "{{/}}"
93 }
94 return out
95 case MeasurementTypeValue, MeasurementTypeDuration:
96 out += fmt.Sprintf("%s - %s", m.ExperimentName, m.Name)
97 if m.Units != "" {
98 out += " [" + m.Units + "]"
99 }
100 if style != "" {
101 out = style + out + "{{/}}"
102 }
103 out += "\n"
104 out += m.Stats().String() + "\n"
105 }
106 t := table.NewTable()
107 t.TableStyle.EnableTextStyling = enableStyling
108 switch m.Type {
109 case MeasurementTypeValue:
110 t.AppendRow(table.R(table.C("Value", table.AlignTypeCenter), table.C("Annotation", table.AlignTypeCenter), table.Divider("="), style))
111 for idx := range m.Values {
112 t.AppendRow(table.R(
113 table.C(fmt.Sprintf(m.PrecisionBundle.ValueFormat, m.Values[idx]), table.AlignTypeRight),
114 table.C(m.Annotations[idx], "{{gray}}", table.AlignTypeLeft),
115 ))
116 }
117 case MeasurementTypeDuration:
118 t.AppendRow(table.R(table.C("Duration", table.AlignTypeCenter), table.C("Annotation", table.AlignTypeCenter), table.Divider("="), style))
119 for idx := range m.Durations {
120 t.AppendRow(table.R(
121 table.C(m.Durations[idx].Round(m.PrecisionBundle.Duration).String(), style, table.AlignTypeRight),
122 table.C(m.Annotations[idx], "{{gray}}", table.AlignTypeLeft),
123 ))
124 }
125 }
126 out += t.Render()
127 return out
128 }
129
130 /*
131 ColorableString generates a styled report that includes all the data points for this Measurement.
132 It is called automatically by Ginkgo's reporting infrastructure when the Measurement is registered as a ReportEntry via AddReportEntry.
133 */
134 func (m Measurement) ColorableString() string {
135 return m.report(true)
136 }
137
138 /*
139 String generates an unstyled report that includes all the data points for this Measurement.
140 */
141 func (m Measurement) String() string {
142 return m.report(false)
143 }
144
145 /*
146 Stats returns a Stats struct summarizing the statistic of this measurement
147 */
148 func (m Measurement) Stats() Stats {
149 if m.Type == MeasurementTypeInvalid || m.Type == MeasurementTypeNote {
150 return Stats{}
151 }
152
153 out := Stats{
154 ExperimentName: m.ExperimentName,
155 MeasurementName: m.Name,
156 Style: m.Style,
157 Units: m.Units,
158 PrecisionBundle: m.PrecisionBundle,
159 }
160
161 switch m.Type {
162 case MeasurementTypeValue:
163 out.Type = StatsTypeValue
164 out.N = len(m.Values)
165 if out.N == 0 {
166 return out
167 }
168 indices, sum := make([]int, len(m.Values)), 0.0
169 for idx, v := range m.Values {
170 indices[idx] = idx
171 sum += v
172 }
173 sort.Slice(indices, func(i, j int) bool {
174 return m.Values[indices[i]] < m.Values[indices[j]]
175 })
176 out.ValueBundle = map[Stat]float64{
177 StatMin: m.Values[indices[0]],
178 StatMax: m.Values[indices[out.N-1]],
179 StatMean: sum / float64(out.N),
180 StatStdDev: 0.0,
181 }
182 out.AnnotationBundle = map[Stat]string{
183 StatMin: m.Annotations[indices[0]],
184 StatMax: m.Annotations[indices[out.N-1]],
185 }
186
187 if out.N%2 == 0 {
188 out.ValueBundle[StatMedian] = (m.Values[indices[out.N/2]] + m.Values[indices[out.N/2-1]]) / 2.0
189 } else {
190 out.ValueBundle[StatMedian] = m.Values[indices[(out.N-1)/2]]
191 }
192
193 for _, v := range m.Values {
194 out.ValueBundle[StatStdDev] += (v - out.ValueBundle[StatMean]) * (v - out.ValueBundle[StatMean])
195 }
196 out.ValueBundle[StatStdDev] = math.Sqrt(out.ValueBundle[StatStdDev] / float64(out.N))
197 case MeasurementTypeDuration:
198 out.Type = StatsTypeDuration
199 out.N = len(m.Durations)
200 if out.N == 0 {
201 return out
202 }
203 indices, sum := make([]int, len(m.Durations)), time.Duration(0)
204 for idx, v := range m.Durations {
205 indices[idx] = idx
206 sum += v
207 }
208 sort.Slice(indices, func(i, j int) bool {
209 return m.Durations[indices[i]] < m.Durations[indices[j]]
210 })
211 out.DurationBundle = map[Stat]time.Duration{
212 StatMin: m.Durations[indices[0]],
213 StatMax: m.Durations[indices[out.N-1]],
214 StatMean: sum / time.Duration(out.N),
215 }
216 out.AnnotationBundle = map[Stat]string{
217 StatMin: m.Annotations[indices[0]],
218 StatMax: m.Annotations[indices[out.N-1]],
219 }
220
221 if out.N%2 == 0 {
222 out.DurationBundle[StatMedian] = (m.Durations[indices[out.N/2]] + m.Durations[indices[out.N/2-1]]) / 2
223 } else {
224 out.DurationBundle[StatMedian] = m.Durations[indices[(out.N-1)/2]]
225 }
226 stdDev := 0.0
227 for _, v := range m.Durations {
228 stdDev += float64(v-out.DurationBundle[StatMean]) * float64(v-out.DurationBundle[StatMean])
229 }
230 out.DurationBundle[StatStdDev] = time.Duration(math.Sqrt(stdDev / float64(out.N)))
231 }
232
233 return out
234 }
0 package gmeasure_test
1
2 import (
3 "math"
4 "strings"
5 "time"
6
7 . "github.com/onsi/ginkgo"
8 . "github.com/onsi/gomega"
9 "github.com/onsi/gomega/gmeasure"
10 )
11
12 var _ = Describe("Measurement", func() {
13 var e *gmeasure.Experiment
14 var measurement gmeasure.Measurement
15
16 BeforeEach(func() {
17 e = gmeasure.NewExperiment("Test Experiment")
18 })
19
20 Describe("Note Measurement", func() {
21 BeforeEach(func() {
22 e.RecordNote("I'm a red note", gmeasure.Style("{{red}}"))
23 measurement = e.Measurements[0]
24 })
25
26 Describe("Generating Stats", func() {
27 It("returns an empty stats", func() {
28 Ω(measurement.Stats()).Should(BeZero())
29 })
30 })
31
32 Describe("Emitting an unstyled report", func() {
33 It("does not include styling", func() {
34 Ω(measurement.String()).Should(Equal("Test Experiment - Note\nI'm a red note\n"))
35 })
36 })
37
38 Describe("Emitting a styled report", func() {
39 It("does include styling", func() {
40 Ω(measurement.ColorableString()).Should(Equal("{{red}}Test Experiment - Note\nI'm a red note\n{{/}}"))
41 })
42 })
43 })
44
45 Describe("Value Measurement", func() {
46 var min, median, mean, stdDev, max float64
47 BeforeEach(func() {
48 e.RecordValue("flange widths", 7.128, gmeasure.Annotation("A"), gmeasure.Precision(2), gmeasure.Units("inches"), gmeasure.Style("{{blue}}"))
49 e.RecordValue("flange widths", 3.141, gmeasure.Annotation("B"))
50 e.RecordValue("flange widths", 9.28223, gmeasure.Annotation("C"))
51 e.RecordValue("flange widths", 14.249, gmeasure.Annotation("D"))
52 e.RecordValue("flange widths", 8.975, gmeasure.Annotation("E"))
53 measurement = e.Measurements[0]
54 min = 3.141
55 max = 14.249
56 median = 8.975
57 mean = (7.128 + 3.141 + 9.28223 + 14.249 + 8.975) / 5.0
58 stdDev = (7.128-mean)*(7.128-mean) + (3.141-mean)*(3.141-mean) + (9.28223-mean)*(9.28223-mean) + (14.249-mean)*(14.249-mean) + (8.975-mean)*(8.975-mean)
59 stdDev = math.Sqrt(stdDev / 5.0)
60 })
61
62 Describe("Generating Stats", func() {
63 It("generates a correctly configured Stats with correct values", func() {
64 stats := measurement.Stats()
65 Ω(stats.ExperimentName).Should(Equal("Test Experiment"))
66 Ω(stats.MeasurementName).Should(Equal("flange widths"))
67 Ω(stats.Style).Should(Equal("{{blue}}"))
68 Ω(stats.Units).Should(Equal("inches"))
69 Ω(stats.PrecisionBundle.ValueFormat).Should(Equal("%.2f"))
70
71 Ω(stats.ValueBundle[gmeasure.StatMin]).Should(Equal(min))
72 Ω(stats.AnnotationBundle[gmeasure.StatMin]).Should(Equal("B"))
73 Ω(stats.ValueBundle[gmeasure.StatMax]).Should(Equal(max))
74 Ω(stats.AnnotationBundle[gmeasure.StatMax]).Should(Equal("D"))
75 Ω(stats.ValueBundle[gmeasure.StatMedian]).Should(Equal(median))
76 Ω(stats.ValueBundle[gmeasure.StatMean]).Should(Equal(mean))
77 Ω(stats.ValueBundle[gmeasure.StatStdDev]).Should(Equal(stdDev))
78 })
79 })
80
81 Describe("Emitting an unstyled report", func() {
82 It("does not include styling", func() {
83 expected := strings.Join([]string{
84 "Test Experiment - flange widths [inches]",
85 "3.14 < [8.97] | <8.56> ±3.59 < 14.25",
86 "Value | Annotation",
87 "==================",
88 " 7.13 | A ",
89 "------------------",
90 " 3.14 | B ",
91 "------------------",
92 " 9.28 | C ",
93 "------------------",
94 "14.25 | D ",
95 "------------------",
96 " 8.97 | E ",
97 "",
98 }, "\n")
99 Ω(measurement.String()).Should(Equal(expected))
100 })
101 })
102
103 Describe("Emitting a styled report", func() {
104 It("does include styling", func() {
105 expected := strings.Join([]string{
106 "{{blue}}Test Experiment - flange widths [inches]{{/}}",
107 "3.14 < [8.97] | <8.56> ±3.59 < 14.25",
108 "{{blue}}Value{{/}} | {{blue}}Annotation{{/}}",
109 "==================",
110 " 7.13 | {{gray}}A {{/}}",
111 "------------------",
112 " 3.14 | {{gray}}B {{/}}",
113 "------------------",
114 " 9.28 | {{gray}}C {{/}}",
115 "------------------",
116 "14.25 | {{gray}}D {{/}}",
117 "------------------",
118 " 8.97 | {{gray}}E {{/}}",
119 "",
120 }, "\n")
121 Ω(measurement.ColorableString()).Should(Equal(expected))
122 })
123 })
124
125 Describe("Computing medians", func() {
126 Context("with an odd number of values", func() {
127 It("returns the middle element", func() {
128 e.RecordValue("odd", 5)
129 e.RecordValue("odd", 1)
130 e.RecordValue("odd", 2)
131 e.RecordValue("odd", 4)
132 e.RecordValue("odd", 3)
133
134 Ω(e.GetStats("odd").ValueBundle[gmeasure.StatMedian]).Should(Equal(3.0))
135 })
136 })
137
138 Context("when an even number of values", func() {
139 It("returns the mean of the two middle elements", func() {
140 e.RecordValue("even", 1)
141 e.RecordValue("even", 2)
142 e.RecordValue("even", 4)
143 e.RecordValue("even", 3)
144
145 Ω(e.GetStats("even").ValueBundle[gmeasure.StatMedian]).Should(Equal(2.5))
146 })
147 })
148 })
149 })
150
151 Describe("Duration Measurement", func() {
152 var min, median, mean, stdDev, max time.Duration
153 BeforeEach(func() {
154 e.RecordDuration("runtime", 7128*time.Millisecond, gmeasure.Annotation("A"), gmeasure.Precision(time.Millisecond*100), gmeasure.Style("{{blue}}"))
155 e.RecordDuration("runtime", 3141*time.Millisecond, gmeasure.Annotation("B"))
156 e.RecordDuration("runtime", 9282*time.Millisecond, gmeasure.Annotation("C"))
157 e.RecordDuration("runtime", 14249*time.Millisecond, gmeasure.Annotation("D"))
158 e.RecordDuration("runtime", 8975*time.Millisecond, gmeasure.Annotation("E"))
159 measurement = e.Measurements[0]
160 min = 3141 * time.Millisecond
161 max = 14249 * time.Millisecond
162 median = 8975 * time.Millisecond
163 mean = ((7128 + 3141 + 9282 + 14249 + 8975) * time.Millisecond) / 5
164 stdDev = time.Duration(math.Sqrt((float64(7128*time.Millisecond-mean)*float64(7128*time.Millisecond-mean) + float64(3141*time.Millisecond-mean)*float64(3141*time.Millisecond-mean) + float64(9282*time.Millisecond-mean)*float64(9282*time.Millisecond-mean) + float64(14249*time.Millisecond-mean)*float64(14249*time.Millisecond-mean) + float64(8975*time.Millisecond-mean)*float64(8975*time.Millisecond-mean)) / 5.0))
165 })
166
167 Describe("Generating Stats", func() {
168 It("generates a correctly configured Stats with correct values", func() {
169 stats := measurement.Stats()
170 Ω(stats.ExperimentName).Should(Equal("Test Experiment"))
171 Ω(stats.MeasurementName).Should(Equal("runtime"))
172 Ω(stats.Style).Should(Equal("{{blue}}"))
173 Ω(stats.Units).Should(Equal("duration"))
174 Ω(stats.PrecisionBundle.Duration).Should(Equal(time.Millisecond * 100))
175
176 Ω(stats.DurationBundle[gmeasure.StatMin]).Should(Equal(min))
177 Ω(stats.AnnotationBundle[gmeasure.StatMin]).Should(Equal("B"))
178 Ω(stats.DurationBundle[gmeasure.StatMax]).Should(Equal(max))
179 Ω(stats.AnnotationBundle[gmeasure.StatMax]).Should(Equal("D"))
180 Ω(stats.DurationBundle[gmeasure.StatMedian]).Should(Equal(median))
181 Ω(stats.DurationBundle[gmeasure.StatMean]).Should(Equal(mean))
182 Ω(stats.DurationBundle[gmeasure.StatStdDev]).Should(Equal(stdDev))
183 })
184 })
185
186 Describe("Emitting an unstyled report", func() {
187 It("does not include styling", func() {
188 expected := strings.Join([]string{
189 "Test Experiment - runtime [duration]",
190 "3.1s < [9s] | <8.6s> ±3.6s < 14.2s",
191 "Duration | Annotation",
192 "=====================",
193 " 7.1s | A ",
194 "---------------------",
195 " 3.1s | B ",
196 "---------------------",
197 " 9.3s | C ",
198 "---------------------",
199 " 14.2s | D ",
200 "---------------------",
201 " 9s | E ",
202 "",
203 }, "\n")
204 Ω(measurement.String()).Should(Equal(expected))
205 })
206 })
207
208 Describe("Emitting a styled report", func() {
209 It("does include styling", func() {
210 expected := strings.Join([]string{
211 "{{blue}}Test Experiment - runtime [duration]{{/}}",
212 "3.1s < [9s] | <8.6s> ±3.6s < 14.2s",
213 "{{blue}}Duration{{/}} | {{blue}}Annotation{{/}}",
214 "=====================",
215 "{{blue}} 7.1s{{/}} | {{gray}}A {{/}}",
216 "---------------------",
217 "{{blue}} 3.1s{{/}} | {{gray}}B {{/}}",
218 "---------------------",
219 "{{blue}} 9.3s{{/}} | {{gray}}C {{/}}",
220 "---------------------",
221 "{{blue}} 14.2s{{/}} | {{gray}}D {{/}}",
222 "---------------------",
223 "{{blue}} 9s{{/}} | {{gray}}E {{/}}",
224 "",
225 }, "\n")
226 Ω(measurement.ColorableString()).Should(Equal(expected))
227 })
228 })
229
230 Describe("Computing medians", func() {
231 Context("with an odd number of values", func() {
232 It("returns the middle element", func() {
233 e.RecordDuration("odd", 5*time.Second)
234 e.RecordDuration("odd", 1*time.Second)
235 e.RecordDuration("odd", 2*time.Second)
236 e.RecordDuration("odd", 4*time.Second)
237 e.RecordDuration("odd", 3*time.Second)
238
239 Ω(e.GetStats("odd").DurationBundle[gmeasure.StatMedian]).Should(Equal(3 * time.Second))
240 })
241 })
242
243 Context("when an even number of values", func() {
244 It("returns the mean of the two middle elements", func() {
245 e.RecordDuration("even", 1*time.Second)
246 e.RecordDuration("even", 2*time.Second)
247 e.RecordDuration("even", 4*time.Second)
248 e.RecordDuration("even", 3*time.Second)
249
250 Ω(e.GetStats("even").DurationBundle[gmeasure.StatMedian]).Should(Equal(2500 * time.Millisecond))
251 })
252 })
253 })
254 })
255 })
0 package gmeasure
1
2 import (
3 "fmt"
4 "sort"
5
6 "github.com/onsi/gomega/gmeasure/table"
7 )
8
9 /*
10 RankingCriteria is an enum representing the criteria by which Stats should be ranked. The enum names should be self explanatory. e.g. LowerMeanIsBetter means that Stats with lower mean values are considered more beneficial, with the lowest mean being declared the "winner" .
11 */
12 type RankingCriteria uint
13
14 const (
15 LowerMeanIsBetter RankingCriteria = iota
16 HigherMeanIsBetter
17 LowerMedianIsBetter
18 HigherMedianIsBetter
19 LowerMinIsBetter
20 HigherMinIsBetter
21 LowerMaxIsBetter
22 HigherMaxIsBetter
23 )
24
25 var rcEnumSupport = newEnumSupport(map[uint]string{uint(LowerMeanIsBetter): "Lower Mean is Better", uint(HigherMeanIsBetter): "Higher Mean is Better", uint(LowerMedianIsBetter): "Lower Median is Better", uint(HigherMedianIsBetter): "Higher Median is Better", uint(LowerMinIsBetter): "Lower Mins is Better", uint(HigherMinIsBetter): "Higher Min is Better", uint(LowerMaxIsBetter): "Lower Max is Better", uint(HigherMaxIsBetter): "Higher Max is Better"})
26
27 func (s RankingCriteria) String() string { return rcEnumSupport.String(uint(s)) }
28 func (s *RankingCriteria) UnmarshalJSON(b []byte) error {
29 out, err := rcEnumSupport.UnmarshalJSON(b)
30 *s = RankingCriteria(out)
31 return err
32 }
33 func (s RankingCriteria) MarshalJSON() ([]byte, error) { return rcEnumSupport.MarshalJSON(uint(s)) }
34
35 /*
36 Ranking ranks a set of Stats by a specified RankingCritera. Use RankStats to create a Ranking.
37
38 When using Ginkgo, you can register Rankings as Report Entries via AddReportEntry. This will emit a formatted table representing the Stats in rank-order when Ginkgo generates the report.
39 */
40 type Ranking struct {
41 Criteria RankingCriteria
42 Stats []Stats
43 }
44
45 /*
46 RankStats creates a new ranking of the passed-in stats according to the passed-in criteria.
47 */
48 func RankStats(criteria RankingCriteria, stats ...Stats) Ranking {
49 sort.Slice(stats, func(i int, j int) bool {
50 switch criteria {
51 case LowerMeanIsBetter:
52 return stats[i].FloatFor(StatMean) < stats[j].FloatFor(StatMean)
53 case HigherMeanIsBetter:
54 return stats[i].FloatFor(StatMean) > stats[j].FloatFor(StatMean)
55 case LowerMedianIsBetter:
56 return stats[i].FloatFor(StatMedian) < stats[j].FloatFor(StatMedian)
57 case HigherMedianIsBetter:
58 return stats[i].FloatFor(StatMedian) > stats[j].FloatFor(StatMedian)
59 case LowerMinIsBetter:
60 return stats[i].FloatFor(StatMin) < stats[j].FloatFor(StatMin)
61 case HigherMinIsBetter:
62 return stats[i].FloatFor(StatMin) > stats[j].FloatFor(StatMin)
63 case LowerMaxIsBetter:
64 return stats[i].FloatFor(StatMax) < stats[j].FloatFor(StatMax)
65 case HigherMaxIsBetter:
66 return stats[i].FloatFor(StatMax) > stats[j].FloatFor(StatMax)
67 }
68 return false
69 })
70
71 out := Ranking{
72 Criteria: criteria,
73 Stats: stats,
74 }
75
76 return out
77 }
78
79 /*
80 Winner returns the Stats with the most optimal rank based on the specified ranking criteria. For example, if the RankingCriteria is LowerMaxIsBetter then the Stats with the lowest value or duration for StatMax will be returned as the "winner"
81 */
82 func (c Ranking) Winner() Stats {
83 if len(c.Stats) == 0 {
84 return Stats{}
85 }
86 return c.Stats[0]
87 }
88
89 func (c Ranking) report(enableStyling bool) string {
90 if len(c.Stats) == 0 {
91 return "Empty Ranking"
92 }
93 t := table.NewTable()
94 t.TableStyle.EnableTextStyling = enableStyling
95 t.AppendRow(table.R(
96 table.C("Experiment"), table.C("Name"), table.C("N"), table.C("Min"), table.C("Median"), table.C("Mean"), table.C("StdDev"), table.C("Max"),
97 table.Divider("="),
98 "{{bold}}",
99 ))
100
101 for idx, stats := range c.Stats {
102 name := stats.MeasurementName
103 if stats.Units != "" {
104 name = name + " [" + stats.Units + "]"
105 }
106 experimentName := stats.ExperimentName
107 style := stats.Style
108 if idx == 0 {
109 style = "{{bold}}" + style
110 name += "\n*Winner*"
111 experimentName += "\n*Winner*"
112 }
113 r := table.R(style)
114 t.AppendRow(r)
115 r.AppendCell(table.C(experimentName), table.C(name))
116 r.AppendCell(stats.cells()...)
117
118 }
119 out := fmt.Sprintf("Ranking Criteria: %s\n", c.Criteria)
120 if enableStyling {
121 out = "{{bold}}" + out + "{{/}}"
122 }
123 out += t.Render()
124 return out
125 }
126
127 /*
128 ColorableString generates a styled report that includes a table of the rank-ordered Stats
129 It is called automatically by Ginkgo's reporting infrastructure when the Ranking is registered as a ReportEntry via AddReportEntry.
130 */
131 func (c Ranking) ColorableString() string {
132 return c.report(true)
133 }
134
135 /*
136 String generates an unstyled report that includes a table of the rank-ordered Stats
137 */
138 func (c Ranking) String() string {
139 return c.report(false)
140 }
0 package gmeasure_test
1
2 import (
3 "strings"
4 "time"
5
6 . "github.com/onsi/ginkgo"
7 . "github.com/onsi/ginkgo/extensions/table"
8 . "github.com/onsi/gomega"
9 "github.com/onsi/gomega/gmeasure"
10 )
11
12 var _ = Describe("Rank", func() {
13 var A, B, C, D gmeasure.Stats
14
15 Describe("Ranking Values", func() {
16 makeStats := func(name string, min float64, max float64, mean float64, median float64) gmeasure.Stats {
17 return gmeasure.Stats{
18 Type: gmeasure.StatsTypeValue,
19 ExperimentName: "Exp-" + name,
20 MeasurementName: name,
21 N: 100,
22 PrecisionBundle: gmeasure.Precision(2),
23 ValueBundle: map[gmeasure.Stat]float64{
24 gmeasure.StatMin: min,
25 gmeasure.StatMax: max,
26 gmeasure.StatMean: mean,
27 gmeasure.StatMedian: median,
28 gmeasure.StatStdDev: 2.0,
29 },
30 }
31 }
32
33 BeforeEach(func() {
34 A = makeStats("A", 1, 2, 3, 4)
35 B = makeStats("B", 2, 3, 4, 1)
36 C = makeStats("C", 3, 4, 1, 2)
37 D = makeStats("D", 4, 1, 2, 3)
38 })
39
40 DescribeTable("ranking by criteria",
41 func(criteria gmeasure.RankingCriteria, expectedOrder func() []gmeasure.Stats) {
42 ranking := gmeasure.RankStats(criteria, A, B, C, D)
43 expected := expectedOrder()
44 Ω(ranking.Winner()).Should(Equal(expected[0]))
45 Ω(ranking.Stats).Should(Equal(expected))
46 },
47 Entry("entry", gmeasure.LowerMeanIsBetter, func() []gmeasure.Stats { return []gmeasure.Stats{C, D, A, B} }),
48 Entry("entry", gmeasure.HigherMeanIsBetter, func() []gmeasure.Stats { return []gmeasure.Stats{B, A, D, C} }),
49 Entry("entry", gmeasure.LowerMedianIsBetter, func() []gmeasure.Stats { return []gmeasure.Stats{B, C, D, A} }),
50 Entry("entry", gmeasure.HigherMedianIsBetter, func() []gmeasure.Stats { return []gmeasure.Stats{A, D, C, B} }),
51 Entry("entry", gmeasure.LowerMinIsBetter, func() []gmeasure.Stats { return []gmeasure.Stats{A, B, C, D} }),
52 Entry("entry", gmeasure.HigherMinIsBetter, func() []gmeasure.Stats { return []gmeasure.Stats{D, C, B, A} }),
53 Entry("entry", gmeasure.LowerMaxIsBetter, func() []gmeasure.Stats { return []gmeasure.Stats{D, A, B, C} }),
54 Entry("entry", gmeasure.HigherMaxIsBetter, func() []gmeasure.Stats { return []gmeasure.Stats{C, B, A, D} }),
55 )
56
57 Describe("Generating Reports", func() {
58 It("can generate an unstyled report", func() {
59 ranking := gmeasure.RankStats(gmeasure.LowerMeanIsBetter, A, B, C, D)
60 Ω(ranking.String()).Should(Equal(strings.Join([]string{
61 "Ranking Criteria: Lower Mean is Better",
62 "Experiment | Name | N | Min | Median | Mean | StdDev | Max ",
63 "==================================================================",
64 "Exp-C | C | 100 | 3.00 | 2.00 | 1.00 | 2.00 | 4.00",
65 "*Winner* | *Winner* | | | | | | ",
66 "------------------------------------------------------------------",
67 "Exp-D | D | 100 | 4.00 | 3.00 | 2.00 | 2.00 | 1.00",
68 "------------------------------------------------------------------",
69 "Exp-A | A | 100 | 1.00 | 4.00 | 3.00 | 2.00 | 2.00",
70 "------------------------------------------------------------------",
71 "Exp-B | B | 100 | 2.00 | 1.00 | 4.00 | 2.00 | 3.00",
72 "",
73 }, "\n")))
74 })
75
76 It("can generate a styled report", func() {
77 ranking := gmeasure.RankStats(gmeasure.LowerMeanIsBetter, A, B, C, D)
78 Ω(ranking.ColorableString()).Should(Equal(strings.Join([]string{
79 "{{bold}}Ranking Criteria: Lower Mean is Better",
80 "{{/}}{{bold}}Experiment{{/}} | {{bold}}Name {{/}} | {{bold}}N {{/}} | {{bold}}Min {{/}} | {{bold}}Median{{/}} | {{bold}}Mean{{/}} | {{bold}}StdDev{{/}} | {{bold}}Max {{/}}",
81 "==================================================================",
82 "{{bold}}Exp-C {{/}} | {{bold}}C {{/}} | {{bold}}100{{/}} | {{bold}}3.00{{/}} | {{bold}}2.00 {{/}} | {{bold}}1.00{{/}} | {{bold}}2.00 {{/}} | {{bold}}4.00{{/}}",
83 "{{bold}}*Winner* {{/}} | {{bold}}*Winner*{{/}} | | | | | | ",
84 "------------------------------------------------------------------",
85 "Exp-D | D | 100 | 4.00 | 3.00 | 2.00 | 2.00 | 1.00",
86 "------------------------------------------------------------------",
87 "Exp-A | A | 100 | 1.00 | 4.00 | 3.00 | 2.00 | 2.00",
88 "------------------------------------------------------------------",
89 "Exp-B | B | 100 | 2.00 | 1.00 | 4.00 | 2.00 | 3.00",
90 "",
91 }, "\n")))
92 })
93 })
94 })
95
96 Describe("Ranking Durations", func() {
97 makeStats := func(name string, min time.Duration, max time.Duration, mean time.Duration, median time.Duration) gmeasure.Stats {
98 return gmeasure.Stats{
99 Type: gmeasure.StatsTypeDuration,
100 ExperimentName: "Exp-" + name,
101 MeasurementName: name,
102 N: 100,
103 PrecisionBundle: gmeasure.Precision(time.Millisecond * 100),
104 DurationBundle: map[gmeasure.Stat]time.Duration{
105 gmeasure.StatMin: min,
106 gmeasure.StatMax: max,
107 gmeasure.StatMean: mean,
108 gmeasure.StatMedian: median,
109 gmeasure.StatStdDev: 2.0,
110 },
111 }
112 }
113
114 BeforeEach(func() {
115 A = makeStats("A", 1*time.Second, 2*time.Second, 3*time.Second, 4*time.Second)
116 B = makeStats("B", 2*time.Second, 3*time.Second, 4*time.Second, 1*time.Second)
117 C = makeStats("C", 3*time.Second, 4*time.Second, 1*time.Second, 2*time.Second)
118 D = makeStats("D", 4*time.Second, 1*time.Second, 2*time.Second, 3*time.Second)
119 })
120
121 DescribeTable("ranking by criteria",
122 func(criteria gmeasure.RankingCriteria, expectedOrder func() []gmeasure.Stats) {
123 ranking := gmeasure.RankStats(criteria, A, B, C, D)
124 expected := expectedOrder()
125 Ω(ranking.Winner()).Should(Equal(expected[0]))
126 Ω(ranking.Stats).Should(Equal(expected))
127 },
128 Entry("entry", gmeasure.LowerMeanIsBetter, func() []gmeasure.Stats { return []gmeasure.Stats{C, D, A, B} }),
129 Entry("entry", gmeasure.HigherMeanIsBetter, func() []gmeasure.Stats { return []gmeasure.Stats{B, A, D, C} }),
130 Entry("entry", gmeasure.LowerMedianIsBetter, func() []gmeasure.Stats { return []gmeasure.Stats{B, C, D, A} }),
131 Entry("entry", gmeasure.HigherMedianIsBetter, func() []gmeasure.Stats { return []gmeasure.Stats{A, D, C, B} }),
132 Entry("entry", gmeasure.LowerMinIsBetter, func() []gmeasure.Stats { return []gmeasure.Stats{A, B, C, D} }),
133 Entry("entry", gmeasure.HigherMinIsBetter, func() []gmeasure.Stats { return []gmeasure.Stats{D, C, B, A} }),
134 Entry("entry", gmeasure.LowerMaxIsBetter, func() []gmeasure.Stats { return []gmeasure.Stats{D, A, B, C} }),
135 Entry("entry", gmeasure.HigherMaxIsBetter, func() []gmeasure.Stats { return []gmeasure.Stats{C, B, A, D} }),
136 )
137
138 Describe("Generating Reports", func() {
139 It("can generate an unstyled report", func() {
140 ranking := gmeasure.RankStats(gmeasure.LowerMeanIsBetter, A, B, C, D)
141 Ω(ranking.String()).Should(Equal(strings.Join([]string{
142 "Ranking Criteria: Lower Mean is Better",
143 "Experiment | Name | N | Min | Median | Mean | StdDev | Max",
144 "================================================================",
145 "Exp-C | C | 100 | 3s | 2s | 1s | 0s | 4s ",
146 "*Winner* | *Winner* | | | | | | ",
147 "----------------------------------------------------------------",
148 "Exp-D | D | 100 | 4s | 3s | 2s | 0s | 1s ",
149 "----------------------------------------------------------------",
150 "Exp-A | A | 100 | 1s | 4s | 3s | 0s | 2s ",
151 "----------------------------------------------------------------",
152 "Exp-B | B | 100 | 2s | 1s | 4s | 0s | 3s ",
153 "",
154 }, "\n")))
155 })
156
157 It("can generate a styled report", func() {
158 ranking := gmeasure.RankStats(gmeasure.LowerMeanIsBetter, A, B, C, D)
159 Ω(ranking.ColorableString()).Should(Equal(strings.Join([]string{
160 "{{bold}}Ranking Criteria: Lower Mean is Better",
161 "{{/}}{{bold}}Experiment{{/}} | {{bold}}Name {{/}} | {{bold}}N {{/}} | {{bold}}Min{{/}} | {{bold}}Median{{/}} | {{bold}}Mean{{/}} | {{bold}}StdDev{{/}} | {{bold}}Max{{/}}",
162 "================================================================",
163 "{{bold}}Exp-C {{/}} | {{bold}}C {{/}} | {{bold}}100{{/}} | {{bold}}3s {{/}} | {{bold}}2s {{/}} | {{bold}}1s {{/}} | {{bold}}0s {{/}} | {{bold}}4s {{/}}",
164 "{{bold}}*Winner* {{/}} | {{bold}}*Winner*{{/}} | | | | | | ",
165 "----------------------------------------------------------------",
166 "Exp-D | D | 100 | 4s | 3s | 2s | 0s | 1s ",
167 "----------------------------------------------------------------",
168 "Exp-A | A | 100 | 1s | 4s | 3s | 0s | 2s ",
169 "----------------------------------------------------------------",
170 "Exp-B | B | 100 | 2s | 1s | 4s | 0s | 3s ",
171 "",
172 }, "\n")))
173 })
174 })
175 })
176
177 })
0 package gmeasure
1
2 import (
3 "fmt"
4 "time"
5
6 "github.com/onsi/gomega/gmeasure/table"
7 )
8
9 /*
10 Stat is an enum representing the statistics you can request of a Stats struct
11 */
12 type Stat uint
13
14 const (
15 StatInvalid Stat = iota
16 StatMin
17 StatMax
18 StatMean
19 StatMedian
20 StatStdDev
21 )
22
23 var statEnumSupport = newEnumSupport(map[uint]string{uint(StatInvalid): "INVALID STAT", uint(StatMin): "Min", uint(StatMax): "Max", uint(StatMean): "Mean", uint(StatMedian): "Median", uint(StatStdDev): "StdDev"})
24
25 func (s Stat) String() string { return statEnumSupport.String(uint(s)) }
26 func (s *Stat) UnmarshalJSON(b []byte) error {
27 out, err := statEnumSupport.UnmarshalJSON(b)
28 *s = Stat(out)
29 return err
30 }
31 func (s Stat) MarshalJSON() ([]byte, error) { return statEnumSupport.MarshalJSON(uint(s)) }
32
33 type StatsType uint
34
35 const (
36 StatsTypeInvalid StatsType = iota
37 StatsTypeValue
38 StatsTypeDuration
39 )
40
41 var statsTypeEnumSupport = newEnumSupport(map[uint]string{uint(StatsTypeInvalid): "INVALID STATS TYPE", uint(StatsTypeValue): "StatsTypeValue", uint(StatsTypeDuration): "StatsTypeDuration"})
42
43 func (s StatsType) String() string { return statsTypeEnumSupport.String(uint(s)) }
44 func (s *StatsType) UnmarshalJSON(b []byte) error {
45 out, err := statsTypeEnumSupport.UnmarshalJSON(b)
46 *s = StatsType(out)
47 return err
48 }
49 func (s StatsType) MarshalJSON() ([]byte, error) { return statsTypeEnumSupport.MarshalJSON(uint(s)) }
50
51 /*
52 Stats records the key statistics for a given measurement. You generally don't make Stats directly - but you can fetch them from Experiments using GetStats() and from Measurements using Stats().
53
54 When using Ginkgo, you can register Measurements as Report Entries via AddReportEntry. This will emit all the captured data points when Ginkgo generates the report.
55 */
56 type Stats struct {
57 // Type is the StatType - one of StatTypeDuration or StatTypeValue
58 Type StatsType
59
60 // ExperimentName is the name of the Experiment that recorded the Measurement from which this Stat is derived
61 ExperimentName string
62
63 // MeasurementName is the name of the Measurement from which this Stat is derived
64 MeasurementName string
65
66 // Units captures the Units of the Measurement from which this Stat is derived
67 Units string
68
69 // Style captures the Style of the Measurement from which this Stat is derived
70 Style string
71
72 // PrecisionBundle captures the precision to use when rendering data for this Measurement.
73 // If Type is StatTypeDuration then PrecisionBundle.Duration is used to round any durations before presentation.
74 // If Type is StatTypeValue then PrecisionBundle.ValueFormat is used to format any values before presentation
75 PrecisionBundle PrecisionBundle
76
77 // N represents the total number of data points in the Meassurement from which this Stat is derived
78 N int
79
80 // If Type is StatTypeValue, ValueBundle will be populated with float64s representing this Stat's statistics
81 ValueBundle map[Stat]float64
82
83 // If Type is StatTypeDuration, DurationBundle will be populated with float64s representing this Stat's statistics
84 DurationBundle map[Stat]time.Duration
85
86 // AnnotationBundle is populated with Annotations corresponding to the data points that can be associated with a Stat.
87 // For example AnnotationBundle[StatMin] will return the Annotation for the data point that has the minimum value/duration.
88 AnnotationBundle map[Stat]string
89 }
90
91 // String returns a minimal summary of the stats of the form "MIN < [MEDIAN] | <MEAN> ±STDDEV < MAX"
92 func (s Stats) String() string {
93 return fmt.Sprintf("%s < [%s] | <%s> ±%s < %s", s.StringFor(StatMin), s.StringFor(StatMedian), s.StringFor(StatMean), s.StringFor(StatStdDev), s.StringFor(StatMax))
94 }
95
96 // ValueFor returns the float64 value for a particular Stat. You should only use this if the Stats has Type StatsTypeValue
97 // For example:
98 //
99 // median := experiment.GetStats("length").ValueFor(gmeasure.StatMedian)
100 //
101 // will return the median data point for the "length" Measurement.
102 func (s Stats) ValueFor(stat Stat) float64 {
103 return s.ValueBundle[stat]
104 }
105
106 // DurationFor returns the time.Duration for a particular Stat. You should only use this if the Stats has Type StatsTypeDuration
107 // For example:
108 //
109 // mean := experiment.GetStats("runtime").ValueFor(gmeasure.StatMean)
110 //
111 // will return the mean duration for the "runtime" Measurement.
112 func (s Stats) DurationFor(stat Stat) time.Duration {
113 return s.DurationBundle[stat]
114 }
115
116 // FloatFor returns a float64 representation of the passed-in Stat.
117 // When Type is StatsTypeValue this is equivalent to s.ValueFor(stat).
118 // When Type is StatsTypeDuration this is equivalent to float64(s.DurationFor(stat))
119 func (s Stats) FloatFor(stat Stat) float64 {
120 switch s.Type {
121 case StatsTypeValue:
122 return s.ValueFor(stat)
123 case StatsTypeDuration:
124 return float64(s.DurationFor(stat))
125 }
126 return 0
127 }
128
129 // StringFor returns a formatted string representation of the passed-in Stat.
130 // The formatting honors the precision directives provided in stats.PrecisionBundle
131 func (s Stats) StringFor(stat Stat) string {
132 switch s.Type {
133 case StatsTypeValue:
134 return fmt.Sprintf(s.PrecisionBundle.ValueFormat, s.ValueFor(stat))
135 case StatsTypeDuration:
136 return s.DurationFor(stat).Round(s.PrecisionBundle.Duration).String()
137 }
138 return ""
139 }
140
141 func (s Stats) cells() []table.Cell {
142 out := []table.Cell{}
143 out = append(out, table.C(fmt.Sprintf("%d", s.N)))
144 for _, stat := range []Stat{StatMin, StatMedian, StatMean, StatStdDev, StatMax} {
145 content := s.StringFor(stat)
146 if s.AnnotationBundle[stat] != "" {
147 content += "\n" + s.AnnotationBundle[stat]
148 }
149 out = append(out, table.C(content))
150 }
151 return out
152 }
0 package gmeasure_test
1
2 import (
3 "time"
4
5 . "github.com/onsi/ginkgo"
6 . "github.com/onsi/gomega"
7 "github.com/onsi/gomega/gmeasure"
8 )
9
10 var _ = Describe("Stats", func() {
11 var stats gmeasure.Stats
12
13 Describe("Stats representing values", func() {
14 BeforeEach(func() {
15 stats = gmeasure.Stats{
16 Type: gmeasure.StatsTypeValue,
17 ExperimentName: "My Test Experiment",
18 MeasurementName: "Sprockets",
19 Units: "widgets",
20 N: 100,
21 PrecisionBundle: gmeasure.Precision(2),
22 ValueBundle: map[gmeasure.Stat]float64{
23 gmeasure.StatMin: 17.48992,
24 gmeasure.StatMax: 293.4820,
25 gmeasure.StatMean: 187.3023,
26 gmeasure.StatMedian: 87.2235,
27 gmeasure.StatStdDev: 73.6394,
28 },
29 }
30 })
31
32 Describe("String()", func() {
33 It("returns a one-line summary", func() {
34 Ω(stats.String()).Should(Equal("17.49 < [87.22] | <187.30> ±73.64 < 293.48"))
35 })
36 })
37
38 Describe("ValueFor()", func() {
39 It("returns the value for the requested stat", func() {
40 Ω(stats.ValueFor(gmeasure.StatMin)).Should(Equal(17.48992))
41 Ω(stats.ValueFor(gmeasure.StatMean)).Should(Equal(187.3023))
42 })
43 })
44
45 Describe("FloatFor", func() {
46 It("returns the requested stat as a float", func() {
47 Ω(stats.FloatFor(gmeasure.StatMin)).Should(Equal(17.48992))
48 Ω(stats.FloatFor(gmeasure.StatMean)).Should(Equal(187.3023))
49 })
50 })
51
52 Describe("StringFor", func() {
53 It("returns the requested stat rendered with the configured precision", func() {
54 Ω(stats.StringFor(gmeasure.StatMin)).Should(Equal("17.49"))
55 Ω(stats.StringFor(gmeasure.StatMean)).Should(Equal("187.30"))
56 })
57 })
58 })
59
60 Describe("Stats representing durations", func() {
61 BeforeEach(func() {
62 stats = gmeasure.Stats{
63 Type: gmeasure.StatsTypeDuration,
64 ExperimentName: "My Test Experiment",
65 MeasurementName: "Runtime",
66 N: 100,
67 PrecisionBundle: gmeasure.Precision(time.Millisecond * 100),
68 DurationBundle: map[gmeasure.Stat]time.Duration{
69 gmeasure.StatMin: 17375 * time.Millisecond,
70 gmeasure.StatMax: 890321 * time.Millisecond,
71 gmeasure.StatMean: 328712 * time.Millisecond,
72 gmeasure.StatMedian: 552390 * time.Millisecond,
73 gmeasure.StatStdDev: 186259 * time.Millisecond,
74 },
75 }
76 })
77 Describe("String()", func() {
78 It("returns a one-line summary", func() {
79 Ω(stats.String()).Should(Equal("17.4s < [9m12.4s] | <5m28.7s> ±3m6.3s < 14m50.3s"))
80 })
81 })
82 Describe("DurationFor()", func() {
83 It("returns the duration for the requested stat", func() {
84 Ω(stats.DurationFor(gmeasure.StatMin)).Should(Equal(17375 * time.Millisecond))
85 Ω(stats.DurationFor(gmeasure.StatMean)).Should(Equal(328712 * time.Millisecond))
86 })
87 })
88
89 Describe("FloatFor", func() {
90 It("returns the float64 representation for the requested duration stat", func() {
91 Ω(stats.FloatFor(gmeasure.StatMin)).Should(Equal(float64(17375 * time.Millisecond)))
92 Ω(stats.FloatFor(gmeasure.StatMean)).Should(Equal(float64(328712 * time.Millisecond)))
93 })
94 })
95
96 Describe("StringFor", func() {
97 It("returns the requested stat rendered with the configured precision", func() {
98 Ω(stats.StringFor(gmeasure.StatMin)).Should(Equal("17.4s"))
99 Ω(stats.StringFor(gmeasure.StatMean)).Should(Equal("5m28.7s"))
100 })
101 })
102 })
103 })
0 package gmeasure
1
2 import "time"
3
4 /*
5 Stopwatch provides a convenient abstraction for recording durations. There are two ways to make a Stopwatch:
6
7 You can make a Stopwatch from an Experiment via experiment.NewStopwatch(). This is how you first get a hold of a Stopwatch.
8
9 You can subsequently call stopwatch.NewStopwatch() to get a fresh Stopwatch.
10 This is only necessary if you need to record durations on a different goroutine as a single Stopwatch is not considered thread-safe.
11
12 The Stopwatch starts as soon as it is created. You can Pause() the stopwatch and Reset() it as needed.
13
14 Stopwatches refer back to their parent Experiment. They use this reference to record any measured durations back with the Experiment.
15 */
16 type Stopwatch struct {
17 Experiment *Experiment
18 t time.Time
19 pauseT time.Time
20 pauseDuration time.Duration
21 running bool
22 }
23
24 func newStopwatch(experiment *Experiment) *Stopwatch {
25 return &Stopwatch{
26 Experiment: experiment,
27 t: time.Now(),
28 running: true,
29 }
30 }
31
32 /*
33 NewStopwatch returns a new Stopwatch pointing to the same Experiment as this Stopwatch
34 */
35 func (s *Stopwatch) NewStopwatch() *Stopwatch {
36 return newStopwatch(s.Experiment)
37 }
38
39 /*
40 Record captures the amount of time that has passed since the Stopwatch was created or most recently Reset(). It records the duration on it's associated Experiment in a Measurement with the passed-in name.
41
42 Record takes all the decorators that experiment.RecordDuration takes (e.g. Annotation("...") can be used to annotate this duration)
43
44 Note that Record does not Reset the Stopwatch. It does, however, return the Stopwatch so the following pattern is common:
45
46 stopwatch := experiment.NewStopwatch()
47 // first expensive operation
48 stopwatch.Record("first operation").Reset() //records the duration of the first operation and resets the stopwatch.
49 // second expensive operation
50 stopwatch.Record("second operation").Reset() //records the duration of the second operation and resets the stopwatch.
51
52 omitting the Reset() after the first operation would cause the duration recorded for the second operation to include the time elapsed by both the first _and_ second operations.
53
54 The Stopwatch must be running (i.e. not paused) when Record is called.
55 */
56 func (s *Stopwatch) Record(name string, args ...interface{}) *Stopwatch {
57 if !s.running {
58 panic("stopwatch is not running - call Resume or Reset before calling Record")
59 }
60 duration := time.Since(s.t) - s.pauseDuration
61 s.Experiment.RecordDuration(name, duration, args...)
62 return s
63 }
64
65 /*
66 Reset resets the Stopwatch. Subsequent recorded durations will measure the time elapsed from the moment Reset was called.
67 If the Stopwatch was Paused it is unpaused after calling Reset.
68 */
69 func (s *Stopwatch) Reset() *Stopwatch {
70 s.running = true
71 s.t = time.Now()
72 s.pauseDuration = 0
73 return s
74 }
75
76 /*
77 Pause pauses the Stopwatch. While pasued the Stopwatch does not accumulate elapsed time. This is useful for ignoring expensive operations that are incidental to the behavior you are attempting to characterize.
78 Note: You must call Resume() before you can Record() subsequent measurements.
79
80 For example:
81
82 stopwatch := experiment.NewStopwatch()
83 // first expensive operation
84 stopwatch.Record("first operation").Reset()
85 // second expensive operation - part 1
86 stopwatch.Pause()
87 // something expensive that we don't care about
88 stopwatch.Resume()
89 // second expensive operation - part 2
90 stopwatch.Record("second operation").Reset() // the recorded duration captures the time elapsed during parts 1 and 2 of the second expensive operation, but not the bit in between
91
92
93 The Stopwatch must be running when Pause is called.
94 */
95 func (s *Stopwatch) Pause() *Stopwatch {
96 if !s.running {
97 panic("stopwatch is not running - call Resume or Reset before calling Pause")
98 }
99 s.running = false
100 s.pauseT = time.Now()
101 return s
102 }
103
104 /*
105 Resume resumes a paused Stopwatch. Any time that elapses after Resume is called will be accumulated as elapsed time when a subsequent duration is Recorded.
106
107 The Stopwatch must be Paused when Resume is called
108 */
109 func (s *Stopwatch) Resume() *Stopwatch {
110 if s.running {
111 panic("stopwatch is running - call Pause before calling Resume")
112 }
113 s.running = true
114 s.pauseDuration = s.pauseDuration + time.Since(s.pauseT)
115 return s
116 }
0 package gmeasure_test
1
2 import (
3 "time"
4
5 . "github.com/onsi/ginkgo"
6 . "github.com/onsi/gomega"
7 "github.com/onsi/gomega/gmeasure"
8 )
9
10 var _ = Describe("Stopwatch", func() {
11 var e *gmeasure.Experiment
12 var stopwatch *gmeasure.Stopwatch
13
14 BeforeEach(func() {
15 e = gmeasure.NewExperiment("My Test Experiment")
16 stopwatch = e.NewStopwatch()
17 })
18
19 It("records durations", func() {
20 time.Sleep(100 * time.Millisecond)
21 stopwatch.Record("recordings", gmeasure.Annotation("A"))
22 time.Sleep(100 * time.Millisecond)
23 stopwatch.Record("recordings", gmeasure.Annotation("B")).Reset()
24 time.Sleep(100 * time.Millisecond)
25 stopwatch.Record("recordings", gmeasure.Annotation("C")).Reset()
26 time.Sleep(100 * time.Millisecond)
27 stopwatch.Pause()
28 time.Sleep(100 * time.Millisecond)
29 stopwatch.Resume()
30 time.Sleep(100 * time.Millisecond)
31 stopwatch.Pause()
32 time.Sleep(100 * time.Millisecond)
33 stopwatch.Resume()
34 time.Sleep(100 * time.Millisecond)
35 stopwatch.Record("recordings", gmeasure.Annotation("D"))
36 durations := e.Get("recordings").Durations
37 annotations := e.Get("recordings").Annotations
38 Ω(annotations).Should(Equal([]string{"A", "B", "C", "D"}))
39 Ω(durations[0]).Should(BeNumerically("~", 100*time.Millisecond, 50*time.Millisecond))
40 Ω(durations[1]).Should(BeNumerically("~", 200*time.Millisecond, 50*time.Millisecond))
41 Ω(durations[2]).Should(BeNumerically("~", 100*time.Millisecond, 50*time.Millisecond))
42 Ω(durations[3]).Should(BeNumerically("~", 300*time.Millisecond, 50*time.Millisecond))
43
44 })
45
46 It("panics when asked to record but not running", func() {
47 stopwatch.Pause()
48 Ω(func() {
49 stopwatch.Record("A")
50 }).Should(PanicWith("stopwatch is not running - call Resume or Reset before calling Record"))
51 })
52
53 It("panics when paused but not running", func() {
54 stopwatch.Pause()
55 Ω(func() {
56 stopwatch.Pause()
57 }).Should(PanicWith("stopwatch is not running - call Resume or Reset before calling Pause"))
58 })
59
60 It("panics when asked to resume but not paused", func() {
61 Ω(func() {
62 stopwatch.Resume()
63 }).Should(PanicWith("stopwatch is running - call Pause before calling Resume"))
64 })
65 })
0 package table
1
2 // This is a temporary package - Table will move to github.com/onsi/consolable once some more dust settles
3
4 import (
5 "reflect"
6 "strings"
7 "unicode/utf8"
8 )
9
10 type AlignType uint
11
12 const (
13 AlignTypeLeft AlignType = iota
14 AlignTypeCenter
15 AlignTypeRight
16 )
17
18 type Divider string
19
20 type Row struct {
21 Cells []Cell
22 Divider string
23 Style string
24 }
25
26 func R(args ...interface{}) *Row {
27 r := &Row{
28 Divider: "-",
29 }
30 for _, arg := range args {
31 switch reflect.TypeOf(arg) {
32 case reflect.TypeOf(Divider("")):
33 r.Divider = string(arg.(Divider))
34 case reflect.TypeOf(r.Style):
35 r.Style = arg.(string)
36 case reflect.TypeOf(Cell{}):
37 r.Cells = append(r.Cells, arg.(Cell))
38 }
39 }
40 return r
41 }
42
43 func (r *Row) AppendCell(cells ...Cell) *Row {
44 r.Cells = append(r.Cells, cells...)
45 return r
46 }
47
48 func (r *Row) Render(widths []int, totalWidth int, tableStyle TableStyle, isLastRow bool) string {
49 out := ""
50 if len(r.Cells) == 1 {
51 out += strings.Join(r.Cells[0].render(totalWidth, r.Style, tableStyle), "\n") + "\n"
52 } else {
53 if len(r.Cells) != len(widths) {
54 panic("row vs width mismatch")
55 }
56 renderedCells := make([][]string, len(r.Cells))
57 maxHeight := 0
58 for colIdx, cell := range r.Cells {
59 renderedCells[colIdx] = cell.render(widths[colIdx], r.Style, tableStyle)
60 if len(renderedCells[colIdx]) > maxHeight {
61 maxHeight = len(renderedCells[colIdx])
62 }
63 }
64 for colIdx := range r.Cells {
65 for len(renderedCells[colIdx]) < maxHeight {
66 renderedCells[colIdx] = append(renderedCells[colIdx], strings.Repeat(" ", widths[colIdx]))
67 }
68 }
69 border := strings.Repeat(" ", tableStyle.Padding)
70 if tableStyle.VerticalBorders {
71 border += "|" + border
72 }
73 for lineIdx := 0; lineIdx < maxHeight; lineIdx++ {
74 for colIdx := range r.Cells {
75 out += renderedCells[colIdx][lineIdx]
76 if colIdx < len(r.Cells)-1 {
77 out += border
78 }
79 }
80 out += "\n"
81 }
82 }
83 if tableStyle.HorizontalBorders && !isLastRow && r.Divider != "" {
84 out += strings.Repeat(string(r.Divider), totalWidth) + "\n"
85 }
86
87 return out
88 }
89
90 type Cell struct {
91 Contents []string
92 Style string
93 Align AlignType
94 }
95
96 func C(contents string, args ...interface{}) Cell {
97 c := Cell{
98 Contents: strings.Split(contents, "\n"),
99 }
100 for _, arg := range args {
101 switch reflect.TypeOf(arg) {
102 case reflect.TypeOf(c.Style):
103 c.Style = arg.(string)
104 case reflect.TypeOf(c.Align):
105 c.Align = arg.(AlignType)
106 }
107 }
108 return c
109 }
110
111 func (c Cell) Width() (int, int) {
112 w, minW := 0, 0
113 for _, line := range c.Contents {
114 lineWidth := utf8.RuneCountInString(line)
115 if lineWidth > w {
116 w = lineWidth
117 }
118 for _, word := range strings.Split(line, " ") {
119 wordWidth := utf8.RuneCountInString(word)
120 if wordWidth > minW {
121 minW = wordWidth
122 }
123 }
124 }
125 return w, minW
126 }
127
128 func (c Cell) alignLine(line string, width int) string {
129 lineWidth := utf8.RuneCountInString(line)
130 if lineWidth == width {
131 return line
132 }
133 if lineWidth < width {
134 gap := width - lineWidth
135 switch c.Align {
136 case AlignTypeLeft:
137 return line + strings.Repeat(" ", gap)
138 case AlignTypeRight:
139 return strings.Repeat(" ", gap) + line
140 case AlignTypeCenter:
141 leftGap := gap / 2
142 rightGap := gap - leftGap
143 return strings.Repeat(" ", leftGap) + line + strings.Repeat(" ", rightGap)
144 }
145 }
146 return line
147 }
148
149 func (c Cell) splitWordToWidth(word string, width int) []string {
150 out := []string{}
151 n, subWord := 0, ""
152 for _, c := range word {
153 subWord += string(c)
154 n += 1
155 if n == width-1 {
156 out = append(out, subWord+"-")
157 n, subWord = 0, ""
158 }
159 }
160 return out
161 }
162
163 func (c Cell) splitToWidth(line string, width int) []string {
164 lineWidth := utf8.RuneCountInString(line)
165 if lineWidth <= width {
166 return []string{line}
167 }
168
169 outLines := []string{}
170 words := strings.Split(line, " ")
171 outWords := []string{words[0]}
172 length := utf8.RuneCountInString(words[0])
173 if length > width {
174 splitWord := c.splitWordToWidth(words[0], width)
175 lastIdx := len(splitWord) - 1
176 outLines = append(outLines, splitWord[:lastIdx]...)
177 outWords = []string{splitWord[lastIdx]}
178 length = utf8.RuneCountInString(splitWord[lastIdx])
179 }
180
181 for _, word := range words[1:] {
182 wordLength := utf8.RuneCountInString(word)
183 if length+wordLength+1 <= width {
184 length += wordLength + 1
185 outWords = append(outWords, word)
186 continue
187 }
188 outLines = append(outLines, strings.Join(outWords, " "))
189
190 outWords = []string{word}
191 length = wordLength
192 if length > width {
193 splitWord := c.splitWordToWidth(word, width)
194 lastIdx := len(splitWord) - 1
195 outLines = append(outLines, splitWord[:lastIdx]...)
196 outWords = []string{splitWord[lastIdx]}
197 length = utf8.RuneCountInString(splitWord[lastIdx])
198 }
199 }
200 if len(outWords) > 0 {
201 outLines = append(outLines, strings.Join(outWords, " "))
202 }
203
204 return outLines
205 }
206
207 func (c Cell) render(width int, style string, tableStyle TableStyle) []string {
208 out := []string{}
209 for _, line := range c.Contents {
210 out = append(out, c.splitToWidth(line, width)...)
211 }
212 for idx := range out {
213 out[idx] = c.alignLine(out[idx], width)
214 }
215
216 if tableStyle.EnableTextStyling {
217 style = style + c.Style
218 if style != "" {
219 for idx := range out {
220 out[idx] = style + out[idx] + "{{/}}"
221 }
222 }
223 }
224
225 return out
226 }
227
228 type TableStyle struct {
229 Padding int
230 VerticalBorders bool
231 HorizontalBorders bool
232 MaxTableWidth int
233 MaxColWidth int
234 EnableTextStyling bool
235 }
236
237 var DefaultTableStyle = TableStyle{
238 Padding: 1,
239 VerticalBorders: true,
240 HorizontalBorders: true,
241 MaxTableWidth: 120,
242 MaxColWidth: 40,
243 EnableTextStyling: true,
244 }
245
246 type Table struct {
247 Rows []*Row
248
249 TableStyle TableStyle
250 }
251
252 func NewTable() *Table {
253 return &Table{
254 TableStyle: DefaultTableStyle,
255 }
256 }
257
258 func (t *Table) AppendRow(row *Row) *Table {
259 t.Rows = append(t.Rows, row)
260 return t
261 }
262
263 func (t *Table) Render() string {
264 out := ""
265 totalWidth, widths := t.computeWidths()
266 for rowIdx, row := range t.Rows {
267 out += row.Render(widths, totalWidth, t.TableStyle, rowIdx == len(t.Rows)-1)
268 }
269 return out
270 }
271
272 func (t *Table) computeWidths() (int, []int) {
273 nCol := 0
274 for _, row := range t.Rows {
275 if len(row.Cells) > nCol {
276 nCol = len(row.Cells)
277 }
278 }
279
280 // lets compute the contribution to width from the borders + padding
281 borderWidth := t.TableStyle.Padding
282 if t.TableStyle.VerticalBorders {
283 borderWidth += 1 + t.TableStyle.Padding
284 }
285 totalBorderWidth := borderWidth * (nCol - 1)
286
287 // lets compute the width of each column
288 widths := make([]int, nCol)
289 minWidths := make([]int, nCol)
290 for colIdx := range widths {
291 for _, row := range t.Rows {
292 if colIdx >= len(row.Cells) {
293 // ignore rows with fewer columns
294 continue
295 }
296 w, minWid := row.Cells[colIdx].Width()
297 if w > widths[colIdx] {
298 widths[colIdx] = w
299 }
300 if minWid > minWidths[colIdx] {
301 minWidths[colIdx] = minWid
302 }
303 }
304 }
305
306 // do we already fit?
307 if sum(widths)+totalBorderWidth <= t.TableStyle.MaxTableWidth {
308 // yes! we're done
309 return sum(widths) + totalBorderWidth, widths
310 }
311
312 // clamp the widths and minWidths to MaxColWidth
313 for colIdx := range widths {
314 widths[colIdx] = min(widths[colIdx], t.TableStyle.MaxColWidth)
315 minWidths[colIdx] = min(minWidths[colIdx], t.TableStyle.MaxColWidth)
316 }
317
318 // do we fit now?
319 if sum(widths)+totalBorderWidth <= t.TableStyle.MaxTableWidth {
320 // yes! we're done
321 return sum(widths) + totalBorderWidth, widths
322 }
323
324 // hmm... still no... can we possibly squeeze the table in without violating minWidths?
325 if sum(minWidths)+totalBorderWidth >= t.TableStyle.MaxTableWidth {
326 // nope - we're just going to have to exceed MaxTableWidth
327 return sum(minWidths) + totalBorderWidth, minWidths
328 }
329
330 // looks like we don't fit yet, but we should be able to fit without violating minWidths
331 // lets start scaling down
332 n := 0
333 for sum(widths)+totalBorderWidth > t.TableStyle.MaxTableWidth {
334 budget := t.TableStyle.MaxTableWidth - totalBorderWidth
335 baseline := sum(widths)
336
337 for colIdx := range widths {
338 widths[colIdx] = max((widths[colIdx]*budget)/baseline, minWidths[colIdx])
339 }
340 n += 1
341 if n > 100 {
342 break // in case we somehow fail to converge
343 }
344 }
345
346 return sum(widths) + totalBorderWidth, widths
347 }
348
349 func sum(s []int) int {
350 out := 0
351 for _, v := range s {
352 out += v
353 }
354 return out
355 }
356
357 func min(a int, b int) int {
358 if a < b {
359 return a
360 }
361 return b
362 }
363
364 func max(a int, b int) int {
365 if a > b {
366 return a
367 }
368 return b
369 }