diff options
author | rspangler@google.com <rspangler@google.com@0039d316-1c4b-4281-b951-d872f2087c98> | 2009-05-13 18:31:04 +0000 |
---|---|---|
committer | rspangler@google.com <rspangler@google.com@0039d316-1c4b-4281-b951-d872f2087c98> | 2009-05-13 18:31:04 +0000 |
commit | 916225c4298662c51f7a3218e283da7816d5844d (patch) | |
tree | b88e01cb8c90289604b5c0363a7b26af78c9da25 /tools | |
parent | 616d628496d7940cecfe5b42a73215cbbbff9f1d (diff) | |
download | chromium_src-916225c4298662c51f7a3218e283da7816d5844d.zip chromium_src-916225c4298662c51f7a3218e283da7816d5844d.tar.gz chromium_src-916225c4298662c51f7a3218e283da7816d5844d.tar.bz2 |
Add croc code coverage tool. (Same change as yesterday, but now made in the
writable repository)
Review URL: http://codereview.chromium.org/113346
git-svn-id: svn://svn.chromium.org/chrome/trunk/src@15974 0039d316-1c4b-4281-b951-d872f2087c98
Diffstat (limited to 'tools')
-rw-r--r-- | tools/code_coverage/croc.py | 669 | ||||
-rw-r--r-- | tools/code_coverage/croc_test.py | 694 |
2 files changed, 1363 insertions, 0 deletions
diff --git a/tools/code_coverage/croc.py b/tools/code_coverage/croc.py new file mode 100644 index 0000000..b2bd33b --- /dev/null +++ b/tools/code_coverage/croc.py @@ -0,0 +1,669 @@ +#!/usr/bin/python2.4 +# +# Copyright 2009, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Crocodile - compute coverage numbers for Chrome coverage dashboard.""" + +import os +import re +import sys +from optparse import OptionParser + + +class CoverageError(Exception): + """Coverage error.""" + +class CoverageStatError(CoverageError): + """Error evaluating coverage stat.""" + +#------------------------------------------------------------------------------ + + +class CoverageStats(dict): + """Coverage statistics.""" + + def Add(self, coverage_stats): + """Adds a contribution from another coverage stats dict. + + Args: + coverage_stats: Statistics to add to this one. + """ + for k, v in coverage_stats.iteritems(): + if k in self: + self[k] = self[k] + v + else: + self[k] = v + +#------------------------------------------------------------------------------ + + +class CoveredFile(object): + """Information about a single covered file.""" + + def __init__(self, filename, group, language): + """Constructor. + + Args: + filename: Full path to file, '/'-delimited. + group: Group file belongs to. + language: Language for file. + """ + self.filename = filename + self.group = group + self.language = language + + # No coverage data for file yet + self.lines = {} # line_no -> None=executable, 0=instrumented, 1=covered + self.stats = CoverageStats() + + def UpdateCoverage(self): + """Updates the coverage summary based on covered lines.""" + exe = instr = cov = 0 + for l in self.lines.itervalues(): + exe += 1 + if l is not None: + instr += 1 + if l == 1: + cov += 1 + + # Add stats that always exist + self.stats = CoverageStats(lines_executable=exe, + lines_instrumented=instr, + lines_covered=cov, + files_executable=1) + + # Add conditional stats + if cov: + self.stats['files_covered'] = 1 + if instr: + self.stats['files_instrumented'] = 1 + + +#------------------------------------------------------------------------------ + + +class CoveredDir(object): + """Information about a directory containing covered files.""" + + def __init__(self, dirpath): + """Constructor. + + Args: + dirpath: Full path of directory, '/'-delimited. + """ + self.dirpath = dirpath + + # List of covered files directly in this dir, indexed by filename (not + # full path) + self.files = {} + + # List of subdirs, indexed by filename (not full path) + self.subdirs = {} + + # Dict of CoverageStats objects summarizing all children, indexed by group + self.stats_by_group = {'all':CoverageStats()} + # TODO: by language + + def GetTree(self, indent=''): + """Recursively gets stats for the directory and its children. + + Args: + indent: indent prefix string. + + Returns: + The tree as a string. + """ + dest = [] + + # Compile all groupstats + groupstats = [] + for group in sorted(self.stats_by_group): + s = self.stats_by_group[group] + if not s.get('lines_executable'): + continue # Skip groups with no executable lines + groupstats.append('%s:%d/%d/%d' % ( + group, s.get('lines_covered', 0), + s.get('lines_instrumented', 0), + s.get('lines_executable', 0))) + + outline = '%s%-30s %s' % (indent, + self.dirpath + '/', ' '.join(groupstats)) + dest.append(outline.rstrip()) + + for d in sorted(self.subdirs): + dest.append(self.subdirs[d].GetTree(indent=indent + ' ')) + + return '\n'.join(dest) + +#------------------------------------------------------------------------------ + + +class Coverage(object): + """Code coverage for a group of files.""" + + def __init__(self): + """Constructor.""" + self.files = {} # Map filename --> CoverageFile + self.root_dirs = [] # (root, altname) + self.rules = [] # (regexp, include, group, language) + self.tree = CoveredDir('') + self.print_stats = [] # Dicts of args to PrintStat() + + self.add_files_walk = os.walk # Walk function for AddFiles() + + # Must specify subdir rule, or AddFiles() won't find any files because it + # will prune out all the subdirs. Since subdirs never match any code, + # they won't be reported in other stats, so this is ok. + self.AddRule('.*/$', language='subdir') + + + def CleanupFilename(self, filename): + """Cleans up a filename. + + Args: + filename: Input filename. + + Returns: + The cleaned up filename. + + Changes all path separators to '/'. + Makes relative paths (those starting with '../' or './' absolute. + Replaces all instances of root dirs with alternate names. + """ + # Change path separators + filename = filename.replace('\\', '/') + + # If path is relative, make it absolute + # TODO: Perhaps we should default to relative instead, and only understand + # absolute to be files starting with '\', '/', or '[A-Za-z]:'? + if filename.split('/')[0] in ('.', '..'): + filename = os.path.abspath(filename).replace('\\', '/') + + # Replace alternate roots + for root, alt_name in self.root_dirs: + filename = re.sub('^' + re.escape(root) + '(?=(/|$))', + alt_name, filename) + return filename + + def ClassifyFile(self, filename): + """Applies rules to a filename, to see if we care about it. + + Args: + filename: Input filename. + + Returns: + (None, None) if the file is not included or has no group or has no + language. Otherwise, a 2-tuple containing: + The group for the file (for example, 'source' or 'test'). + The language of the file. + """ + include = False + group = None + language = None + + # Process all rules + for regexp, rule_include, rule_group, rule_language in self.rules: + if regexp.match(filename): + # include/exclude source + if rule_include is not None: + include = rule_include + if rule_group is not None: + group = rule_group + if rule_language is not None: + language = rule_language + + # TODO: Should have a debug mode which prints files which aren't excluded + # and why (explicitly excluded, no type, no language, etc.) + + # TODO: Files can belong to multiple groups? + # (test/source) + # (mac/pc/win) + # (media_test/all_tests) + # (small/med/large) + # How to handle that? + + # Return classification if the file is included and has a group and + # language + if include and group and language: + return group, language + else: + return None, None + + def AddRoot(self, root_path, alt_name='#'): + """Adds a root directory. + + Args: + root_path: Root directory to add. + alt_name: If specified, name of root dir + """ + # Clean up root path based on existing rules + self.root_dirs.append([self.CleanupFilename(root_path), alt_name]) + + def AddRule(self, path_regexp, include=None, group=None, language=None): + """Adds a rule. + + Args: + path_regexp: Regular expression to match for filenames. These are + matched after root directory replacement. + include: If True, includes matches; if False, excludes matches. Ignored + if None. + group: If not None, sets group to apply to matches. + language: If not None, sets file language to apply to matches. + """ + # Compile regexp ahead of time + self.rules.append([re.compile(path_regexp), include, group, language]) + + def GetCoveredFile(self, filename, add=False): + """Gets the CoveredFile object for the filename. + + Args: + filename: Name of file to find. + add: If True, will add the file if it's not present. This applies the + transformations from AddRoot() and AddRule(), and only adds the file + if a rule includes it, and it has a group and language. + + Returns: + The matching CoveredFile object, or None if not present. + """ + # Clean filename + filename = self.CleanupFilename(filename) + + # Check for existing match + if filename in self.files: + return self.files[filename] + + # File isn't one we know about. If we can't add it, give up. + if not add: + return None + + # Check rules to see if file can be added + group, language = self.ClassifyFile(filename) + if not group: + return None + + # Add the file + f = CoveredFile(filename, group, language) + self.files[filename] = f + + # Return the newly covered file + return f + + def ParseLcovData(self, lcov_data): + """Adds coverage from LCOV-formatted data. + + Args: + lcov_data: An iterable returning lines of data in LCOV format. For + example, a file or list of strings. + """ + cov_file = None + cov_lines = None + for line in lcov_data: + line = line.strip() + if line.startswith('SF:'): + # Start of data for a new file; payload is filename + cov_file = self.GetCoveredFile(line[3:], add=True) + if cov_file: + cov_lines = cov_file.lines + elif not cov_file: + # Inside data for a file we don't care about - so skip it + pass + elif line.startswith('DA:'): + # Data point - that is, an executable line in current file + line_no, is_covered = map(int, line[3:].split(',')) + if is_covered: + # Line is covered + cov_lines[line_no] = 1 + elif cov_lines.get(line_no) != 1: + # Line is not covered, so track it as uncovered + cov_lines[line_no] = 0 + elif line == 'end_of_record': + cov_file.UpdateCoverage() + cov_file = None + # (else ignore other line types) + + def ParseLcovFile(self, input_filename): + """Adds coverage data from a .lcov file. + + Args: + input_filename: Input filename. + """ + # TODO: All manner of error checking + lcov_file = None + try: + lcov_file = open(input_filename, 'rt') + self.ParseLcovData(lcov_file) + finally: + if lcov_file: + lcov_file.close() + + def GetStat(self, stat, group='all', default=None): + """Gets a statistic from the coverage object. + + Args: + stat: Statistic to get. May also be an evaluatable python expression, + using the stats. For example, 'stat1 - stat2'. + group: File group to match; if 'all', matches all groups. + default: Value to return if there was an error evaluating the stat. For + example, if the stat does not exist. If None, raises + CoverageStatError. + + Returns: + The evaluated stat, or None if error. + + Raises: + CoverageStatError: Error evaluating stat. + """ + # TODO: specify a subdir to get the stat from, then walk the tree to + # print the stats from just that subdir + + # Make sure the group exists + if group not in self.tree.stats_by_group: + if default is None: + raise CoverageStatError('Group %r not found.' % group) + else: + return default + + stats = self.tree.stats_by_group[group] + try: + return eval(stat, {'__builtins__':{'S':self.GetStat}}, stats) + except Exception, e: + if default is None: + raise CoverageStatError('Error evaluating stat %r: %s' % (stat, e)) + else: + return default + + def PrintStat(self, stat, format=None, outfile=sys.stdout, **kwargs): + """Prints a statistic from the coverage object. + + Args: + stat: Statistic to get. May also be an evaluatable python expression, + using the stats. For example, 'stat1 - stat2'. + format: Format string to use when printing stat. If None, prints the + stat and its evaluation. + outfile: File stream to output stat to; defaults to stdout. + kwargs: Additional args to pass to GetStat(). + """ + s = self.GetStat(stat, **kwargs) + if format is None: + outfile.write('GetStat(%r) = %s\n' % (stat, s)) + else: + outfile.write(format % s + '\n') + + def AddFiles(self, src_dir): + """Adds files to coverage information. + + LCOV files only contains files which are compiled and instrumented as part + of running coverage. This function finds missing files and adds them. + + Args: + src_dir: Directory on disk at which to start search. May be a relative + path on disk starting with '.' or '..', or an absolute path, or a + path relative to an alt_name for one of the roots + (for example, '#/src'). If the alt_name matches more than one root, + all matches will be attempted. + + Note that dirs not underneath one of the root dirs and covered by an + inclusion rule will be ignored. + """ + # Check for root dir alt_names in the path and replace with the actual + # root dirs, then recurse. + found_root = False + for root, alt_name in self.root_dirs: + replaced_root = re.sub('^' + re.escape(alt_name) + '(?=(/|$))', root, + src_dir) + if replaced_root != src_dir: + found_root = True + self.AddFiles(replaced_root) + if found_root: + return # Replaced an alt_name with a root_dir, so already recursed. + + for (dirpath, dirnames, filenames) in self.add_files_walk(src_dir): + # Make a copy of the dirnames list so we can modify the original to + # prune subdirs we don't need to walk. + for d in list(dirnames): + # Add trailing '/' to directory names so dir-based regexps can match + # '/' instead of needing to specify '(/|$)'. + dpath = self.CleanupFilename(dirpath + '/' + d) + '/' + group, language = self.ClassifyFile(dpath) + if not group: + # Directory has been excluded, so don't traverse it + # TODO: Document the slight weirdness caused by this: If you + # AddFiles('./A'), and the rules include 'A/B/C/D' but not 'A/B', + # then it won't recurse into './A/B' so won't find './A/B/C/D'. + # Workarounds are to AddFiles('./A/B/C/D') or AddFiles('./A/B/C'). + # The latter works because it explicitly walks the contents of the + # path passed to AddFiles(), so it finds './A/B/C/D'. + dirnames.remove(d) + + for f in filenames: + covf = self.GetCoveredFile(dirpath + '/' + f, add=True) + # TODO: scan files for executable lines. Add these to the file as + # 'executable', but not 'instrumented' or 'covered'. + # TODO: if a file has no executable lines, don't add it. + if covf: + covf.UpdateCoverage() + + def AddConfig(self, config_data, lcov_queue=None, addfiles_queue=None): + """Adds JSON-ish config data. + + Args: + config_data: Config data string. + lcov_queue: If not None, object to append lcov_files to instead of + parsing them immediately. + addfiles_queue: If not None, object to append add_files to instead of + processing them immediately. + """ + # TODO: All manner of error checking + cfg = eval(config_data, {'__builtins__':{}}, {}) + + for rootdict in cfg.get('roots', []): + self.AddRoot(rootdict['root'], alt_name=rootdict.get('altname', '#')) + + for ruledict in cfg.get('rules', []): + self.AddRule(ruledict['regexp'], + include=ruledict.get('include'), + group=ruledict.get('group'), + language=ruledict.get('language')) + + for add_lcov in cfg.get('lcov_files', []): + if lcov_queue is not None: + lcov_queue.append(add_lcov) + else: + self.ParseLcovFile(add_lcov) + + for add_path in cfg.get('add_files', []): + if addfiles_queue is not None: + addfiles_queue.append(add_path) + else: + self.AddFiles(add_path) + + self.print_stats += cfg.get('print_stats', []) + + def ParseConfig(self, filename, **kwargs): + """Parses a configuration file. + + Args: + filename: Config filename. + """ + # TODO: All manner of error checking + f = None + try: + f = open(filename, 'rt') + # Need to strip CR's from CRLF-terminated lines or posix systems can't + # eval the data. + config_data = f.read().replace('\r\n', '\n') + # TODO: some sort of include syntax. Needs to be done at string-time + # rather than at eval()-time, so that it's possible to include parts of + # dicts. Path from a file to its include should be relative to the dir + # containing the file. + self.AddConfig(config_data, **kwargs) + finally: + if f: + f.close() + + def UpdateTreeStats(self): + """Recalculates the tree stats from the currently covered files. + + Also calculates coverage summary for files.""" + self.tree = CoveredDir('') + for cov_file in self.files.itervalues(): + # Add the file to the tree + # TODO: Don't really need to create the tree unless we're creating HTML + fdirs = cov_file.filename.split('/') + parent = self.tree + ancestors = [parent] + for d in fdirs[:-1]: + if d not in parent.subdirs: + parent.subdirs[d] = CoveredDir(d) + parent = parent.subdirs[d] + ancestors.append(parent) + # Final subdir actually contains the file + parent.files[fdirs[-1]] = cov_file + + # Now add file's contribution to coverage by dir + for a in ancestors: + # Add to 'all' group + a.stats_by_group['all'].Add(cov_file.stats) + + # Add to group file belongs to + if cov_file.group not in a.stats_by_group: + a.stats_by_group[cov_file.group] = CoverageStats() + cbyg = a.stats_by_group[cov_file.group] + cbyg.Add(cov_file.stats) + + def PrintTree(self): + """Prints the tree stats.""" + # Print the tree + print 'Lines of code coverage by directory:' + print self.tree.GetTree() + +#------------------------------------------------------------------------------ + + +def Main(argv): + """Main routine. + + Args: + argv: list of arguments + + Returns: + exit code, 0 for normal exit. + """ + # Parse args + parser = OptionParser() + parser.add_option( + '-i', '--input', dest='inputs', type='string', action='append', + metavar='FILE', + help='read LCOV input from FILE') + parser.add_option( + '-r', '--root', dest='roots', type='string', action='append', + metavar='ROOT[=ALTNAME]', + help='add ROOT directory, optionally map in coverage results as ALTNAME') + parser.add_option( + '-c', '--config', dest='configs', type='string', action='append', + metavar='FILE', + help='read settings from configuration FILE') + parser.add_option( + '-a', '--addfiles', dest='addfiles', type='string', action='append', + metavar='PATH', + help='add files from PATH to coverage data') + parser.add_option( + '-t', '--tree', dest='tree', action='store_true', + help='print tree of code coverage by group') + parser.add_option( + '-u', '--uninstrumented', dest='uninstrumented', action='store_true', + help='list uninstrumented files') + + parser.set_defaults( + inputs=[], + roots=[], + configs=[], + addfiles=[], + tree=False, + ) + + (options, args) = parser.parse_args() + + cov = Coverage() + + # Set root directories for coverage + for root_opt in options.roots: + if '=' in root_opt: + cov.AddRoot(*root_opt.split('=')) + else: + cov.AddRoot(root_opt) + + # Read config files + for config_file in options.configs: + cov.ParseConfig(config_file, lcov_queue=options.inputs, + addfiles_queue=options.addfiles) + + # Parse lcov files + for input_filename in options.inputs: + cov.ParseLcovFile(input_filename) + + # Add missing files + for add_path in options.addfiles: + cov.AddFiles(add_path) + + # Print help if no files specified + if not cov.files: + print 'No covered files found.' + parser.print_help() + return 1 + + # Update tree stats + cov.UpdateTreeStats() + + # Print uninstrumented filenames + if options.uninstrumented: + print 'Uninstrumented files:' + for f in sorted(cov.files): + covf = cov.files[f] + if not covf.stats.get('lines_instrumented'): + print ' %-6s %-6s %s' % (covf.group, covf.language, f) + + + # Print tree stats + if options.tree: + cov.PrintTree() + + # Print stats + for ps_args in cov.print_stats: + cov.PrintStat(**ps_args) + + # Normal exit + return 0 + + +#------------------------------------------------------------------------------ + +if __name__ == '__main__': + sys.exit(Main(sys.argv)) diff --git a/tools/code_coverage/croc_test.py b/tools/code_coverage/croc_test.py new file mode 100644 index 0000000..028521c --- /dev/null +++ b/tools/code_coverage/croc_test.py @@ -0,0 +1,694 @@ +#!/usr/bin/python2.4 +# +# Copyright 2009, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +"""Unit tests for Crocodile.""" + +import os +import re +import sys +import StringIO +import unittest +import croc + +#------------------------------------------------------------------------------ + +class TestCoverageStats(unittest.TestCase): + """Tests for croc.CoverageStats.""" + + def testAdd(self): + """Test Add().""" + c = croc.CoverageStats() + + # Initially empty + self.assertEqual(c, {}) + + # Add items + c['a'] = 1 + c['b'] = 0 + self.assertEqual(c, {'a':1, 'b':0}) + + # Add dict with non-overlapping items + c.Add({'c':5}) + self.assertEqual(c, {'a':1, 'b':0, 'c':5}) + + # Add dict with overlapping items + c.Add({'a':4, 'd':3}) + self.assertEqual(c, {'a':5, 'b':0, 'c':5, 'd':3}) + +#------------------------------------------------------------------------------ + +class TestCoveredFile(unittest.TestCase): + """Tests for croc.CoveredFile.""" + + def setUp(self): + self.cov_file = croc.CoveredFile('bob.cc', 'source', 'C++') + + def testInit(self): + """Test init.""" + f = self.cov_file + + # Check initial values + self.assertEqual(f.filename, 'bob.cc') + self.assertEqual(f.group, 'source') + self.assertEqual(f.language, 'C++') + self.assertEqual(f.lines, {}) + self.assertEqual(f.stats, {}) + + def testUpdateCoverageEmpty(self): + """Test updating coverage when empty.""" + f = self.cov_file + f.UpdateCoverage() + self.assertEqual(f.stats, { + 'lines_executable':0, + 'lines_instrumented':0, + 'lines_covered':0, + 'files_executable':1, + }) + + def testUpdateCoverageExeOnly(self): + """Test updating coverage when no lines are instrumented.""" + f = self.cov_file + f.lines = {1:None, 2:None, 4:None} + f.UpdateCoverage() + self.assertEqual(f.stats, { + 'lines_executable':3, + 'lines_instrumented':0, + 'lines_covered':0, + 'files_executable':1, + }) + + def testUpdateCoverageExeAndInstr(self): + """Test updating coverage when no lines are covered.""" + f = self.cov_file + f.lines = {1:None, 2:None, 4:0, 5:0, 7:None} + f.UpdateCoverage() + self.assertEqual(f.stats, { + 'lines_executable':5, + 'lines_instrumented':2, + 'lines_covered':0, + 'files_executable':1, + 'files_instrumented':1, + }) + + def testUpdateCoverageWhenCovered(self): + """Test updating coverage when lines are covered.""" + f = self.cov_file + f.lines = {1:None, 2:None, 3:1, 4:0, 5:0, 6:1, 7:None} + f.UpdateCoverage() + self.assertEqual(f.stats, { + 'lines_executable':7, + 'lines_instrumented':4, + 'lines_covered':2, + 'files_executable':1, + 'files_instrumented':1, + 'files_covered':1, + }) + +#------------------------------------------------------------------------------ + +class TestCoveredDir(unittest.TestCase): + """Tests for croc.CoveredDir.""" + + def setUp(self): + self.cov_dir = croc.CoveredDir('/a/b/c') + + def testInit(self): + """Test init.""" + d = self.cov_dir + + # Check initial values + self.assertEqual(d.dirpath, '/a/b/c') + self.assertEqual(d.files, {}) + self.assertEqual(d.subdirs, {}) + self.assertEqual(d.stats_by_group, {'all':{}}) + + def testGetTreeEmpty(self): + """Test getting empty tree.""" + d = self.cov_dir + self.assertEqual(d.GetTree(), '/a/b/c/') + + def testGetTreeStats(self): + """Test getting tree with stats.""" + d = self.cov_dir + d.stats_by_group['all'] = croc.CoverageStats( + lines_executable=50, lines_instrumented=30, lines_covered=20) + d.stats_by_group['bar'] = croc.CoverageStats( + lines_executable=0, lines_instrumented=0, lines_covered=0) + d.stats_by_group['foo'] = croc.CoverageStats( + lines_executable=33, lines_instrumented=22, lines_covered=11) + # 'bar' group is skipped because it has no executable lines + self.assertEqual(d.GetTree(), + '/a/b/c/ all:20/30/50 foo:11/22/33') + + def testGetTreeSubdir(self): + """Test getting tree with subdirs.""" + d1 = self.cov_dir = croc.CoveredDir('/a') + d2 = self.cov_dir = croc.CoveredDir('/a/b') + d3 = self.cov_dir = croc.CoveredDir('/a/c') + d4 = self.cov_dir = croc.CoveredDir('/a/b/d') + d5 = self.cov_dir = croc.CoveredDir('/a/b/e') + d1.subdirs = {'/a/b':d2, '/a/c':d3} + d2.subdirs = {'/a/b/d':d4, '/a/b/e':d5} + self.assertEqual(d1.GetTree(), + '/a/\n /a/b/\n /a/b/d/\n /a/b/e/\n /a/c/') + +#------------------------------------------------------------------------------ + +class TestCoverage(unittest.TestCase): + """Tests for croc.Coverage.""" + + def MockWalk(self, src_dir): + """Mock for os.walk(). + + Args: + src_dir: Source directory to walk. + + Returns: + A list of (dirpath, dirnames, filenames) tuples. + """ + self.mock_walk_calls.append(src_dir) + return self.mock_walk_return + + def setUp(self): + """Per-test setup""" + + # Empty coverage object + self.cov = croc.Coverage() + + # Coverage object with minimal setup + self.cov_minimal = croc.Coverage() + self.cov_minimal.AddRoot('/src') + self.cov_minimal.AddRoot('c:\\source') + self.cov_minimal.AddRule('^#/', include=1, group='my') + self.cov_minimal.AddRule('.*\\.c$', language='C') + self.cov_minimal.AddRule('.*\\.c##$', language='C##') # sharper than thou + + # Data for MockWalk() + self.mock_walk_calls = [] + self.mock_walk_return = [] + + def testInit(self): + """Test init.""" + c = self.cov + self.assertEqual(c.files, {}) + self.assertEqual(c.root_dirs, []) + self.assertEqual(c.print_stats, []) + + # Check for the initial subdir rule + self.assertEqual(len(c.rules), 1) + r0 = c.rules[0] + self.assertEqual(r0[0].pattern, '.*/$') + self.assertEqual(r0[1:], [None, None, 'subdir']) + + def testAddRoot(self): + """Test AddRoot() and CleanupFilename().""" + c = self.cov + + # Check for identity on already-clean filenames + self.assertEqual(c.CleanupFilename(''), '') + self.assertEqual(c.CleanupFilename('a'), 'a') + self.assertEqual(c.CleanupFilename('.a'), '.a') + self.assertEqual(c.CleanupFilename('..a'), '..a') + self.assertEqual(c.CleanupFilename('a.b'), 'a.b') + self.assertEqual(c.CleanupFilename('a/b/c'), 'a/b/c') + self.assertEqual(c.CleanupFilename('a/b/c/'), 'a/b/c/') + + # Backslash to forward slash + self.assertEqual(c.CleanupFilename('a\\b\\c'), 'a/b/c') + + # Handle relative paths + self.assertEqual(c.CleanupFilename('.'), + c.CleanupFilename(os.path.abspath('.'))) + self.assertEqual(c.CleanupFilename('..'), + c.CleanupFilename(os.path.abspath('..'))) + self.assertEqual(c.CleanupFilename('./foo/bar'), + c.CleanupFilename(os.path.abspath('./foo/bar'))) + self.assertEqual(c.CleanupFilename('../../a/b/c'), + c.CleanupFilename(os.path.abspath('../../a/b/c'))) + + # Replace alt roots + c.AddRoot('foo', '#') + self.assertEqual(c.CleanupFilename('foo'), '#') + self.assertEqual(c.CleanupFilename('foo/bar/baz'), '#/bar/baz') + self.assertEqual(c.CleanupFilename('aaa/foo'), 'aaa/foo') + + # Alt root replacement is applied for all roots + c.AddRoot('foo/bar', '#B') + self.assertEqual(c.CleanupFilename('foo/bar/baz'), '#B/baz') + + # Can use previously defined roots in cleanup + c.AddRoot('#/nom/nom/nom', '#CANHAS') + self.assertEqual(c.CleanupFilename('foo/nom/nom/nom/cheezburger'), + '#CANHAS/cheezburger') + + # Verify roots starting with UNC paths or drive letters work, and that + # more than one root can point to the same alt_name + c.AddRoot('/usr/local/foo', '#FOO') + c.AddRoot('D:\\my\\foo', '#FOO') + self.assertEqual(c.CleanupFilename('/usr/local/foo/a/b'), '#FOO/a/b') + self.assertEqual(c.CleanupFilename('D:\\my\\foo\\c\\d'), '#FOO/c/d') + + def testAddRule(self): + """Test AddRule() and ClassifyFile().""" + c = self.cov + + # With only the default rule, nothing gets kept + self.assertEqual(c.ClassifyFile('#/src/'), (None, None)) + self.assertEqual(c.ClassifyFile('#/src/a.c'), (None, None)) + + # Add rules to include a tree and set a default group + c.AddRule('^#/src/', include=1, group='source') + # Now the subdir matches, but source doesn't, since no languages are + # defined yet + self.assertEqual(c.ClassifyFile('#/src/'), ('source', 'subdir')) + self.assertEqual(c.ClassifyFile('#/notsrc/'), (None, None)) + self.assertEqual(c.ClassifyFile('#/src/a.c'), (None, None)) + + # Define some languages and groups + c.AddRule('.*\\.(c|h)$', language='C') + c.AddRule('.*\\.py$', language='Python') + c.AddRule('.*_test\\.', group='test') + self.assertEqual(c.ClassifyFile('#/src/a.c'), ('source', 'C')) + self.assertEqual(c.ClassifyFile('#/src/a.h'), ('source', 'C')) + self.assertEqual(c.ClassifyFile('#/src/a.cpp'), (None, None)) + self.assertEqual(c.ClassifyFile('#/src/a_test.c'), ('test', 'C')) + self.assertEqual(c.ClassifyFile('#/src/test_a.c'), ('source', 'C')) + self.assertEqual(c.ClassifyFile('#/src/foo/bar.py'), ('source', 'Python')) + self.assertEqual(c.ClassifyFile('#/src/test.py'), ('source', 'Python')) + + # Exclude a path (for example, anything in a build output dir) + c.AddRule('.*/build/', include=0) + # But add back in a dir which matched the above rule but isn't a build + # output dir + c.AddRule('#/src/tools/build/', include=1) + self.assertEqual(c.ClassifyFile('#/src/build.c'), ('source', 'C')) + self.assertEqual(c.ClassifyFile('#/src/build/'), (None, None)) + self.assertEqual(c.ClassifyFile('#/src/build/a.c'), (None, None)) + self.assertEqual(c.ClassifyFile('#/src/tools/build/'), ('source', 'subdir')) + self.assertEqual(c.ClassifyFile('#/src/tools/build/t.c'), ('source', 'C')) + + def testGetCoveredFile(self): + """Test GetCoveredFile().""" + c = self.cov_minimal + + # Not currently any covered files + self.assertEqual(c.GetCoveredFile('#/a.c'), None) + + # Add some files + a_c = c.GetCoveredFile('#/a.c', add=True) + b_c = c.GetCoveredFile('#/b.c##', add=True) + self.assertEqual(a_c.filename, '#/a.c') + self.assertEqual(a_c.group, 'my') + self.assertEqual(a_c.language, 'C') + self.assertEqual(b_c.filename, '#/b.c##') + self.assertEqual(b_c.group, 'my') + self.assertEqual(b_c.language, 'C##') + + # Specifying the same filename should return the existing object + self.assertEqual(c.GetCoveredFile('#/a.c'), a_c) + self.assertEqual(c.GetCoveredFile('#/a.c', add=True), a_c) + + # Filenames get cleaned on the way in, as do root paths + self.assertEqual(c.GetCoveredFile('/src/a.c'), a_c) + self.assertEqual(c.GetCoveredFile('c:\\source\\a.c'), a_c) + + def testParseLcov(self): + """Test ParseLcovData().""" + c = self.cov_minimal + + c.ParseLcovData([ + '# Ignore unknown lines', + # File we should include' + 'SF:/src/a.c', + 'DA:10,1', + 'DA:11,0', + 'DA:12,1 \n', # Trailing whitespace should get stripped + 'end_of_record', + # File we should ignore + 'SF:/not_src/a.c', + 'DA:20,1', + 'end_of_record', + # Same as first source file, but alternate root + 'SF:c:\\source\\a.c', + 'DA:30,1', + 'end_of_record', + # Ignore extra end of record + 'end_of_record', + # Ignore data points after end of record + 'DA:40,1', + # Instrumented but uncovered file + 'SF:/src/b.c', + 'DA:50,0', + 'end_of_record', + ]) + + # We should know about two files + self.assertEqual(sorted(c.files), ['#/a.c', '#/b.c']) + + # Check expected contents + a_c = c.GetCoveredFile('#/a.c') + self.assertEqual(a_c.lines, {10: 1, 11: 0, 12: 1, 30: 1}) + self.assertEqual(a_c.stats, { + 'files_executable': 1, + 'files_instrumented': 1, + 'files_covered': 1, + 'lines_instrumented': 4, + 'lines_executable': 4, + 'lines_covered': 3, + }) + b_c = c.GetCoveredFile('#/b.c') + self.assertEqual(b_c.lines, {50: 0}) + self.assertEqual(b_c.stats, { + 'files_executable': 1, + 'files_instrumented': 1, + 'lines_instrumented': 1, + 'lines_executable': 1, + 'lines_covered': 0, + }) + + def testGetStat(self): + """Test GetStat() and PrintStat().""" + c = self.cov + + # Add some stats, so there's something to report + c.tree.stats_by_group = { + 'all': { + 'count_a': 10, + 'count_b': 4, + 'foo': 'bar', + }, + 'tests': { + 'count_a': 2, + 'count_b': 5, + 'baz': 'bob', + }, + } + + # Test missing stats and groups + self.assertRaises(croc.CoverageStatError, c.GetStat, 'nosuch') + self.assertRaises(croc.CoverageStatError, c.GetStat, 'baz') + self.assertRaises(croc.CoverageStatError, c.GetStat, 'foo', group='tests') + self.assertRaises(croc.CoverageStatError, c.GetStat, 'foo', group='nosuch') + + # Test returning defaults + self.assertEqual(c.GetStat('nosuch', default=13), 13) + self.assertEqual(c.GetStat('baz', default='aaa'), 'aaa') + self.assertEqual(c.GetStat('foo', group='tests', default=0), 0) + self.assertEqual(c.GetStat('foo', group='nosuch', default=''), '') + + # Test getting stats + self.assertEqual(c.GetStat('count_a'), 10) + self.assertEqual(c.GetStat('count_a', group='tests'), 2) + self.assertEqual(c.GetStat('foo', default='baz'), 'bar') + + # Test stat math (eval) + self.assertEqual(c.GetStat('count_a - count_b'), 6) + self.assertEqual(c.GetStat('100.0 * count_a / count_b', group='tests'), + 40.0) + # Should catch eval errors + self.assertRaises(croc.CoverageStatError, c.GetStat, '100 / 0') + self.assertRaises(croc.CoverageStatError, c.GetStat, 'count_a -') + + # Test nested stats via S() + self.assertEqual(c.GetStat('count_a - S("count_a", group="tests")'), 8) + self.assertRaises(croc.CoverageStatError, c.GetStat, 'S()') + self.assertRaises(croc.CoverageStatError, c.GetStat, 'S("nosuch")') + + # Test PrintStat() + # We won't see the first print, but at least verify it doesn't assert + c.PrintStat('count_a', format='(test to stdout: %s)') + # Send subsequent prints to a file + f = StringIO.StringIO() + c.PrintStat('count_b', outfile=f) + # Test specifying output format + c.PrintStat('count_a', format='Count A = %05d', outfile=f) + # Test specifing additional keyword args + c.PrintStat('count_a', group='tests', outfile=f) + c.PrintStat('nosuch', default=42, outfile=f) + self.assertEqual(f.getvalue(), ("""\ +GetStat('count_b') = 4 +Count A = 00010 +GetStat('count_a') = 2 +GetStat('nosuch') = 42 +""")) + f.close() + + def testAddConfigEmpty(self): + """Test AddConfig() with empty config.""" + c = self.cov + # Most minimal config is an empty dict; should do nothing + c.AddConfig('{} # And we ignore comments') + + def testAddConfig(self): + """Test AddConfig().""" + c = self.cov + lcov_queue = [] + addfiles_queue = [] + + c.AddConfig("""{ + 'roots' : [ + {'root' : '/foo'}, + {'root' : '/bar', 'altname' : '#BAR'}, + ], + 'rules' : [ + {'regexp' : '^#', 'group' : 'apple'}, + {'regexp' : 're2', 'include' : 1, 'language' : 'elvish'}, + ], + 'lcov_files' : ['a.lcov', 'b.lcov'], + 'add_files' : ['/src', '#BAR/doo'], + 'print_stats' : [ + {'stat' : 'count_a'}, + {'stat' : 'count_b', 'group' : 'tests'}, + ], + 'extra_key' : 'is ignored', + }""", lcov_queue=lcov_queue, addfiles_queue=addfiles_queue) + + self.assertEqual(lcov_queue, ['a.lcov', 'b.lcov']) + self.assertEqual(addfiles_queue, ['/src', '#BAR/doo']) + self.assertEqual(c.root_dirs, [['/foo', '#'], ['/bar', '#BAR']]) + self.assertEqual(c.print_stats, [ + {'stat': 'count_a'}, + {'stat': 'count_b', 'group': 'tests'}, + ]) + # Convert compiled re's back to patterns for comparison + rules = [[r[0].pattern] + r[1:] for r in c.rules] + self.assertEqual(rules, [ + ['.*/$', None, None, 'subdir'], + ['^#', None, 'apple', None], + ['re2', 1, None, 'elvish'], + ]) + + def testAddFilesSimple(self): + """Test AddFiles() simple call.""" + c = self.cov_minimal + c.add_files_walk = self.MockWalk + c.AddFiles('/a/b/c') + self.assertEqual(self.mock_walk_calls, ['/a/b/c']) + self.assertEqual(c.files, {}) + + def testAddFilesRootMap(self): + """Test AddFiles() with root mappings.""" + c = self.cov_minimal + c.add_files_walk = self.MockWalk + c.AddRoot('#/subdir', '#SUBDIR') + + # AddFiles() should replace the '#SUBDIR' alt_name, then match both + # possible roots for the '#' alt_name. + c.AddFiles('#SUBDIR/foo') + self.assertEqual(self.mock_walk_calls, + ['/src/subdir/foo', 'c:/source/subdir/foo']) + self.assertEqual(c.files, {}) + + def testAddFilesNonEmpty(self): + """Test AddFiles() where files are returned.""" + + c = self.cov_minimal + c.add_files_walk = self.MockWalk + + # Add a rule to exclude a subdir + c.AddRule('^#/proj1/excluded/', include=0) + + # Set data for mock walk + self.mock_walk_return = [ + [ + '/src/proj1', + ['excluded', 'subdir'], + ['a.c', 'no.f', 'yes.c'], + ], + [ + '/src/proj1/subdir', + [], + ['cherry.c'], + ], + ] + + c.AddFiles('/src/proj1') + + self.assertEqual(self.mock_walk_calls, ['/src/proj1']) + + # Include files from the main dir and subdir + self.assertEqual(sorted(c.files), [ + '#/proj1/a.c', + '#/proj1/subdir/cherry.c', + '#/proj1/yes.c']) + + # Excluded dir should have been pruned from the mock walk data dirnames. + # In the real os.walk() call this prunes the walk. + self.assertEqual(self.mock_walk_return[0][1], ['subdir']) + + def testUpdateTreeStats(self): + """Test UpdateTreeStats().""" + + c = self.cov_minimal + + + + c.AddRule('.*_test', group='test') + + # Fill the files list + c.ParseLcovData([ + 'SF:/src/a.c', + 'DA:10,1', 'DA:11,1', 'DA:20,0', + 'end_of_record', + 'SF:/src/a_test.c', + 'DA:10,1', 'DA:11,1', 'DA:12,1', + 'end_of_record', + 'SF:/src/foo/b.c', + 'DA:10,1', 'DA:11,1', 'DA:20,0', 'DA:21,0', 'DA:30,0', + 'end_of_record', + 'SF:/src/foo/b_test.c', + 'DA:20,0', 'DA:21,0', 'DA:22,0', + 'end_of_record', + ]) + c.UpdateTreeStats() + + t = c.tree + self.assertEqual(t.dirpath, '') + self.assertEqual(sorted(t.files), []) + self.assertEqual(sorted(t.subdirs), ['#']) + self.assertEqual(t.stats_by_group, { + 'all': { + 'files_covered': 3, + 'files_executable': 4, + 'lines_executable': 14, + 'lines_covered': 7, + 'lines_instrumented': 14, + 'files_instrumented': 4, + }, + 'my': { + 'files_covered': 2, + 'files_executable': 2, + 'lines_executable': 8, + 'lines_covered': 4, + 'lines_instrumented': 8, + 'files_instrumented': 2, + }, + 'test': { + 'files_covered': 1, + 'files_executable': 2, + 'lines_executable': 6, + 'lines_covered': 3, + 'lines_instrumented': 6, + 'files_instrumented': 2, + }, + }) + + t = t.subdirs['#'] + self.assertEqual(t.dirpath, '#') + self.assertEqual(sorted(t.files), ['a.c', 'a_test.c']) + self.assertEqual(sorted(t.subdirs), ['foo']) + self.assertEqual(t.stats_by_group, { + 'all': { + 'files_covered': 3, + 'files_executable': 4, + 'lines_executable': 14, + 'lines_covered': 7, + 'lines_instrumented': 14, + 'files_instrumented': 4, + }, + 'my': { + 'files_covered': 2, + 'files_executable': 2, + 'lines_executable': 8, + 'lines_covered': 4, + 'lines_instrumented': 8, + 'files_instrumented': 2, + }, + 'test': { + 'files_covered': 1, + 'files_executable': 2, + 'lines_executable': 6, + 'lines_covered': 3, + 'lines_instrumented': 6, + 'files_instrumented': 2, + }, + }) + + t = t.subdirs['foo'] + self.assertEqual(t.dirpath, 'foo') + self.assertEqual(sorted(t.files), ['b.c', 'b_test.c']) + self.assertEqual(sorted(t.subdirs), []) + self.assertEqual(t.stats_by_group, { + 'test': { + 'files_executable': 1, + 'files_instrumented': 1, + 'lines_executable': 3, + 'lines_instrumented': 3, + 'lines_covered': 0, + }, + 'all': { + 'files_covered': 1, + 'files_executable': 2, + 'lines_executable': 8, + 'lines_covered': 2, + 'lines_instrumented': 8, + 'files_instrumented': 2, + }, + 'my': { + 'files_covered': 1, + 'files_executable': 1, + 'lines_executable': 5, + 'lines_covered': 2, + 'lines_instrumented': 5, + 'files_instrumented': 1, + } + }) + + # TODO: test: less important, since these are thin wrappers around other + # tested methods. + # ParseConfig() + # ParseLcovFile() + # PrintTree() + +#------------------------------------------------------------------------------ + +if __name__ == '__main__': + unittest.main() |