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.
20 YAML_PATH = "../adventure.yaml"
21 HTML_TEMPLATE_PATH = "../templates/coverage_dungeon.html.tpl"
22 DEFAULT_HTML_OUTPUT_PATH = "../coverage/adventure.yaml.html"
23 DANGLING = ["ACT_VERSION"]
25 STDOUT_REPORT_CATEGORY = " {name:.<19}: {percent:5.1f}% covered ({covered} of {total})\n"
27 HTML_SUMMARY_ROW = '''
29 <td class="headerItem"><a href="#{name}">{name}:</a></td>
30 <td class="headerCovTableEntry">{total}</td>
31 <td class="headerCovTableEntry">{covered}</td>
32 <td class="headerCovTableEntry">{percent:.1f}%</td>
36 HTML_CATEGORY_SECTION = '''
44 HTML_CATEGORY_HEADER = '''
46 <td class="tableHead" width="60%" colspan="{colspan}">{label}</td>
51 HTML_CATEGORY_HEADER_CELL = '<td class="tableHead" width="15%">{}</td>\n'
53 HTML_CATEGORY_COVERAGE_CELL = '<td class="{}"> </td>\n'
55 HTML_CATEGORY_ROW = '''
57 <td class="coverFile" colspan="{colspan}">{id}</td>
62 def search(needle, haystack):
63 # Search for needle in haystack, first escaping needle for regex, then
64 # replacing %s, %d, etc. with regex wildcards, so the variable messages
65 # within the dungeon definition will actually match
67 if needle == None or needle == "" or needle == "NO_MESSAGE":
68 # if needle is empty, assume we're going to find an empty string
71 needle_san = re.escape(needle) \
72 .replace("\\n", "\n") \
73 .replace("\\t", "\t") \
74 .replace("\%S", ".*") \
75 .replace("\%s", ".*") \
76 .replace("\%d", ".*") \
79 return re.search(needle_san, haystack)
81 def obj_coverage(objects, text, report):
82 # objects have multiple descriptions based on state
83 for i, objouter in enumerate(objects):
84 (obj_name, obj) = objouter
85 if obj["descriptions"]:
86 for j, desc in enumerate(obj["descriptions"]):
87 name = "{}[{}]".format(obj_name, j)
88 if name not in report["messages"]:
89 report["messages"][name] = {"covered" : False}
91 if report["messages"][name]["covered"] != True and search(desc, text):
92 report["messages"][name]["covered"] = True
93 report["covered"] += 1
95 def loc_coverage(locations, text, report):
96 # locations have a long and a short description, that each have to
97 # be checked seperately
98 for name, loc in locations:
99 desc = loc["description"]
100 if name not in report["messages"]:
101 report["messages"][name] = {"long" : False, "short": False}
103 if report["messages"][name]["long"] != True and search(desc["long"], text):
104 report["messages"][name]["long"] = True
105 report["covered"] += 1
106 if report["messages"][name]["short"] != True and search(desc["short"], text):
107 report["messages"][name]["short"] = True
108 report["covered"] += 1
110 def hint_coverage(obituaries, text, report):
111 # hints have a "question" where the hint is offered, followed
112 # by the actual hint if the player requests it
113 for i, hintouter in enumerate(obituaries):
114 hint = hintouter["hint"]
116 if name not in report["messages"]:
117 report["messages"][name] = {"question" : False, "hint": False}
119 if report["messages"][name]["question"] != True and search(hint["question"], text):
120 report["messages"][name]["question"] = True
121 report["covered"] += 1
122 if report["messages"][name]["hint"] != True and search(hint["hint"], text):
123 report["messages"][name]["hint"] = True
124 report["covered"] += 1
126 def obit_coverage(obituaries, text, report):
127 # obituaries have a "query" where it asks the player for a resurrection,
128 # followed by a snarky comment if the player says yes
129 for name, obit in enumerate(obituaries):
130 if name not in report["messages"]:
131 report["messages"][name] = {"query" : False, "yes_response": False}
133 if report["messages"][name]["query"] != True and search(obit["query"], text):
134 report["messages"][name]["query"] = True
135 report["covered"] += 1
136 if report["messages"][name]["yes_response"] != True and search(obit["yes_response"], text):
137 report["messages"][name]["yes_response"] = True
138 report["covered"] += 1
140 def threshold_coverage(classes, text, report):
141 # works for class thresholds and turn threshold, which have a "message"
143 for name, item in enumerate(classes):
144 if name not in report["messages"]:
145 report["messages"][name] = {"covered" : "False"}
147 if report["messages"][name]["covered"] != True and search(item["message"], text):
148 report["messages"][name]["covered"] = True
149 report["covered"] += 1
151 def arb_coverage(arb_msgs, text, report):
152 for name, message in arb_msgs:
153 if name not in report["messages"]:
154 report["messages"][name] = {"covered" : False}
156 if report["messages"][name]["covered"] != True and search(message, text):
157 report["messages"][name]["covered"] = True
158 report["covered"] += 1
160 def actions_coverage(items, text, report):
162 for name, item in items:
163 if name not in report["messages"]:
164 report["messages"][name] = {"covered" : False}
166 if report["messages"][name]["covered"] != True and (search(item["message"], text) or name in DANGLING):
167 report["messages"][name]["covered"] = True
168 report["covered"] += 1
170 def coverage_report(db, check_file_contents):
171 # Create report for each catagory, including total items, number of items
172 # covered, and a list of the covered messages
174 for name in db.keys():
175 # initialize each catagory
177 "name" : name, # convenience for string formatting
183 # search for each message in every test check file
184 for chk in check_file_contents:
185 arb_coverage(db["arbitrary_messages"], chk, report["arbitrary_messages"])
186 hint_coverage(db["hints"], chk, report["hints"])
187 loc_coverage(db["locations"], chk, report["locations"])
188 obit_coverage(db["obituaries"], chk, report["obituaries"])
189 obj_coverage(db["objects"], chk, report["objects"])
190 actions_coverage(db["actions"], chk, report["actions"])
191 threshold_coverage(db["classes"], chk, report["classes"])
192 threshold_coverage(db["turn_thresholds"], chk, report["turn_thresholds"])
196 if __name__ == "__main__":
199 with open(YAML_PATH, "r") as f:
202 print('ERROR: could not load {} ({}})'.format(YAML_PATH, e.strerror))
205 # get contents of all the check files
206 check_file_contents = []
207 for filename in os.listdir(TEST_DIR):
208 if filename.endswith(".chk"):
209 with open(filename, "r") as f:
210 check_file_contents.append(f.read())
212 # run coverage analysis report on dungeon database
213 report = coverage_report(db, check_file_contents)
215 # render report output
218 summary_stdout = "adventure.yaml coverage rate:\n"
219 for name, category in sorted(report.items()):
220 # ignore categories with zero entries
221 if category["total"] > 0:
222 # Calculate percent coverage
223 category["percent"] = (category["covered"] / float(category["total"])) * 100
225 # render section header
226 cat_messages = list(category["messages"].items())
227 cat_keys = cat_messages[0][1].keys()
229 colspan = 10 - len(cat_keys)
231 headers_html += HTML_CATEGORY_HEADER_CELL.format(key)
232 category_html = HTML_CATEGORY_HEADER.format(colspan=colspan, label=category["name"], cells=headers_html)
234 # render message coverage row
235 for message_id, covered in cat_messages:
236 category_html_row = ""
237 for key, value in covered.items():
238 category_html_row += HTML_CATEGORY_COVERAGE_CELL.format("uncovered" if value != True else "covered")
239 category_html += HTML_CATEGORY_ROW.format(id=message_id,colspan=colspan, cells=category_html_row)
240 categories_html += HTML_CATEGORY_SECTION.format(id=name, rows=category_html)
242 # render category summaries
243 summary_stdout += STDOUT_REPORT_CATEGORY.format(**category)
244 summary_html += HTML_SUMMARY_ROW.format(**category)
246 # output some quick report stats
247 print(summary_stdout)
249 if len(sys.argv) > 1:
250 html_output_path = sys.argv[1]
252 html_output_path = DEFAULT_HTML_OUTPUT_PATH
256 with open(HTML_TEMPLATE_PATH, "r") as f:
257 # read in HTML template
258 html_template = f.read()
260 print('ERROR: reading HTML report template failed ({})'.format(e.strerror))
263 # parse template with report and write it out
265 with open(html_output_path, "w") as f:
266 f.write(html_template.format(categories=categories_html, summary=summary_html))
268 print('ERROR: writing HTML report failed ({})'.format(e.strerror))