yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
test_suite_loader.cc
Go to the documentation of this file.
2
3#include <filesystem>
4#include <fstream>
5#include <sstream>
6
7#include "absl/status/status.h"
8#include "absl/strings/ascii.h"
9#include "absl/strings/match.h"
10#include "absl/strings/numbers.h"
11#include "absl/strings/str_cat.h"
12#include "absl/strings/str_split.h"
13#include "absl/strings/string_view.h"
14#include "absl/strings/strip.h"
15#include "util/macro.h"
16
17namespace yaze {
18namespace cli {
19namespace {
20
21using ::absl::string_view;
22
23std::string Trim(string_view value) {
24 return std::string(absl::StripAsciiWhitespace(value));
25}
26
27std::string StripComment(string_view line) {
28 bool in_single_quote = false;
29 bool in_double_quote = false;
30 for (size_t i = 0; i < line.size(); ++i) {
31 char c = line[i];
32 if (c == '\'') {
33 if (!in_double_quote) {
34 in_single_quote = !in_single_quote;
35 }
36 } else if (c == '"') {
37 if (!in_single_quote) {
38 in_double_quote = !in_double_quote;
39 }
40 } else if (c == '#' && !in_single_quote && !in_double_quote) {
41 return std::string(line.substr(0, i));
42 }
43 }
44 return std::string(line);
45}
46
47int CountIndent(string_view line) {
48 int count = 0;
49 for (char c : line) {
50 if (c == ' ') {
51 ++count;
52 } else if (c == '\t') {
53 count += 2; // Treat tab as two spaces for simplicity
54 } else {
55 break;
56 }
57 }
58 return count;
59}
60
61bool ParseKeyValue(string_view input, std::string* key, std::string* value) {
62 size_t colon_pos = input.find(':');
63 if (colon_pos == string_view::npos) {
64 return false;
65 }
66 *key = Trim(input.substr(0, colon_pos));
67 *value = Trim(input.substr(colon_pos + 1));
68 return true;
69}
70
71std::string Unquote(string_view value) {
72 std::string trimmed = Trim(value);
73 if (trimmed.size() >= 2) {
74 if ((trimmed.front() == '"' && trimmed.back() == '"') ||
75 (trimmed.front() == '\'' && trimmed.back() == '\'')) {
76 return std::string(trimmed.substr(1, trimmed.size() - 2));
77 }
78 }
79 return trimmed;
80}
81
82std::vector<std::string> ParseInlineList(string_view value) {
83 std::vector<std::string> items;
84 std::string trimmed = Trim(value);
85 if (trimmed.empty()) {
86 return items;
87 }
88 if (trimmed.front() != '[' || trimmed.back() != ']') {
89 items.push_back(Unquote(trimmed));
90 return items;
91 }
92 string_view inner(trimmed.data() + 1, trimmed.size() - 2);
93 if (inner.empty()) {
94 return items;
95 }
96 for (string_view piece : absl::StrSplit(inner, ',', absl::SkipWhitespace())) {
97 items.push_back(Unquote(piece));
98 }
99 return items;
100}
101
102absl::StatusOr<int> ParseInt(string_view value) {
103 int result = 0;
104 if (!absl::SimpleAtoi(value, &result)) {
105 return absl::InvalidArgumentError(
106 absl::StrCat("Expected integer value, got '", value, "'"));
107 }
108 return result;
109}
110
111absl::StatusOr<int> ParseDurationSeconds(string_view value) {
112 std::string lower = absl::AsciiStrToLower(std::string(value));
113 if (lower.empty()) {
114 return 0;
115 }
116 int multiplier = 1;
117 if (absl::EndsWith(lower, "ms")) {
118 lower = lower.substr(0, lower.size() - 2);
119 multiplier = 0; // Use 0 to indicate sub-second resolution
120 } else if (absl::EndsWith(lower, "s")) {
121 lower = lower.substr(0, lower.size() - 1);
122 } else if (absl::EndsWith(lower, "m")) {
123 lower = lower.substr(0, lower.size() - 1);
124 multiplier = 60;
125 }
126
127 int numeric = 0;
128 if (!absl::SimpleAtoi(lower, &numeric)) {
129 return absl::InvalidArgumentError(
130 absl::StrCat("Invalid duration value '", value, "'"));
131 }
132
133 if (multiplier == 0) {
134 // Round milliseconds up to nearest whole second
135 return (numeric + 999) / 1000;
136 }
137 return numeric * multiplier;
138}
139
140bool ParseBoolean(string_view value, bool* output) {
141 std::string lower = absl::AsciiStrToLower(std::string(value));
142 if (lower == "true" || lower == "yes" || lower == "on" || lower == "1") {
143 *output = true;
144 return true;
145 }
146 if (lower == "false" || lower == "no" || lower == "off" || lower == "0") {
147 *output = false;
148 return true;
149 }
150 return false;
151}
152
153std::string DeriveTestName(const std::string& path) {
154 std::filesystem::path fs_path(path);
155 std::string stem = fs_path.stem().string();
156 if (stem.empty()) {
157 return path;
158 }
159 return stem;
160}
161
162absl::Status ParseScalarConfig(const std::string& key, const std::string& value,
163 TestSuiteConfig* config) {
164 if (key == "timeout_per_test") {
165 ASSIGN_OR_RETURN(int seconds, ParseDurationSeconds(value));
166 config->timeout_seconds = seconds;
167 return absl::OkStatus();
168 }
169 if (key == "retry_on_failure") {
170 ASSIGN_OR_RETURN(int retries, ParseInt(value));
171 config->retry_on_failure = retries;
172 return absl::OkStatus();
173 }
174 if (key == "parallel_execution") {
175 bool enabled = false;
176 if (!ParseBoolean(value, &enabled)) {
177 return absl::InvalidArgumentError(absl::StrCat(
178 "Invalid boolean for parallel_execution: '", value, "'"));
179 }
180 config->parallel_execution = enabled;
181 return absl::OkStatus();
182 }
183 return absl::InvalidArgumentError(
184 absl::StrCat("Unknown config key: '", key, "'"));
185}
186
187absl::Status ParseStringListBlock(const std::vector<std::string>& lines,
188 size_t* index, int base_indent,
189 std::vector<std::string>* output) {
190 while (*index < lines.size()) {
191 std::string raw = StripComment(lines[*index]);
192 std::string trimmed = Trim(raw);
193 int indent = CountIndent(raw);
194 if (trimmed.empty()) {
195 ++(*index);
196 continue;
197 }
198 if (indent < base_indent) {
199 break;
200 }
201 if (indent != base_indent) {
202 return absl::InvalidArgumentError("Invalid indentation in list block");
203 }
204 if (trimmed.empty() || trimmed.front() != '-') {
205 return absl::InvalidArgumentError(
206 "Expected list entry starting with '-'");
207 }
208 std::string value = Trim(trimmed.substr(1));
209 output->push_back(Unquote(value));
210 ++(*index);
211 }
212 return absl::OkStatus();
213}
214
215absl::Status ParseParametersBlock(const std::vector<std::string>& lines,
216 size_t* index, int base_indent,
217 std::map<std::string, std::string>* params) {
218 while (*index < lines.size()) {
219 std::string raw = StripComment(lines[*index]);
220 std::string trimmed = Trim(raw);
221 int indent = CountIndent(raw);
222 if (trimmed.empty()) {
223 ++(*index);
224 continue;
225 }
226 if (indent < base_indent) {
227 break;
228 }
229 if (indent != base_indent) {
230 return absl::InvalidArgumentError(
231 "Invalid indentation in parameters block");
232 }
233 std::string key;
234 std::string value;
235 if (!ParseKeyValue(trimmed, &key, &value)) {
236 return absl::InvalidArgumentError(
237 "Expected key/value pair inside parameters block");
238 }
239 (*params)[key] = Unquote(value);
240 ++(*index);
241 }
242 return absl::OkStatus();
243}
244
245absl::Status ParseTestCaseEntry(const std::vector<std::string>& lines,
246 size_t* index, int base_indent,
247 TestGroupDefinition* group) {
248 const std::string& raw_line = lines[*index];
249 std::string stripped = StripComment(raw_line);
250 int indent = CountIndent(stripped);
251 if (indent != base_indent) {
252 return absl::InvalidArgumentError(
253 "Invalid indentation for test case entry");
254 }
255
256 size_t dash_pos = stripped.find('-');
257 if (dash_pos == std::string::npos) {
258 return absl::InvalidArgumentError("Malformed list entry in tests block");
259 }
260
261 std::string content = Trim(stripped.substr(dash_pos + 1));
263 test.group_name = group->name;
264
265 auto commit_test = [&]() {
266 if (test.script_path.empty()) {
267 return absl::InvalidArgumentError("Test case missing script_path");
268 }
269 if (test.name.empty()) {
270 test.name = DeriveTestName(test.script_path);
271 }
272 if (test.id.empty()) {
273 test.id = absl::StrCat(test.group_name, ":", test.name);
274 }
275 group->tests.push_back(std::move(test));
276 return absl::OkStatus();
277 };
278
279 if (content.empty()) {
280 ++(*index);
281 } else if (content.find(':') == std::string::npos) {
282 test.script_path = Unquote(content);
283 ++(*index);
284 return commit_test();
285 } else {
286 std::string key;
287 std::string value;
288 if (!ParseKeyValue(content, &key, &value)) {
289 return absl::InvalidArgumentError("Malformed key/value in test entry");
290 }
291 if (key == "path" || key == "script" || key == "script_path") {
292 test.script_path = Unquote(value);
293 } else if (key == "name") {
294 test.name = Unquote(value);
295 } else if (key == "description") {
296 test.description = Unquote(value);
297 } else if (key == "id") {
298 test.id = Unquote(value);
299 } else if (key == "tags") {
300 auto tags = ParseInlineList(value);
301 test.tags.insert(test.tags.end(), tags.begin(), tags.end());
302 } else {
303 test.parameters[key] = Unquote(value);
304 }
305 ++(*index);
306 }
307
308 while (*index < lines.size()) {
309 std::string raw = StripComment(lines[*index]);
310 std::string trimmed = Trim(raw);
311 int indent_next = CountIndent(raw);
312 if (trimmed.empty()) {
313 ++(*index);
314 continue;
315 }
316 if (indent_next <= base_indent) {
317 break;
318 }
319 if (indent_next == base_indent + 2) {
320 std::string key;
321 std::string value;
322 if (!ParseKeyValue(trimmed, &key, &value)) {
323 return absl::InvalidArgumentError(
324 "Expected key/value pair in test definition");
325 }
326 if (key == "path" || key == "script" || key == "script_path") {
327 test.script_path = Unquote(value);
328 } else if (key == "name") {
329 test.name = Unquote(value);
330 } else if (key == "description") {
331 test.description = Unquote(value);
332 } else if (key == "id") {
333 test.id = Unquote(value);
334 } else if (key == "tags") {
335 auto tags = ParseInlineList(value);
336 if (tags.empty() && value.empty()) {
337 ++(*index);
339 ParseStringListBlock(lines, index, indent_next + 2, &test.tags));
340 continue;
341 }
342 test.tags.insert(test.tags.end(), tags.begin(), tags.end());
343 } else if (key == "parameters") {
344 if (!value.empty()) {
345 return absl::InvalidArgumentError(
346 "parameters block must be indented on following lines");
347 }
348 ++(*index);
349 RETURN_IF_ERROR(ParseParametersBlock(lines, index, indent_next + 2,
350 &test.parameters));
351 continue;
352 } else {
353 test.parameters[key] = Unquote(value);
354 }
355 ++(*index);
356 } else {
357 return absl::InvalidArgumentError(
358 "Unexpected indentation inside test entry");
359 }
360 }
361
362 return commit_test();
363}
364
365absl::Status ParseTestsBlock(const std::vector<std::string>& lines,
366 size_t* index, int base_indent,
367 TestGroupDefinition* group) {
368 while (*index < lines.size()) {
369 std::string raw = StripComment(lines[*index]);
370 std::string trimmed = Trim(raw);
371 int indent = CountIndent(raw);
372 if (trimmed.empty()) {
373 ++(*index);
374 continue;
375 }
376 if (indent < base_indent) {
377 break;
378 }
379 if (indent != base_indent) {
380 return absl::InvalidArgumentError(
381 "Invalid indentation inside tests block");
382 }
383 RETURN_IF_ERROR(ParseTestCaseEntry(lines, index, base_indent, group));
384 }
385 return absl::OkStatus();
386}
387
388absl::Status ParseGroupEntry(const std::vector<std::string>& lines,
389 size_t* index, TestSuiteDefinition* suite) {
390 const std::string& raw_line = lines[*index];
391 std::string stripped = StripComment(raw_line);
392 int base_indent = CountIndent(stripped);
393
394 size_t dash_pos = stripped.find('-');
395 if (dash_pos == std::string::npos || dash_pos < base_indent) {
396 return absl::InvalidArgumentError("Expected '-' to start group entry");
397 }
398
399 std::string content = Trim(stripped.substr(dash_pos + 1));
401
402 if (!content.empty()) {
403 std::string key;
404 std::string value;
405 if (!ParseKeyValue(content, &key, &value)) {
406 return absl::InvalidArgumentError("Malformed group entry");
407 }
408 if (key == "name") {
409 group.name = Unquote(value);
410 } else if (key == "description") {
411 group.description = Unquote(value);
412 } else if (key == "depends_on") {
413 auto deps = ParseInlineList(value);
414 group.depends_on.insert(group.depends_on.end(), deps.begin(), deps.end());
415 } else {
416 return absl::InvalidArgumentError(
417 absl::StrCat("Unknown field in group entry: '", key, "'"));
418 }
419 }
420
421 ++(*index);
422
423 while (*index < lines.size()) {
424 std::string raw = StripComment(lines[*index]);
425 std::string trimmed = Trim(raw);
426 int indent = CountIndent(raw);
427 if (trimmed.empty()) {
428 ++(*index);
429 continue;
430 }
431 if (indent <= base_indent) {
432 break;
433 }
434 if (indent == base_indent + 2) {
435 std::string key;
436 std::string value;
437 if (!ParseKeyValue(trimmed, &key, &value)) {
438 return absl::InvalidArgumentError(
439 "Expected key/value pair inside group definition");
440 }
441 if (key == "name") {
442 group.name = Unquote(value);
443 ++(*index);
444 } else if (key == "description") {
445 group.description = Unquote(value);
446 ++(*index);
447 } else if (key == "depends_on") {
448 if (!value.empty()) {
449 auto deps = ParseInlineList(value);
450 group.depends_on.insert(group.depends_on.end(), deps.begin(),
451 deps.end());
452 ++(*index);
453 } else {
454 ++(*index);
455 RETURN_IF_ERROR(ParseStringListBlock(lines, index, base_indent + 4,
456 &group.depends_on));
457 }
458 } else if (key == "tests") {
459 if (!value.empty()) {
460 return absl::InvalidArgumentError(
461 "tests block must be defined as indented list");
462 }
463 ++(*index);
464 RETURN_IF_ERROR(ParseTestsBlock(lines, index, base_indent + 4, &group));
465 } else {
466 return absl::InvalidArgumentError(
467 absl::StrCat("Unknown attribute in group definition: '", key, "'"));
468 }
469 } else {
470 return absl::InvalidArgumentError(
471 "Unexpected indentation inside group definition");
472 }
473 }
474
475 if (group.name.empty()) {
476 return absl::InvalidArgumentError("Each test group must define a name");
477 }
478 suite->groups.push_back(std::move(group));
479 return absl::OkStatus();
480}
481
482absl::Status ParseGroupBlock(const std::vector<std::string>& lines,
483 size_t* index, TestSuiteDefinition* suite) {
484 while (*index < lines.size()) {
485 std::string raw = StripComment(lines[*index]);
486 std::string trimmed = Trim(raw);
487 int indent = CountIndent(raw);
488 if (trimmed.empty()) {
489 ++(*index);
490 continue;
491 }
492 if (indent < 2) {
493 break;
494 }
495 if (indent != 2) {
496 return absl::InvalidArgumentError(
497 "Invalid indentation inside test_groups block");
498 }
499 RETURN_IF_ERROR(ParseGroupEntry(lines, index, suite));
500 }
501 return absl::OkStatus();
502}
503
504absl::Status ParseConfigBlock(const std::vector<std::string>& lines,
505 size_t* index, TestSuiteConfig* config) {
506 while (*index < lines.size()) {
507 std::string raw = StripComment(lines[*index]);
508 std::string trimmed = Trim(raw);
509 int indent = CountIndent(raw);
510 if (trimmed.empty()) {
511 ++(*index);
512 continue;
513 }
514 if (indent < 2) {
515 break;
516 }
517 if (indent != 2) {
518 return absl::InvalidArgumentError(
519 "Invalid indentation inside config block");
520 }
521 std::string key;
522 std::string value;
523 if (!ParseKeyValue(trimmed, &key, &value)) {
524 return absl::InvalidArgumentError(
525 "Expected key/value pair inside config block");
526 }
527 RETURN_IF_ERROR(ParseScalarConfig(key, value, config));
528 ++(*index);
529 }
530 return absl::OkStatus();
531}
532
533} // namespace
534
535absl::StatusOr<TestSuiteDefinition> ParseTestSuiteDefinition(
536 absl::string_view content) {
537 std::vector<std::string> lines = absl::StrSplit(content, '\n');
539 size_t index = 0;
540
541 while (index < lines.size()) {
542 std::string raw = StripComment(lines[index]);
543 std::string trimmed = Trim(raw);
544 if (trimmed.empty()) {
545 ++index;
546 continue;
547 }
548
549 int indent = CountIndent(raw);
550 if (indent != 0) {
551 return absl::InvalidArgumentError(
552 "Top-level entries must not be indented in suite definition");
553 }
554
555 std::string key;
556 std::string value;
557 if (!ParseKeyValue(trimmed, &key, &value)) {
558 return absl::InvalidArgumentError(
559 absl::StrCat("Malformed top-level entry: '", trimmed, "'"));
560 }
561
562 if (key == "name") {
563 suite.name = Unquote(value);
564 ++index;
565 } else if (key == "description") {
566 suite.description = Unquote(value);
567 ++index;
568 } else if (key == "version") {
569 suite.version = Unquote(value);
570 ++index;
571 } else if (key == "config") {
572 if (!value.empty()) {
573 return absl::InvalidArgumentError(
574 "config block must not specify inline value");
575 }
576 ++index;
577 RETURN_IF_ERROR(ParseConfigBlock(lines, &index, &suite.config));
578 } else if (key == "test_groups") {
579 if (!value.empty()) {
580 return absl::InvalidArgumentError(
581 "test_groups must be defined as an indented list");
582 }
583 ++index;
584 RETURN_IF_ERROR(ParseGroupBlock(lines, &index, &suite));
585 } else {
586 return absl::InvalidArgumentError(
587 absl::StrCat("Unknown top-level key: '", key, "'"));
588 }
589 }
590
591 if (suite.name.empty()) {
592 suite.name = "Unnamed Suite";
593 }
594 if (suite.version.empty()) {
595 suite.version = "1.0";
596 }
597 return suite;
598}
599
600absl::StatusOr<TestSuiteDefinition> LoadTestSuiteFromFile(
601 const std::string& path) {
602 std::ifstream file(path);
603 if (!file.is_open()) {
604 return absl::NotFoundError(
605 absl::StrCat("Failed to open test suite file '", path, "'"));
606 }
607 std::stringstream buffer;
608 buffer << file.rdbuf();
609 return ParseTestSuiteDefinition(buffer.str());
610}
611
612} // namespace cli
613} // namespace yaze
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
absl::Status ParseGroupEntry(const std::vector< std::string > &lines, size_t *index, TestSuiteDefinition *suite)
absl::Status ParseParametersBlock(const std::vector< std::string > &lines, size_t *index, int base_indent, std::map< std::string, std::string > *params)
std::vector< std::string > ParseInlineList(string_view value)
std::string DeriveTestName(const std::string &path)
absl::Status ParseConfigBlock(const std::vector< std::string > &lines, size_t *index, TestSuiteConfig *config)
bool ParseKeyValue(string_view input, std::string *key, std::string *value)
absl::Status ParseScalarConfig(const std::string &key, const std::string &value, TestSuiteConfig *config)
absl::Status ParseStringListBlock(const std::vector< std::string > &lines, size_t *index, int base_indent, std::vector< std::string > *output)
absl::Status ParseTestsBlock(const std::vector< std::string > &lines, size_t *index, int base_indent, TestGroupDefinition *group)
absl::StatusOr< int > ParseInt(string_view value)
absl::Status ParseGroupBlock(const std::vector< std::string > &lines, size_t *index, TestSuiteDefinition *suite)
absl::StatusOr< int > ParseDurationSeconds(string_view value)
absl::Status ParseTestCaseEntry(const std::vector< std::string > &lines, size_t *index, int base_indent, TestGroupDefinition *group)
bool ParseBoolean(string_view value, bool *output)
absl::StatusOr< TestSuiteDefinition > LoadTestSuiteFromFile(const std::string &path)
absl::StatusOr< TestSuiteDefinition > ParseTestSuiteDefinition(absl::string_view content)
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
std::map< std::string, std::string > parameters
Definition test_suite.h:28
std::vector< std::string > tags
Definition test_suite.h:27
std::vector< TestCaseDefinition > tests
Definition test_suite.h:35
std::vector< std::string > depends_on
Definition test_suite.h:34
std::vector< TestGroupDefinition > groups
Definition test_suite.h:43