Srikrishna Iyer | 86169d0 | 2021-05-10 09:35:52 -0700 | [diff] [blame] | 1 | # Copyright lowRISC contributors. |
| 2 | # Licensed under the Apache License, Version 2.0, see LICENSE for details. |
| 3 | # SPDX-License-Identifier: Apache-2.0 |
| 4 | r"""Testpoint and Testplan classes for maintaining the testplan |
| 5 | """ |
| 6 | |
| 7 | import os |
| 8 | import re |
| 9 | import sys |
| 10 | from collections import defaultdict |
| 11 | |
| 12 | import hjson |
| 13 | import mistletoe |
| 14 | from tabulate import tabulate |
| 15 | |
| 16 | |
| 17 | class Result: |
| 18 | '''The results for a single test''' |
| 19 | def __init__(self, name, passing=0, total=0): |
| 20 | self.name = name |
| 21 | self.passing = passing |
| 22 | self.total = total |
| 23 | self.mapped = False |
| 24 | |
| 25 | |
| 26 | class Element(): |
| 27 | """An element of the testplan. |
| 28 | |
| 29 | This is either a testpoint or a covergroup. |
| 30 | """ |
| 31 | # Type of the testplan element. Must be set by the extended class. |
| 32 | kind = None |
| 33 | |
| 34 | # Mandatory fields in a testplan element. |
| 35 | fields = ("name", "desc") |
| 36 | |
| 37 | def __init__(self, raw_dict): |
| 38 | """Initialize the testplan element. |
| 39 | |
| 40 | raw_dict is the dictionary parsed from the HJSon file. |
| 41 | """ |
| 42 | for field in self.fields: |
| 43 | try: |
| 44 | setattr(self, field, raw_dict.pop(field)) |
| 45 | except KeyError as e: |
| 46 | raise KeyError(f"Error: {self.kind} does not contain all of " |
| 47 | f"the required fields:\n{raw_dict}\nRequired:\n" |
| 48 | f"{self.fields}\n{e}") |
| 49 | |
| 50 | # Set the remaining k-v pairs in raw_dict as instance attributes. |
| 51 | for k, v in raw_dict: |
| 52 | setattr(self, k, v) |
| 53 | |
| 54 | # Verify things are in order. |
| 55 | self._validate() |
| 56 | |
| 57 | def __str__(self): |
| 58 | # Reindent the multiline desc with 4 spaces. |
| 59 | desc = "\n".join( |
| 60 | [" " + line.lstrip() for line in self.desc.split("\n")]) |
| 61 | return (f" {self.kind.capitalize()}: {self.name}\n" |
| 62 | f" Description:\n{desc}\n") |
| 63 | |
| 64 | def _validate(self): |
| 65 | """Runs some basic consistency checks.""" |
| 66 | if not self.name: |
| 67 | raise ValueError(f"Error: {self.kind.capitalize()} name cannot " |
| 68 | f"be empty:\n{self}") |
| 69 | |
| 70 | |
| 71 | class Covergroup(Element): |
| 72 | """A coverage model item. |
| 73 | |
| 74 | The list of covergroups defines the coverage model for the design. Each |
| 75 | entry captures the name of the covergroup (suffixed with _cg) and a brief |
| 76 | description describing what functionality is covered. It is recommended to |
| 77 | include individual coverpoints and crosses in the description. |
| 78 | """ |
| 79 | kind = "covergroup" |
| 80 | |
| 81 | def _validate(self): |
| 82 | super()._validate() |
| 83 | if not self.name.endswith("_cg"): |
| 84 | raise ValueError(f"Error: Covergroup name {self.name} needs to " |
| 85 | "end with suffix \"_cg\".") |
| 86 | |
| 87 | |
| 88 | class Testpoint(Element): |
| 89 | """An testcase entry in the testplan. |
| 90 | |
| 91 | A testpoint maps to a unique design feature that is planned to be verified. |
| 92 | It captures following information: |
| 93 | - name of the planned test |
| 94 | - a brief description indicating intent, stimulus and checking procedure |
| 95 | - the targeted milestone |
| 96 | - the list of actual developed tests that verify it |
| 97 | """ |
| 98 | kind = "testpoint" |
| 99 | fields = Element.fields + ("milestone", "tests") |
| 100 | |
| 101 | # Verification milestones. |
| 102 | milestones = ("N.A.", "V1", "V2", "V3") |
| 103 | |
| 104 | def __init__(self, raw_dict): |
| 105 | super().__init__(raw_dict) |
| 106 | |
| 107 | # List of Result objects indicating test results mapped to this |
| 108 | # testpoint. |
| 109 | self.test_results = [] |
| 110 | |
| 111 | def __str__(self): |
| 112 | return super().__str__() + (f" Milestone: {self.milestone}\n" |
| 113 | f" Tests: {self.tests}\n") |
| 114 | |
| 115 | def _validate(self): |
| 116 | super()._validate() |
| 117 | if self.milestone not in Testpoint.milestones: |
| 118 | raise ValueError(f"Testpoint milestone {self.milestone} is " |
| 119 | f"invalid:\n{self}\nLegal values: " |
| 120 | f"Testpoint.milestones") |
| 121 | |
| 122 | def do_substitutions(self, substitutions): |
| 123 | '''Substitute {wildcards} in tests |
| 124 | |
| 125 | If tests have {wildcards}, they are substituted with the 'correct' |
| 126 | values using the key=value pairs provided by the substitutions arg. |
| 127 | Wildcards with no substitution arg are replaced by an empty string. |
| 128 | |
| 129 | substitutions is a dictionary of wildcard-replacement pairs. |
| 130 | ''' |
| 131 | resolved_tests = [] |
| 132 | for test in self.tests: |
| 133 | match = re.findall(r"{([A-Za-z0-9\_]+)}", test) |
| 134 | if not match: |
| 135 | resolved_tests.append(test) |
| 136 | continue |
| 137 | |
| 138 | # 'match' is a list of wildcards used in the test. Get their |
| 139 | # corresponding values. |
| 140 | subst = {item: substitutions.get(item, "") for item in match} |
| 141 | |
| 142 | resolved = [test] |
| 143 | for item, value in subst.items(): |
| 144 | values = value if isinstance(value, list) else [value] |
| 145 | resolved = [ |
| 146 | t.replace(f"{{{item}}}", v) for t in resolved |
| 147 | for v in values |
| 148 | ] |
| 149 | resolved_tests.extend(resolved) |
| 150 | |
| 151 | self.tests = resolved_tests |
| 152 | |
| 153 | def map_test_results(self, test_results): |
| 154 | """Map test results to tests against this testpoint. |
| 155 | |
| 156 | Given a list of test results find the ones that match the tests listed |
| 157 | in this testpoint and buiild a structure. If no match is found, or if |
| 158 | self.tests is an empty list, indicate 0/1 passing so that it is |
| 159 | factored into the final total. |
| 160 | """ |
| 161 | for tr in test_results: |
| 162 | assert isinstance(tr, Result) |
| 163 | if tr.name in self.tests: |
| 164 | tr.mapped = True |
| 165 | self.test_results.append(tr) |
| 166 | |
| 167 | # Did we map all tests in this testpoint? If we are mapping the full |
| 168 | # testplan, then count the ones not found as "not run", i.e. 0 / 0. |
| 169 | tests_mapped = [tr.name for tr in self.test_results] |
| 170 | for test in self.tests: |
| 171 | if test not in tests_mapped: |
| 172 | self.test_results.append(Result(name=test, passing=0, total=0)) |
| 173 | |
| 174 | # If no written tests were indicated for this testpoint, then reuse |
| 175 | # the testpoint name to count towards "not run". |
| 176 | if not self.tests: |
| 177 | self.test_results = [Result(name=self.name, passing=0, total=0)] |
| 178 | |
| 179 | |
| 180 | class Testplan(): |
| 181 | """The full testplan |
| 182 | |
| 183 | The list of Testpoints and Covergroups make up the testplan. |
| 184 | """ |
| 185 | |
| 186 | rsvd_keywords = ["import_testplans", "testpoints", "covergroups"] |
| 187 | element_cls = {'testpoint': Testpoint, 'covergroup': Covergroup} |
| 188 | |
| 189 | @staticmethod |
| 190 | def _parse_hjson(filename): |
| 191 | """Parses an input file with HJson and returns a dict.""" |
| 192 | try: |
| 193 | return hjson.load(open(filename, 'rU')) |
| 194 | except IOError as e: |
| 195 | print(f"IO Error when opening fie {filename}\n{e}") |
| 196 | except hjson.scanner.HjsonDecodeError as e: |
| 197 | print(f"Error: Unable to decode HJSON with file {filename}:\n{e}") |
| 198 | sys.exit(1) |
| 199 | |
| 200 | @staticmethod |
| 201 | def _create_testplan_elements(kind, raw_dicts_list): |
| 202 | """Creates testplan elements from the list of raw dicts. |
| 203 | |
| 204 | kind is either 'testpoint' or 'covergroup'. |
| 205 | raw_dicts_list is a list of dictionaries extracted from the HJson file. |
| 206 | """ |
| 207 | items = [] |
| 208 | item_names = set() |
| 209 | for dict_entry in raw_dicts_list: |
| 210 | try: |
| 211 | item = Testplan.element_cls[kind](dict_entry) |
| 212 | except KeyError as e: |
| 213 | print(f"Error: {kind} arg is invalid.\n{e}") |
| 214 | sys.exit(1) |
| 215 | except ValueError as e: |
| 216 | print(e) |
| 217 | sys.exit(1) |
| 218 | |
| 219 | if item.name in item_names: |
| 220 | print(f"Error: Duplicate {kind} item found with name: " |
| 221 | f"{item.name}") |
| 222 | sys.exit(1) |
| 223 | items.append(item) |
| 224 | item_names.add(item.name) |
| 225 | return items |
| 226 | |
| 227 | @staticmethod |
| 228 | def _get_percentage(value, total): |
| 229 | """Returns a string representing percentage upto 2 decimal places.""" |
| 230 | if total == 0: |
| 231 | return "-- %" |
| 232 | perc = value / total * 100 * 1.0 |
| 233 | return "{0:.2f} %".format(round(perc, 2)) |
| 234 | |
| 235 | @staticmethod |
| 236 | def get_dv_style_css(): |
| 237 | """Returns text with HTML CSS style for a table.""" |
| 238 | return ("<style>\n" |
| 239 | "table.dv {\n" |
| 240 | " border: 1px solid black;\n" |
| 241 | " border-collapse: collapse;\n" |
| 242 | " width: 100%;\n" |
| 243 | " text-align: left;\n" |
| 244 | " vertical-align: middle;\n" |
| 245 | " display: table;\n" |
| 246 | " font-size: smaller;\n" |
| 247 | "}\n" |
| 248 | "table.dv th, td {\n" |
| 249 | " border: 1px solid black;\n" |
| 250 | "}\n" |
| 251 | "</style>\n") |
| 252 | |
| 253 | def __str__(self): |
| 254 | lines = [f"Name: {self.name}\n"] |
| 255 | lines += ["Testpoints:"] |
| 256 | lines += [f"{t}" for t in self.testpoints] |
| 257 | lines += ["Covergroups:"] |
| 258 | lines += [f"{c}" for c in self.covergroups] |
| 259 | return "\n".join(lines) |
| 260 | |
| 261 | def __init__(self, filename, repo_top=None, name=None): |
| 262 | """Initialize the testplan. |
| 263 | |
| 264 | filename is the HJson file that captures the testplan. |
| 265 | repo_top is an optional argument indicating the path to top level repo |
| 266 | / project directory. It is used with filename arg. |
| 267 | name is an optional argument indicating the name of the testplan / DUT. |
| 268 | It overrides the name set in the testplan HJson. |
| 269 | """ |
| 270 | self.name = None |
| 271 | self.testpoints = [] |
| 272 | self.covergroups = [] |
| 273 | self.test_results_mapped = False |
| 274 | |
| 275 | if filename: |
| 276 | self._parse_testplan(filename, repo_top) |
| 277 | |
| 278 | if name: |
| 279 | self.name = name |
| 280 | |
| 281 | if not self.name: |
| 282 | print("Error: the testplan 'name' is not set!") |
| 283 | sys.exit(1) |
| 284 | |
| 285 | # Represents current progress towards each milestone. Milestone = N.A. |
| 286 | # is used to indicate the unmapped tests. |
| 287 | self.progress = {} |
| 288 | for key in Testpoint.milestones: |
| 289 | self.progress[key] = { |
| 290 | "written": 0, |
| 291 | "total": 0, |
| 292 | "progress": 0.0, |
| 293 | } |
| 294 | |
| 295 | def _parse_testplan(self, filename, repo_top=None): |
| 296 | '''Parse testplan Hjson file and create the testplan elements. |
| 297 | |
| 298 | It creates the list of testpoints and covergroups extracted from the |
| 299 | file. |
| 300 | |
| 301 | filename is the path to the testplan file written in HJson format. |
| 302 | repo_top is an optional argument indicating the path to repo top. |
| 303 | ''' |
| 304 | if repo_top is None: |
| 305 | # Assume dvsim's original location: $REPO_TOP/util/dvsim. |
| 306 | self_path = os.path.dirname(os.path.realpath(__file__)) |
| 307 | repo_top = os.path.abspath( |
| 308 | os.path.join(self_path, os.pardir, os.pardir)) |
| 309 | |
| 310 | obj = Testplan._parse_hjson(filename) |
Srikrishna Iyer | 0f910ed | 2021-05-26 14:13:14 -0700 | [diff] [blame] | 311 | |
| 312 | parsed = set() |
Srikrishna Iyer | 86169d0 | 2021-05-10 09:35:52 -0700 | [diff] [blame] | 313 | imported_testplans = obj.get("import_testplans", []) |
Srikrishna Iyer | 0f910ed | 2021-05-26 14:13:14 -0700 | [diff] [blame] | 314 | while imported_testplans: |
| 315 | testplan = imported_testplans.pop(0) |
| 316 | if testplan in parsed: |
| 317 | print(f"Error: encountered the testplan {testplan} again, " |
| 318 | "which was already parsed. Please check for circular " |
| 319 | "dependencies.") |
| 320 | sys.exit(1) |
| 321 | parsed.add(testplan) |
| 322 | data = self._parse_hjson(os.path.join(repo_top, testplan)) |
| 323 | imported_testplans.extend(data.get("import_testplans", [])) |
| 324 | obj = _merge_dicts(obj, data) |
Srikrishna Iyer | 86169d0 | 2021-05-10 09:35:52 -0700 | [diff] [blame] | 325 | |
| 326 | self.name = obj.get("name") |
| 327 | |
| 328 | testpoints = obj.get("testpoints", []) |
| 329 | self.testpoints = self._create_testplan_elements( |
| 330 | 'testpoint', testpoints) |
| 331 | |
| 332 | covergroups = obj.get("covergroups", []) |
| 333 | self.covergroups = self._create_testplan_elements( |
| 334 | 'covergroup', covergroups) |
| 335 | |
| 336 | if not testpoints and not covergroups: |
| 337 | print(f"Error: No testpoints or covergroups found in {filename}") |
| 338 | sys.exit(1) |
| 339 | |
| 340 | # Any variable in the testplan that is not a recognized HJson field can |
| 341 | # be used as a substitution variable. |
| 342 | substitutions = { |
| 343 | k: v |
| 344 | for k, v in obj.items() if k not in self.rsvd_keywords |
| 345 | } |
| 346 | for tp in self.testpoints: |
| 347 | tp.do_substitutions(substitutions) |
| 348 | |
| 349 | self._sort() |
| 350 | |
| 351 | def _sort(self): |
| 352 | """Sort testpoints by milestone and covergroups by name.""" |
| 353 | self.testpoints.sort(key=lambda x: x.milestone) |
| 354 | self.covergroups.sort(key=lambda x: x.name) |
| 355 | |
| 356 | def get_milestone_regressions(self): |
| 357 | regressions = defaultdict(set) |
| 358 | for tp in self.testpoints: |
| 359 | if tp.milestone in tp.milestones[1:]: |
Srikrishna Iyer | bc7789d | 2021-05-24 17:47:17 -0700 | [diff] [blame] | 360 | regressions[tp.milestone].update({t for t in tp.tests if t}) |
Srikrishna Iyer | 86169d0 | 2021-05-10 09:35:52 -0700 | [diff] [blame] | 361 | |
| 362 | # Build regressions dict into a hjson like data structure |
| 363 | return [{ |
| 364 | "name": ms, |
| 365 | "tests": list(regressions[ms]) |
| 366 | } for ms in regressions] |
| 367 | |
Srikrishna Iyer | ba48231 | 2021-05-21 02:09:54 -0700 | [diff] [blame] | 368 | def get_testplan_table(self, fmt="pipe"): |
Srikrishna Iyer | 86169d0 | 2021-05-10 09:35:52 -0700 | [diff] [blame] | 369 | """Generate testplan table from hjson testplan. |
| 370 | |
Srikrishna Iyer | ba48231 | 2021-05-21 02:09:54 -0700 | [diff] [blame] | 371 | fmt is either 'pipe' (markdown) or 'html'. 'pipe' is the name used by |
| 372 | tabulate to generate a markdown formatted table. |
Srikrishna Iyer | 86169d0 | 2021-05-10 09:35:52 -0700 | [diff] [blame] | 373 | """ |
Srikrishna Iyer | ba48231 | 2021-05-21 02:09:54 -0700 | [diff] [blame] | 374 | assert fmt in ["pipe", "html"] |
| 375 | |
| 376 | def _fmt_text(text, fmt): |
| 377 | return mistletoe.markdown(text) if fmt == "html" else text |
Srikrishna Iyer | 86169d0 | 2021-05-10 09:35:52 -0700 | [diff] [blame] | 378 | |
| 379 | if self.testpoints: |
Srikrishna Iyer | ba48231 | 2021-05-21 02:09:54 -0700 | [diff] [blame] | 380 | lines = [_fmt_text("\n### Testpoints\n", fmt)] |
Srikrishna Iyer | 86169d0 | 2021-05-10 09:35:52 -0700 | [diff] [blame] | 381 | header = ["Milestone", "Name", "Tests", "Description"] |
| 382 | colalign = ("center", "center", "left", "left") |
| 383 | table = [] |
| 384 | for tp in self.testpoints: |
Srikrishna Iyer | ba48231 | 2021-05-21 02:09:54 -0700 | [diff] [blame] | 385 | desc = _fmt_text(tp.desc.strip(), fmt) |
| 386 | # TODO(astanin/python-tabulate#126): Tabulate does not |
| 387 | # convert \n's to line-breaks. |
| 388 | tests = "<br>\n".join(tp.tests) |
Srikrishna Iyer | 86169d0 | 2021-05-10 09:35:52 -0700 | [diff] [blame] | 389 | table.append([tp.milestone, tp.name, tests, desc]) |
Srikrishna Iyer | ba48231 | 2021-05-21 02:09:54 -0700 | [diff] [blame] | 390 | lines += [ |
| 391 | tabulate(table, |
| 392 | headers=header, |
| 393 | tablefmt=fmt, |
| 394 | colalign=colalign) |
| 395 | ] |
Srikrishna Iyer | 86169d0 | 2021-05-10 09:35:52 -0700 | [diff] [blame] | 396 | |
| 397 | if self.covergroups: |
Srikrishna Iyer | ba48231 | 2021-05-21 02:09:54 -0700 | [diff] [blame] | 398 | lines += [_fmt_text("\n### Covergroups\n", fmt)] |
Srikrishna Iyer | 86169d0 | 2021-05-10 09:35:52 -0700 | [diff] [blame] | 399 | header = ["Name", "Description"] |
| 400 | colalign = ("center", "left") |
| 401 | table = [] |
| 402 | for covergroup in self.covergroups: |
Srikrishna Iyer | bc7789d | 2021-05-24 17:47:17 -0700 | [diff] [blame] | 403 | desc = _fmt_text(covergroup.desc.strip(), fmt) |
Srikrishna Iyer | 86169d0 | 2021-05-10 09:35:52 -0700 | [diff] [blame] | 404 | table.append([covergroup.name, desc]) |
Srikrishna Iyer | ba48231 | 2021-05-21 02:09:54 -0700 | [diff] [blame] | 405 | lines += [ |
| 406 | tabulate(table, |
| 407 | headers=header, |
| 408 | tablefmt=fmt, |
| 409 | colalign=colalign) |
| 410 | ] |
Srikrishna Iyer | 86169d0 | 2021-05-10 09:35:52 -0700 | [diff] [blame] | 411 | |
Srikrishna Iyer | ba48231 | 2021-05-21 02:09:54 -0700 | [diff] [blame] | 412 | text = "\n".join(lines) |
Srikrishna Iyer | 86169d0 | 2021-05-10 09:35:52 -0700 | [diff] [blame] | 413 | if fmt == "html": |
Srikrishna Iyer | ba48231 | 2021-05-21 02:09:54 -0700 | [diff] [blame] | 414 | text = self.get_dv_style_css() + text |
Srikrishna Iyer | 86169d0 | 2021-05-10 09:35:52 -0700 | [diff] [blame] | 415 | text = text.replace("<table>", "<table class=\"dv\">") |
| 416 | |
Srikrishna Iyer | ba48231 | 2021-05-21 02:09:54 -0700 | [diff] [blame] | 417 | # Tabulate does not support HTML tags. |
| 418 | text = text.replace("<", "<").replace(">", ">") |
Srikrishna Iyer | 86169d0 | 2021-05-10 09:35:52 -0700 | [diff] [blame] | 419 | return text |
| 420 | |
| 421 | def map_test_results(self, test_results): |
| 422 | """Map test results to testpoints.""" |
| 423 | # Maintain a list of tests we already counted. |
| 424 | tests_seen = set() |
| 425 | |
| 426 | def _process_testpoint(testpoint, totals): |
| 427 | """Computes the testplan progress and the sim footprint. |
| 428 | |
| 429 | totals is a list of Testpoint items that represent the total number |
| 430 | of tests passing for each milestone. The sim footprint is simply |
| 431 | the sum total of all tests run in the simulation, counted for each |
| 432 | milestone and also the grand total. |
| 433 | """ |
| 434 | ms = testpoint.milestone |
| 435 | for tr in testpoint.test_results: |
| 436 | if tr.name in tests_seen: |
| 437 | continue |
| 438 | |
| 439 | tests_seen.add(tr.name) |
| 440 | # Compute the testplan progress. |
| 441 | self.progress[ms]["total"] += 1 |
| 442 | if tr.total != 0: |
| 443 | self.progress[ms]["written"] += 1 |
| 444 | |
| 445 | # Compute the milestone total & the grand total. |
| 446 | totals[ms].test_results[0].passing += tr.passing |
| 447 | totals[ms].test_results[0].total += tr.total |
| 448 | if ms != "N.A.": |
| 449 | totals["N.A."].test_results[0].passing += tr.passing |
| 450 | totals["N.A."].test_results[0].total += tr.total |
| 451 | |
| 452 | totals = {} |
| 453 | # Create testpoints to represent the total for each milestone & the |
| 454 | # grand total. |
| 455 | for ms in Testpoint.milestones: |
| 456 | arg = { |
| 457 | "name": "N.A.", |
| 458 | "desc": f"Total {ms} tests", |
| 459 | "milestone": ms, |
| 460 | "tests": [], |
| 461 | } |
| 462 | totals[ms] = Testpoint(arg) |
| 463 | totals[ms].test_results = [Result("**TOTAL**")] |
| 464 | |
| 465 | # Create unmapped as a testpoint to represent tests from the simulation |
| 466 | # results that could not be mapped to the testpoints. |
| 467 | arg = { |
| 468 | "name": "Unmapped tests", |
| 469 | "desc": "Unmapped tests", |
| 470 | "milestone": "N.A.", |
| 471 | "tests": [], |
| 472 | } |
| 473 | unmapped = Testpoint(arg) |
| 474 | |
| 475 | # Now, map the simulation results to each testpoint. |
| 476 | for tp in self.testpoints: |
| 477 | tp.map_test_results(test_results) |
| 478 | _process_testpoint(tp, totals) |
| 479 | |
| 480 | # If we do have unmapped tests, then count that too. |
| 481 | unmapped.test_results = [tr for tr in test_results if not tr.mapped] |
| 482 | _process_testpoint(unmapped, totals) |
| 483 | |
| 484 | # Add milestone totals back into 'testpoints' and sort. |
| 485 | for ms in Testpoint.milestones[1:]: |
| 486 | self.testpoints.append(totals[ms]) |
| 487 | self._sort() |
| 488 | |
| 489 | # Append unmapped and the grand total at the end. |
| 490 | if unmapped.test_results: |
| 491 | self.testpoints.append(unmapped) |
| 492 | self.testpoints.append(totals["N.A."]) |
| 493 | |
| 494 | # Compute the progress rate fpr each milestone. |
| 495 | for ms in Testpoint.milestones: |
| 496 | stat = self.progress[ms] |
| 497 | |
| 498 | # Remove milestones that are not targeted. |
| 499 | if stat["total"] == 0: |
| 500 | self.progress.pop(ms) |
| 501 | continue |
| 502 | |
| 503 | stat["progress"] = self._get_percentage(stat["written"], |
| 504 | stat["total"]) |
| 505 | |
| 506 | self.test_results_mapped = True |
| 507 | |
| 508 | def map_covergroups(self, cgs_found): |
| 509 | """Map the covergroups found from simulation to the testplan. |
| 510 | |
| 511 | For now, this does nothing more than 'check off' the covergroup |
| 512 | found from the simulation results with the coverage model in the |
| 513 | testplan by updating the progress dict. |
| 514 | |
| 515 | cgs_found is a list of covergroup names extracted from the coverage |
| 516 | database after the simulation is run with coverage enabled. |
| 517 | """ |
| 518 | |
| 519 | if not self.covergroups: |
| 520 | return |
| 521 | |
| 522 | written = 0 |
| 523 | total = 0 |
| 524 | for cg in self.covergroups: |
| 525 | total += 1 |
| 526 | if cg.name in cgs_found: |
| 527 | written += 1 |
| 528 | |
| 529 | self.progress["Covergroups"] = { |
| 530 | "written": written, |
| 531 | "total": total, |
| 532 | "progress": self._get_percentage(written, total), |
| 533 | } |
| 534 | |
| 535 | def get_test_results_table(self, map_full_testplan=True): |
| 536 | """Return the mapped test results into a markdown table.""" |
| 537 | |
| 538 | assert self.test_results_mapped, "Have you invoked map_test_results()?" |
| 539 | header = [ |
| 540 | "Milestone", "Name", "Tests", "Passing", "Total", "Pass Rate" |
| 541 | ] |
| 542 | colalign = ("center", "center", "left", "center", "center", "center") |
| 543 | table = [] |
| 544 | for tp in self.testpoints: |
| 545 | milestone = "" if tp.milestone == "N.A." else tp.milestone |
| 546 | tp_name = "" if tp.name == "N.A." else tp.name |
| 547 | for tr in tp.test_results: |
| 548 | if tr.total == 0 and not map_full_testplan: |
| 549 | continue |
| 550 | pass_rate = self._get_percentage(tr.passing, tr.total) |
| 551 | table.append([ |
| 552 | milestone, tp_name, tr.name, tr.passing, tr.total, |
| 553 | pass_rate |
| 554 | ]) |
| 555 | milestone = "" |
| 556 | tp_name = "" |
| 557 | |
| 558 | text = "\n### Test Results\n" |
| 559 | text += tabulate(table, |
| 560 | headers=header, |
| 561 | tablefmt="pipe", |
| 562 | colalign=colalign) |
| 563 | text += "\n" |
| 564 | return text |
| 565 | |
| 566 | def get_progress_table(self): |
| 567 | """Returns the current progress of the effort towards the testplan.""" |
| 568 | |
| 569 | assert self.test_results_mapped, "Have you invoked map_test_results()?" |
| 570 | header = [] |
| 571 | table = [] |
| 572 | for key in self.progress: |
| 573 | stat = self.progress[key] |
| 574 | values = [v for v in stat.values()] |
| 575 | if not header: |
| 576 | header = ["Items"] + [k.capitalize() for k in stat] |
| 577 | table.append([key] + values) |
| 578 | |
| 579 | text = "\n### Testplan Progress\n" |
| 580 | colalign = (("center", ) * len(header)) |
| 581 | text += tabulate(table, |
| 582 | headers=header, |
| 583 | tablefmt="pipe", |
| 584 | colalign=colalign) |
| 585 | text += "\n" |
| 586 | return text |
| 587 | |
| 588 | def get_cov_results_table(self, cov_results): |
| 589 | """Returns the coverage in a table format. |
| 590 | |
| 591 | cov_results is a list of dicts with name and result keys, representing |
| 592 | the name of the coverage metric and the result in decimal / fp value. |
| 593 | """ |
| 594 | |
| 595 | if not cov_results: |
| 596 | return "" |
| 597 | |
| 598 | try: |
| 599 | cov_header = [c["name"].capitalize() for c in cov_results] |
| 600 | cov_values = [c["result"] for c in cov_results] |
| 601 | except KeyError as e: |
| 602 | print(f"Malformed cov_results:\n{cov_results}\n{e}") |
| 603 | sys.exit(1) |
| 604 | |
| 605 | colalign = (("center", ) * len(cov_header)) |
| 606 | text = "\n### Coverage Results\n" |
| 607 | text += tabulate([cov_values], |
| 608 | headers=cov_header, |
| 609 | tablefmt="pipe", |
| 610 | colalign=colalign) |
| 611 | text += "\n" |
| 612 | return text |
| 613 | |
| 614 | def get_test_results_summary(self): |
| 615 | """Returns the final total as a summary.""" |
| 616 | assert self.test_results_mapped, "Have you invoked map_test_results()?" |
| 617 | |
| 618 | # The last item in tespoints is the final sum total. We use that to |
| 619 | # return the results summary as a dict. |
| 620 | total = self.testpoints[-1] |
| 621 | assert total.name == "N.A." |
| 622 | assert total.milestone == "N.A." |
| 623 | |
| 624 | tr = total.test_results[0] |
| 625 | |
| 626 | result = {} |
| 627 | result["Name"] = self.name.upper() |
| 628 | result["Passing"] = tr.passing |
| 629 | result["Total"] = tr.total |
| 630 | result["Pass Rate"] = self._get_percentage(tr.passing, tr.total) |
| 631 | return result |
| 632 | |
| 633 | def get_sim_results(self, sim_results_file, fmt="md"): |
| 634 | """Returns the mapped sim result tables in HTML formatted text. |
| 635 | |
| 636 | The data extracted from the sim_results table HJson file is mapped into |
| 637 | a test results, test progress, covergroup progress and coverage tables. |
Srikrishna Iyer | ba48231 | 2021-05-21 02:09:54 -0700 | [diff] [blame] | 638 | |
| 639 | fmt is either 'md' (markdown) or 'html'. |
Srikrishna Iyer | 86169d0 | 2021-05-10 09:35:52 -0700 | [diff] [blame] | 640 | """ |
| 641 | assert fmt in ["md", "html"] |
| 642 | sim_results = Testplan._parse_hjson(sim_results_file) |
| 643 | test_results_ = sim_results.get("test_results", None) |
| 644 | |
| 645 | test_results = [] |
| 646 | for item in test_results_: |
| 647 | try: |
| 648 | tr = Result(item["name"], item["passing"], item["total"]) |
| 649 | test_results.append(tr) |
| 650 | except KeyError as e: |
| 651 | print(f"Error: data in {sim_results_file} is malformed!\n{e}") |
| 652 | sys.exit(1) |
| 653 | |
| 654 | self.map_test_results(test_results) |
| 655 | self.map_covergroups(sim_results.get("covergroups", [])) |
| 656 | |
| 657 | text = "# Simulation Results\n" |
| 658 | text += "## Run on {}\n".format(sim_results["timestamp"]) |
| 659 | text += self.get_test_results_table() |
| 660 | text += self.get_progress_table() |
| 661 | |
| 662 | cov_results = sim_results.get("cov_results", []) |
| 663 | text += self.get_cov_results_table(cov_results) |
| 664 | |
| 665 | if fmt == "html": |
| 666 | text = self.get_dv_style_css() + mistletoe.markdown(text) |
| 667 | text = text.replace("<table>", "<table class=\"dv\">") |
| 668 | return text |
| 669 | |
| 670 | |
| 671 | def _merge_dicts(list1, list2, use_list1_for_defaults=True): |
| 672 | '''Merge 2 dicts into one |
| 673 | |
| 674 | This function takes 2 dicts as args list1 and list2. It recursively merges |
| 675 | list2 into list1 and returns list1. The recursion happens when the the |
| 676 | value of a key in both lists is a dict. If the values of the same key in |
| 677 | both lists (at the same tree level) are of dissimilar type, then there is a |
| 678 | conflict and an error is thrown. If they are of the same scalar type, then |
| 679 | the third arg "use_list1_for_defaults" is used to pick the final one. |
| 680 | ''' |
| 681 | for key, item2 in list2.items(): |
| 682 | item1 = list1.get(key) |
| 683 | if item1 is None: |
| 684 | list1[key] = item2 |
| 685 | continue |
| 686 | |
| 687 | # Both dictionaries have an entry for this key. Are they both lists? If |
| 688 | # so, append. |
| 689 | if isinstance(item1, list) and isinstance(item2, list): |
| 690 | list1[key] = item1 + item2 |
| 691 | continue |
| 692 | |
| 693 | # Are they both dictionaries? If so, recurse. |
| 694 | if isinstance(item1, dict) and isinstance(item2, dict): |
| 695 | _merge_dicts(item1, item2) |
| 696 | continue |
| 697 | |
| 698 | # We treat other types as atoms. If the types of the two items are |
| 699 | # equal pick one or the other (based on use_list1_for_defaults). |
| 700 | if isinstance(item1, type(item2)) and isinstance(item2, type(item1)): |
| 701 | list1[key] = item1 if use_list1_for_defaults else item2 |
| 702 | continue |
| 703 | |
| 704 | # Oh no! We can't merge this. |
| 705 | print("ERROR: Cannot merge dictionaries at key {!r} because items " |
| 706 | "have conflicting types ({} in 1st; {} in 2nd).".format( |
| 707 | key, type(item1), type(item2))) |
| 708 | sys.exit(1) |
| 709 | |
| 710 | return list1 |