| # Copyright 2017 The PDFium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| """Classes that draw conclusions out of a comparison and represent them.""" |
| |
| from collections import Counter |
| |
| FORMAT_RED = '\033[01;31m{0}\033[00m' |
| FORMAT_GREEN = '\033[01;32m{0}\033[00m' |
| FORMAT_MAGENTA = '\033[01;35m{0}\033[00m' |
| FORMAT_CYAN = '\033[01;36m{0}\033[00m' |
| FORMAT_NORMAL = '{0}' |
| |
| RATING_FAILURE = 'failure' |
| RATING_REGRESSION = 'regression' |
| RATING_IMPROVEMENT = 'improvement' |
| RATING_NO_CHANGE = 'no_change' |
| RATING_SMALL_CHANGE = 'small_change' |
| |
| RATINGS = [ |
| RATING_FAILURE, RATING_REGRESSION, RATING_IMPROVEMENT, RATING_NO_CHANGE, |
| RATING_SMALL_CHANGE |
| ] |
| |
| RATING_TO_COLOR = { |
| RATING_FAILURE: FORMAT_MAGENTA, |
| RATING_REGRESSION: FORMAT_RED, |
| RATING_IMPROVEMENT: FORMAT_CYAN, |
| RATING_NO_CHANGE: FORMAT_GREEN, |
| RATING_SMALL_CHANGE: FORMAT_NORMAL, |
| } |
| |
| |
| class ComparisonConclusions(object): |
| """All conclusions drawn from a comparison. |
| |
| This is initialized empty and then processes pairs of results for each test |
| case, determining the rating for that case, which can be: |
| "failure" if either or both runs for the case failed. |
| "regression" if there is a significant increase in time for the test case. |
| "improvement" if there is a significant decrease in time for the test case. |
| "no_change" if the time for the test case did not change at all. |
| "small_change" if the time for the test case changed but within the threshold. |
| """ |
| |
| def __init__(self, threshold_significant): |
| """Initializes an empty ComparisonConclusions. |
| |
| Args: |
| threshold_significant: Float with the tolerance beyond which changes in |
| measurements are considered significant. |
| |
| The change is considered as a multiplication rather than an addition |
| of a fraction of the previous measurement, that is, a |
| threshold_significant of 1.0 will flag test cases that became over |
| 100% slower (> 200% of the previous time measured) or over 100% faster |
| (< 50% of the previous time measured). |
| |
| threshold_significant 0.02 -> 98.04% to 102% is not significant |
| threshold_significant 0.1 -> 90.9% to 110% is not significant |
| threshold_significant 0.25 -> 80% to 125% is not significant |
| threshold_significant 1 -> 50% to 200% is not significant |
| threshold_significant 4 -> 20% to 500% is not significant |
| |
| """ |
| self.threshold_significant = threshold_significant |
| self.threshold_significant_negative = (1 / (1 + threshold_significant)) - 1 |
| |
| self.params = {'threshold': threshold_significant} |
| self.summary = ComparisonSummary() |
| self.case_results = {} |
| |
| def ProcessCase(self, case_name, before, after): |
| """Feeds a test case results to the ComparisonConclusions. |
| |
| Args: |
| case_name: String identifying the case. |
| before: Measurement for the "before" version of the code. |
| after: Measurement for the "after" version of the code. |
| """ |
| |
| # Switch 0 to None to simplify the json dict output. All zeros are |
| # considered failed runs, so they will be represented by "null". |
| if not before: |
| before = None |
| if not after: |
| after = None |
| |
| if not before or not after: |
| ratio = None |
| rating = RATING_FAILURE |
| else: |
| ratio = (float(after) / before) - 1.0 |
| if ratio > self.threshold_significant: |
| rating = RATING_REGRESSION |
| elif ratio < self.threshold_significant_negative: |
| rating = RATING_IMPROVEMENT |
| elif ratio == 0: |
| rating = RATING_NO_CHANGE |
| else: |
| rating = RATING_SMALL_CHANGE |
| |
| case_result = CaseResult(case_name, before, after, ratio, rating) |
| |
| self.summary.ProcessCaseResult(case_result) |
| self.case_results[case_name] = case_result |
| |
| def GetSummary(self): |
| """Gets the ComparisonSummary with consolidated totals.""" |
| return self.summary |
| |
| def GetCaseResults(self): |
| """Gets a dict mapping each test case identifier to its CaseResult.""" |
| return self.case_results |
| |
| def GetOutputDict(self): |
| """Returns a conclusions dict with all the conclusions drawn. |
| |
| Returns: |
| A serializable dict with the format illustrated below: |
| { |
| "version": 1, |
| "params": { |
| "threshold": 0.02 |
| }, |
| "summary": { |
| "total": 123, |
| "failure": 1, |
| "regression": 2, |
| "improvement": 1, |
| "no_change": 100, |
| "small_change": 19 |
| }, |
| "comparison_by_case": { |
| "testing/resources/new_test.pdf": { |
| "before": None, |
| "after": 1000, |
| "ratio": None, |
| "rating": "failure" |
| }, |
| "testing/resources/test1.pdf": { |
| "before": 100, |
| "after": 120, |
| "ratio": 0.2, |
| "rating": "regression" |
| }, |
| "testing/resources/test2.pdf": { |
| "before": 100, |
| "after": 2000, |
| "ratio": 19.0, |
| "rating": "regression" |
| }, |
| "testing/resources/test3.pdf": { |
| "before": 1000, |
| "after": 1005, |
| "ratio": 0.005, |
| "rating": "small_change" |
| }, |
| "testing/resources/test4.pdf": { |
| "before": 1000, |
| "after": 1000, |
| "ratio": 0.0, |
| "rating": "no_change" |
| }, |
| "testing/resources/test5.pdf": { |
| "before": 1000, |
| "after": 600, |
| "ratio": -0.4, |
| "rating": "improvement" |
| } |
| } |
| } |
| """ |
| output_dict = {} |
| output_dict['version'] = 1 |
| output_dict['params'] = {'threshold': self.threshold_significant} |
| output_dict['summary'] = self.summary.GetOutputDict() |
| output_dict['comparison_by_case'] = { |
| cr.case_name.decode('utf-8'): cr.GetOutputDict() |
| for cr in self.GetCaseResults().values() |
| } |
| return output_dict |
| |
| |
| class ComparisonSummary(object): |
| """Totals computed for a comparison.""" |
| |
| def __init__(self): |
| self.rating_counter = Counter() |
| |
| def ProcessCaseResult(self, case_result): |
| self.rating_counter[case_result.rating] += 1 |
| |
| def GetTotal(self): |
| """Gets the number of test cases processed.""" |
| return sum(self.rating_counter.values()) |
| |
| def GetCount(self, rating): |
| """Gets the number of test cases processed with a given rating.""" |
| return self.rating_counter[rating] |
| |
| def GetOutputDict(self): |
| """Returns a dict that can be serialized with all the totals.""" |
| result = {'total': self.GetTotal()} |
| for rating in RATINGS: |
| result[rating] = self.GetCount(rating) |
| return result |
| |
| |
| class CaseResult(object): |
| """The conclusion for the comparison of a single test case.""" |
| |
| def __init__(self, case_name, before, after, ratio, rating): |
| """Initializes an empty ComparisonConclusions. |
| |
| Args: |
| case_name: String identifying the case. |
| before: Measurement for the "before" version of the code. |
| after: Measurement for the "after" version of the code. |
| ratio: Difference between |after| and |before| as a fraction of |before|. |
| rating: Rating for this test case. |
| """ |
| self.case_name = case_name |
| self.before = before |
| self.after = after |
| self.ratio = ratio |
| self.rating = rating |
| |
| def GetOutputDict(self): |
| """Returns a dict with the test case's conclusions.""" |
| return { |
| 'before': self.before, |
| 'after': self.after, |
| 'ratio': self.ratio, |
| 'rating': self.rating |
| } |
| |
| |
| def PrintConclusionsDictHumanReadable(conclusions_dict, colored, key=None): |
| """Prints a conclusions dict in a human-readable way. |
| |
| Args: |
| conclusions_dict: Dict to print. |
| colored: Whether to color the output to highlight significant changes. |
| key: String with the CaseResult dictionary key to sort the cases. |
| """ |
| # Print header |
| print '=' * 80 |
| print '{0:>11s} {1:>15s} {2}'.format('% Change', 'Time after', 'Test case') |
| print '-' * 80 |
| |
| color = FORMAT_NORMAL |
| |
| # Print cases |
| if key is not None: |
| case_pairs = sorted( |
| conclusions_dict['comparison_by_case'].iteritems(), |
| key=lambda kv: kv[1][key]) |
| else: |
| case_pairs = sorted(conclusions_dict['comparison_by_case'].iteritems()) |
| |
| for case_name, case_dict in case_pairs: |
| if colored: |
| color = RATING_TO_COLOR[case_dict['rating']] |
| |
| if case_dict['rating'] == RATING_FAILURE: |
| print u'{} to measure time for {}'.format( |
| color.format('Failed'), case_name).encode('utf-8') |
| continue |
| |
| print u'{0} {1:15,d} {2}'.format( |
| color.format('{:+11.4%}'.format(case_dict['ratio'])), |
| case_dict['after'], case_name).encode('utf-8') |
| |
| # Print totals |
| totals = conclusions_dict['summary'] |
| print '=' * 80 |
| print 'Test cases run: %d' % totals['total'] |
| |
| if colored: |
| color = FORMAT_MAGENTA if totals[RATING_FAILURE] else FORMAT_GREEN |
| print('Failed to measure: %s' % color.format(totals[RATING_FAILURE])) |
| |
| if colored: |
| color = FORMAT_RED if totals[RATING_REGRESSION] else FORMAT_GREEN |
| print('Regressions: %s' % color.format(totals[RATING_REGRESSION])) |
| |
| if colored: |
| color = FORMAT_CYAN if totals[RATING_IMPROVEMENT] else FORMAT_GREEN |
| print('Improvements: %s' % color.format(totals[RATING_IMPROVEMENT])) |