3 This is the open-adventure dungeon text coverage report generator. It
4 consumes a YAML description of the dungeon and determines whether the
5 various strings contained are present within the test check files.
7 The default HTML output is appropriate for use with Gitlab CI.
8 You can override it with a command-line argument.
10 The DANGLING list is for actions that should be considered always found
11 even if the checkfile search doesn't find them. Typically this will because
12 they emit a templated message that can't be regression-tested by equality.
21 YAML_PATH = "../adventure.yaml"
22 HTML_TEMPLATE_PATH = "../templates/coverage_dungeon.html.tpl"
23 DEFAULT_HTML_OUTPUT_PATH = "../coverage/adventure.yaml.html"
24 DANGLING = ["ACT_VERSION"]
26 STDOUT_REPORT_CATEGORY = " {name:.<19}: {percent:5.1f}% covered ({covered} of {total})\n"
28 HTML_SUMMARY_ROW = '''
30 <td class="headerItem"><a href="#{name}">{name}:</a></td>
31 <td class="headerCovTableEntry">{total}</td>
32 <td class="headerCovTableEntry">{covered}</td>
33 <td class="headerCovTableEntry">{percent:.1f}%</td>
37 HTML_CATEGORY_SECTION = '''
45 HTML_CATEGORY_HEADER = '''
47 <td class="tableHead" width="60%" colspan="{colspan}">{label}</td>
52 HTML_CATEGORY_HEADER_CELL = '<td class="tableHead" width="15%">{}</td>\n'
54 HTML_CATEGORY_COVERAGE_CELL = '<td class="{}"> </td>\n'
56 HTML_CATEGORY_ROW = '''
58 <td class="coverFile" colspan="{colspan}">{id}</td>
63 def search(needle, haystack):
64 # Search for needle in haystack, first escaping needle for regex, then
65 # replacing %s, %d, etc. with regex wildcards, so the variable messages
66 # within the dungeon definition will actually match
68 if needle is None or needle == "" or needle == "NO_MESSAGE":
69 # if needle is empty, assume we're going to find an empty string
72 needle_san = re.escape(needle) \
73 .replace("\\n", "\n") \
74 .replace("\\t", "\t") \
75 .replace("%S", ".*") \
76 .replace("%s", ".*") \
77 .replace("%d", ".*") \
80 return re.search(needle_san, haystack)
82 def obj_coverage(objects, text, report):
83 # objects have multiple descriptions based on state
84 for _, objouter in enumerate(objects):
85 (obj_name, obj) = objouter
86 if obj["descriptions"]:
87 for j, desc in enumerate(obj["descriptions"]):
88 name = "{}[{}]".format(obj_name, j)
89 if name not in report["messages"]:
90 report["messages"][name] = {"covered" : False}
92 if not report["messages"][name]["covered"] and search(desc, text):
93 report["messages"][name]["covered"] = True
94 report["covered"] += 1
96 def loc_coverage(locations, text, report):
97 # locations have a long and a short description, that each have to
98 # be checked seperately
99 for name, loc in locations:
100 desc = loc["description"]
101 if name not in report["messages"]:
102 report["messages"][name] = {"long" : False, "short": False}
104 if not report["messages"][name]["long"] and search(desc["long"], text):
105 report["messages"][name]["long"] = True
106 report["covered"] += 1
107 if not report["messages"][name]["short"] and search(desc["short"], text):
108 report["messages"][name]["short"] = True
109 report["covered"] += 1
111 def hint_coverage(obituaries, text, report):
112 # hints have a "question" where the hint is offered, followed
113 # by the actual hint if the player requests it
114 for _, hintouter in enumerate(obituaries):
115 hint = hintouter["hint"]
117 if name not in report["messages"]:
118 report["messages"][name] = {"question" : False, "hint": False}
120 if not report["messages"][name]["question"] and search(hint["question"], text):
121 report["messages"][name]["question"] = True
122 report["covered"] += 1
123 if not report["messages"][name]["hint"] and search(hint["hint"], text):
124 report["messages"][name]["hint"] = True
125 report["covered"] += 1
127 def obit_coverage(obituaries, text, report):
128 # obituaries have a "query" where it asks the player for a resurrection,
129 # followed by a snarky comment if the player says yes
130 for name, obit in enumerate(obituaries):
131 if name not in report["messages"]:
132 report["messages"][name] = {"query" : False, "yes_response": False}
134 if not report["messages"][name]["query"] and search(obit["query"], text):
135 report["messages"][name]["query"] = True
136 report["covered"] += 1
137 if not report["messages"][name]["yes_response"] and search(obit["yes_response"], text):
138 report["messages"][name]["yes_response"] = True
139 report["covered"] += 1
141 def threshold_coverage(classes, text, report):
142 # works for class thresholds and turn threshold, which have a "message"
144 for name, item in enumerate(classes):
145 if name not in report["messages"]:
146 report["messages"][name] = {"covered" : "False"}
148 if not report["messages"][name]["covered"] and search(item["message"], text):
149 report["messages"][name]["covered"] = True
150 report["covered"] += 1
152 def arb_coverage(arb_msgs, text, report):
153 for name, message in arb_msgs:
154 if name not in report["messages"]:
155 report["messages"][name] = {"covered" : False}
157 if not report["messages"][name]["covered"] and search(message, text):
158 report["messages"][name]["covered"] = True
159 report["covered"] += 1
161 def actions_coverage(items, text, report):
163 for name, item in items:
164 if name not in report["messages"]:
165 report["messages"][name] = {"covered" : False}
167 if not report["messages"][name]["covered"] and (search(item["message"], text) or name in DANGLING):
168 report["messages"][name]["covered"] = True
169 report["covered"] += 1
171 def coverage_report(db, check_file_contents):
172 # Create report for each catagory, including total items, number of items
173 # covered, and a list of the covered messages
175 for name in db.keys():
176 # initialize each catagory
178 "name" : name, # convenience for string formatting
184 # search for each message in every test check file
185 for chk in check_file_contents:
186 arb_coverage(db["arbitrary_messages"], chk, report["arbitrary_messages"])
187 hint_coverage(db["hints"], chk, report["hints"])
188 loc_coverage(db["locations"], chk, report["locations"])
189 obit_coverage(db["obituaries"], chk, report["obituaries"])
190 obj_coverage(db["objects"], chk, report["objects"])
191 actions_coverage(db["actions"], chk, report["actions"])
192 threshold_coverage(db["classes"], chk, report["classes"])
193 threshold_coverage(db["turn_thresholds"], chk, report["turn_thresholds"])
197 if __name__ == "__main__":
200 with open(YAML_PATH, "r") as f:
201 db = yaml.safe_load(f)
203 print('ERROR: could not load %s (%s)' % (YAML_PATH, e.strerror))
206 # get contents of all the check files
207 check_file_contents = []
208 for filename in os.listdir(TEST_DIR):
209 if filename.endswith(".chk"):
210 with open(filename, "r") as f:
211 check_file_contents.append(f.read())
213 # run coverage analysis report on dungeon database
214 report = coverage_report(db, check_file_contents)
216 # render report output
219 summary_stdout = "adventure.yaml coverage rate:\n"
220 for name, category in sorted(report.items()):
221 # ignore categories with zero entries
222 if category["total"] > 0:
223 # Calculate percent coverage
224 category["percent"] = (category["covered"] / float(category["total"])) * 100
226 # render section header
227 cat_messages = list(category["messages"].items())
228 cat_keys = cat_messages[0][1].keys()
230 colspan = 10 - len(cat_keys)
232 headers_html += HTML_CATEGORY_HEADER_CELL.format(key)
233 category_html = HTML_CATEGORY_HEADER.format(colspan=colspan, label=category["name"], cells=headers_html)
235 # render message coverage row
236 for message_id, covered in cat_messages:
237 category_html_row = ""
238 for key, value in covered.items():
239 category_html_row += HTML_CATEGORY_COVERAGE_CELL.format("uncovered" if not value else "covered")
240 category_html += HTML_CATEGORY_ROW.format(id=message_id,colspan=colspan, cells=category_html_row)
241 categories_html += HTML_CATEGORY_SECTION.format(id=name, rows=category_html)
243 # render category summaries
244 summary_stdout += STDOUT_REPORT_CATEGORY.format(**category)
245 summary_html += HTML_SUMMARY_ROW.format(**category)
247 # output some quick report stats
248 print(summary_stdout)
250 if len(sys.argv) > 1:
251 html_output_path = sys.argv[1]
253 html_output_path = DEFAULT_HTML_OUTPUT_PATH
257 with open(HTML_TEMPLATE_PATH, "r") as f:
258 # read in HTML template
259 html_template = f.read()
261 print('ERROR: reading HTML report template failed ({})'.format(e.strerror))
264 # parse template with report and write it out
266 with open(html_output_path, "w") as f:
267 f.write(html_template.format(categories=categories_html, summary=summary_html))
269 print('ERROR: writing HTML report failed ({})'.format(e.strerror))