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 lists are for actions and messages that should be
11 considered always found even if the checkfile search doesn't find them.
12 Typically this will because an action emit a templated message that
13 can't be regression-tested by equality.
16 # pylint: disable=consider-using-f-string,line-too-long,invalid-name,missing-function-docstring,redefined-outer-name
24 YAML_PATH = "../adventure.yaml"
25 HTML_TEMPLATE_PATH = "../templates/coverage_dungeon.html.tpl"
26 DEFAULT_HTML_OUTPUT_PATH = "../coverage/adventure.yaml.html"
27 DANGLING_ACTIONS = ["ACT_VERSION"]
28 DANGLING_MESSAGES = ["SAVERESUME_DISABLED", "SAVE_TAMPERING"]
30 STDOUT_REPORT_CATEGORY = " {name:.<19}: {percent:5.1f}% covered ({covered} of {total})\n"
32 HTML_SUMMARY_ROW = '''
34 <td class="headerItem"><a href="#{name}">{name}:</a></td>
35 <td class="headerCovTableEntry">{total}</td>
36 <td class="headerCovTableEntry">{covered}</td>
37 <td class="headerCovTableEntry">{percent:.1f}%</td>
41 HTML_CATEGORY_SECTION = '''
49 HTML_CATEGORY_HEADER = '''
51 <td class="tableHead" width="60%" colspan="{colspan}">{label}</td>
56 HTML_CATEGORY_HEADER_CELL = '<td class="tableHead" width="15%">{}</td>\n'
58 HTML_CATEGORY_COVERAGE_CELL = '<td class="{}"> </td>\n'
60 HTML_CATEGORY_ROW = '''
62 <td class="coverFile" colspan="{colspan}">{id}</td>
67 def search(needle, haystack):
68 # Search for needle in haystack, first escaping needle for regex, then
69 # replacing %s, %d, etc. with regex wildcards, so the variable messages
70 # within the dungeon definition will actually match
72 if needle is None or needle == "" or needle == "NO_MESSAGE":
73 # if needle is empty, assume we're going to find an empty string
76 needle_san = re.escape(needle) \
77 .replace("\\n", "\n") \
78 .replace("\\t", "\t") \
79 .replace("%S", ".*") \
80 .replace("%s", ".*") \
81 .replace("%d", ".*") \
84 return re.search(needle_san, haystack)
86 def obj_coverage(objects, text, report):
87 # objects have multiple descriptions based on state
88 for _, objouter in enumerate(objects):
89 (obj_name, obj) = objouter
90 if obj["descriptions"]:
91 for j, desc in enumerate(obj["descriptions"]):
92 name = "{}[{}]".format(obj_name, j)
93 if name not in report["messages"]:
94 report["messages"][name] = {"covered" : False}
96 if not report["messages"][name]["covered"] and search(desc, text):
97 report["messages"][name]["covered"] = True
98 report["covered"] += 1
100 def loc_coverage(locations, text, report):
101 # locations have a long and a short description, that each have to
102 # be checked seperately
103 for name, loc in locations:
104 desc = loc["description"]
105 if name not in report["messages"]:
106 report["messages"][name] = {"long" : False, "short": False}
108 if not report["messages"][name]["long"] and search(desc["long"], text):
109 report["messages"][name]["long"] = True
110 report["covered"] += 1
111 if not report["messages"][name]["short"] and search(desc["short"], text):
112 report["messages"][name]["short"] = True
113 report["covered"] += 1
115 def hint_coverage(obituaries, text, report):
116 # hints have a "question" where the hint is offered, followed
117 # by the actual hint if the player requests it
118 for _, hintouter in enumerate(obituaries):
119 hint = hintouter["hint"]
121 if name not in report["messages"]:
122 report["messages"][name] = {"question" : False, "hint": False}
124 if not report["messages"][name]["question"] and search(hint["question"], text):
125 report["messages"][name]["question"] = True
126 report["covered"] += 1
127 if not report["messages"][name]["hint"] and search(hint["hint"], text):
128 report["messages"][name]["hint"] = True
129 report["covered"] += 1
131 def obit_coverage(obituaries, text, report):
132 # obituaries have a "query" where it asks the player for a resurrection,
133 # followed by a snarky comment if the player says yes
134 for name, obit in enumerate(obituaries):
135 if name not in report["messages"]:
136 report["messages"][name] = {"query" : False, "yes_response": False}
138 if not report["messages"][name]["query"] and search(obit["query"], text):
139 report["messages"][name]["query"] = True
140 report["covered"] += 1
141 if not report["messages"][name]["yes_response"] and search(obit["yes_response"], text):
142 report["messages"][name]["yes_response"] = True
143 report["covered"] += 1
145 def threshold_coverage(classes, text, report):
146 # works for class thresholds and turn threshold, which have a "message"
148 for name, item in enumerate(classes):
149 if name not in report["messages"]:
150 report["messages"][name] = {"covered" : False}
152 if not report["messages"][name]["covered"] and search(item["message"], text):
153 report["messages"][name]["covered"] = True
154 report["covered"] += 1
156 def arb_coverage(arb_msgs, text, report):
157 for name, message in arb_msgs:
158 if name not in report["messages"]:
159 report["messages"][name] = {"covered" : False}
161 if not report["messages"][name]["covered"] and (search(message, text) or name in DANGLING_MESSAGES):
162 report["messages"][name]["covered"] = True
163 report["covered"] += 1
165 def actions_coverage(items, text, report):
167 for name, item in items:
168 if name not in report["messages"]:
169 report["messages"][name] = {"covered" : False}
171 if not report["messages"][name]["covered"] and (search(item["message"], text) or name in DANGLING_ACTIONS):
172 report["messages"][name]["covered"] = True
173 report["covered"] += 1
175 def coverage_report(db, check_file_contents):
176 # Create report for each catagory, including total items, number of items
177 # covered, and a list of the covered messages
179 for name in db.keys():
180 # initialize each catagory
182 "name" : name, # convenience for string formatting
188 # search for each message in every test check file
189 for chk in check_file_contents:
190 arb_coverage(db["arbitrary_messages"], chk, report["arbitrary_messages"])
191 hint_coverage(db["hints"], chk, report["hints"])
192 loc_coverage(db["locations"], chk, report["locations"])
193 obit_coverage(db["obituaries"], chk, report["obituaries"])
194 obj_coverage(db["objects"], chk, report["objects"])
195 actions_coverage(db["actions"], chk, report["actions"])
196 threshold_coverage(db["classes"], chk, report["classes"])
197 threshold_coverage(db["turn_thresholds"], chk, report["turn_thresholds"])
201 if __name__ == "__main__":
204 with open(YAML_PATH, "r", encoding='ascii', errors='surrogateescape') as f:
205 db = yaml.safe_load(f)
207 print('ERROR: could not load %s (%s)' % (YAML_PATH, e.strerror))
210 # get contents of all the check files
211 check_file_contents = []
212 for filename in os.listdir(TEST_DIR):
213 if filename.endswith(".chk"):
214 with open(filename, "r", encoding='ascii', errors='surrogateescape') as f:
215 check_file_contents.append(f.read())
217 # run coverage analysis report on dungeon database
218 report = coverage_report(db, check_file_contents)
220 # render report output
223 summary_stdout = "adventure.yaml coverage rate:\n"
224 for name, category in sorted(report.items()):
225 # ignore categories with zero entries
226 if category["total"] > 0:
227 # Calculate percent coverage
228 category["percent"] = (category["covered"] / float(category["total"])) * 100
230 # render section header
231 cat_messages = list(category["messages"].items())
232 cat_keys = cat_messages[0][1].keys()
234 colspan = 10 - len(cat_keys)
236 headers_html += HTML_CATEGORY_HEADER_CELL.format(key)
237 category_html = HTML_CATEGORY_HEADER.format(colspan=colspan, label=category["name"], cells=headers_html)
239 # render message coverage row
240 for message_id, covered in cat_messages:
241 category_html_row = ""
242 for key, value in covered.items():
243 category_html_row += HTML_CATEGORY_COVERAGE_CELL.format("uncovered" if not value else "covered")
244 category_html += HTML_CATEGORY_ROW.format(id=message_id,colspan=colspan, cells=category_html_row)
245 categories_html += HTML_CATEGORY_SECTION.format(id=name, rows=category_html)
247 # render category summaries
248 summary_stdout += STDOUT_REPORT_CATEGORY.format(**category)
249 summary_html += HTML_SUMMARY_ROW.format(**category)
251 # output some quick report stats
252 print(summary_stdout)
254 if len(sys.argv) > 1:
255 html_output_path = sys.argv[1]
257 html_output_path = DEFAULT_HTML_OUTPUT_PATH
261 with open(HTML_TEMPLATE_PATH, "r", encoding='ascii', errors='surrogateescape') as f:
262 # read in HTML template
263 html_template = f.read()
265 print('ERROR: reading HTML report template failed ({})'.format(e.strerror))
268 # parse template with report and write it out
270 with open(html_output_path, "w", encoding='ascii', errors='surrogateescape') as f:
271 f.write(html_template.format(categories=categories_html, summary=summary_html))
273 print('ERROR: writing HTML report failed ({})'.format(e.strerror))