From 8a8953933f1e43f13f579a4300c052ba43f9ddde Mon Sep 17 00:00:00 2001 From: Isaac Good Date: Mon, 12 Oct 2026 20:40:14 -0700 Subject: [PATCH] [swift-scheduling] Add new practice exercise --- config.json | 8 + .../swift-scheduling/.docs/instructions.md | 50 ++ .../swift-scheduling/.docs/introduction.md | 6 + .../swift-scheduling/.meta/config.json | 22 + .../swift-scheduling/.meta/example.gd | 68 +++ .../swift-scheduling/.meta/template.j2 | 8 + .../swift-scheduling/.meta/tests.toml | 61 +++ .../swift-scheduling/lib/test_runner.gd | 467 ++++++++++++++++++ .../practice/swift-scheduling/project.godot | 5 + exercises/practice/swift-scheduling/run_tests | 6 + .../swift-scheduling/swift_scheduling.gd | 2 + .../swift-scheduling/swift_scheduling_test.gd | 100 ++++ 12 files changed, 803 insertions(+) create mode 100644 exercises/practice/swift-scheduling/.docs/instructions.md create mode 100644 exercises/practice/swift-scheduling/.docs/introduction.md create mode 100644 exercises/practice/swift-scheduling/.meta/config.json create mode 100644 exercises/practice/swift-scheduling/.meta/example.gd create mode 100644 exercises/practice/swift-scheduling/.meta/template.j2 create mode 100644 exercises/practice/swift-scheduling/.meta/tests.toml create mode 100644 exercises/practice/swift-scheduling/lib/test_runner.gd create mode 100644 exercises/practice/swift-scheduling/project.godot create mode 100755 exercises/practice/swift-scheduling/run_tests create mode 100644 exercises/practice/swift-scheduling/swift_scheduling.gd create mode 100644 exercises/practice/swift-scheduling/swift_scheduling_test.gd diff --git a/config.json b/config.json index 38eb540..9447618 100644 --- a/config.json +++ b/config.json @@ -406,6 +406,14 @@ "prerequisites": [], "difficulty": 5 }, + { + "slug": "swift-scheduling", + "name": "Swift Scheduling", + "uuid": "1708d1f2-f588-43f0-a247-8072efcff288", + "practices": [], + "prerequisites": [], + "difficulty": 6 + }, { "slug": "variable-length-quantity", "name": "Variable Length Quantity", diff --git a/exercises/practice/swift-scheduling/.docs/instructions.md b/exercises/practice/swift-scheduling/.docs/instructions.md new file mode 100644 index 0000000..6423a10 --- /dev/null +++ b/exercises/practice/swift-scheduling/.docs/instructions.md @@ -0,0 +1,50 @@ +# Instructions + +Your task is to convert delivery date descriptions to _actual_ delivery dates, based on when the meeting started. + +There are two types of delivery date descriptions: + +1. Fixed: a predefined set of words. +2. Variable: words that have a variable component, but follow a predefined set of patterns. + +## Fixed delivery date descriptions + +There are three fixed delivery date descriptions: + +- `"NOW"` +- `"ASAP"` (As Soon As Possible) +- `"EOW"` (End Of Week) + +The following table shows how to translate them: + +| Description | Meeting start | Delivery date | +| ----------- | ----------------------------- | ----------------------------------- | +| `"NOW"` | - | Two hours after the meeting started | +| `"ASAP"` | Before 13:00 | Today at 17:00 | +| `"ASAP"` | After or at 13:00 | Tomorrow at 13:00 | +| `"EOW"` | Monday, Tuesday, or Wednesday | Friday at 17:00 | +| `"EOW"` | Thursday or Friday | Sunday at 20:00 | + +## Variable delivery date descriptions + +There are two variable delivery date description patterns: + +- `"M"` (N-th month) +- `"Q"` (N-th quarter) + +| Description | Meeting start | Delivery date | +| ----------- | ------------------------- | --------------------------------------------------------- | +| `"M"` | Before N-th month | At 8:00 on the _first_ workday of this year's N-th month | +| `"M"` | After or in N-th month | At 8:00 on the _first_ workday of next year's N-th month | +| `"Q"` | Before or in N-th quarter | At 8:00 on the _last_ workday of this year's N-th quarter | +| `"Q"` | After N-th quarter | At 8:00 on the _last_ workday of next year's N-th quarter | + +~~~~exercism/note +A workday is a Monday, Tuesday, Wednesday, Thursday, or Friday. + +A year has four quarters, each with three months: +1. January/February/March +2. April/May/June +3. July/August/September +4. October/November/December. +~~~~ diff --git a/exercises/practice/swift-scheduling/.docs/introduction.md b/exercises/practice/swift-scheduling/.docs/introduction.md new file mode 100644 index 0000000..2322f81 --- /dev/null +++ b/exercises/practice/swift-scheduling/.docs/introduction.md @@ -0,0 +1,6 @@ +# Introduction + +This week, it is your turn to take notes in the department's planning meeting. +In this meeting, your boss will set delivery dates for all open work items. +Annoyingly, instead of specifying the _actual_ delivery dates, your boss will only _describe them_ in an abbreviated format. +As many of your colleagues won't be familiar with this corporate lingo, you'll need to convert these delivery date descriptions to actual delivery dates. diff --git a/exercises/practice/swift-scheduling/.meta/config.json b/exercises/practice/swift-scheduling/.meta/config.json new file mode 100644 index 0000000..5a5d2f4 --- /dev/null +++ b/exercises/practice/swift-scheduling/.meta/config.json @@ -0,0 +1,22 @@ +{ + "authors": [ + "IsaacG" + ], + "files": { + "solution": [ + "swift_scheduling.gd" + ], + "test": [ + "swift_scheduling_test.gd", + "run_tests", + "lib/test_runner.gd", + "project.godot" + ], + "example": [ + ".meta/example.gd" + ] + }, + "blurb": "Convert delivery date descriptions to actual delivery dates.", + "source": "Erik Schierboom", + "source_url": "https://github.com/exercism/problem-specifications/pull/2536" +} diff --git a/exercises/practice/swift-scheduling/.meta/example.gd b/exercises/practice/swift-scheduling/.meta/example.gd new file mode 100644 index 0000000..917d127 --- /dev/null +++ b/exercises/practice/swift-scheduling/.meta/example.gd @@ -0,0 +1,68 @@ +const QUARTERS = [[0, 4], [0, 7], [0, 10], [1, 1]] + +func delivery_date(start: String, description: String): + var now_details = Time.get_datetime_dict_from_datetime_string(start, true) + var now_ts = Time.get_unix_time_from_datetime_string(start) + var delivery = null + var offset = 0 + if description == "NOW": + # NOW: Two hours after the meeting started + offset = 2 * 60 * 60 + elif description == "ASAP": + # ASAP Before 12:00 => Today at 17:00 + # ASAP After 12:00 => Tomorrow at 12:00 + if now_details["hour"] <= 12: + now_details["hour"] = 17 + else: + now_details["hour"] = 13 + offset = 24 * 60 * 60 + now_details["minute"] = 0 + now_details["second"] = 0 + elif description == "EOW": + # EOW Monday, Tuesday, Wednesday => Friday at 17:00 + # EOW Thursday or Friday => Sunday at 20:00 + if now_details["weekday"] in [Time.WEEKDAY_MONDAY, Time.WEEKDAY_TUESDAY, Time.WEEKDAY_WEDNESDAY]: + now_details["hour"] = 17 + offset = 24 * 60 * 60 * (Time.WEEKDAY_FRIDAY - now_details["weekday"]) + else: + now_details["hour"] = 20 + offset = 24 * 60 * 60 * (Time.WEEKDAY_SATURDAY + 1 - now_details["weekday"]) + now_details["minute"] = 0 + now_details["second"] = 0 + elif description.ends_with("M"): + # "M" Before N-th month => At 8:00 on the first workday¹ of this year's N-th month + # "M" After or in N-th month => At 8:00 on the first workday¹ of next year's N-th month + var target_month = int(description.rstrip("M")) + if now_details["month"] >= target_month: + now_details["year"] += 1 + now_details["month"] = target_month + now_details["day"] = 1 + now_details["hour"] = 8 + now_details["minute"] = 0 + now_details["second"] = 0 + + # Ensure it is not a weekend -- compute day of week and roll forward to Monday. + now_details = Time.get_datetime_dict_from_unix_time(Time.get_unix_time_from_datetime_dict(now_details)) + if now_details["weekday"] == Time.WEEKDAY_SATURDAY: + offset += 24 * 60 * 60 * 2 + elif now_details["weekday"] == Time.WEEKDAY_SUNDAY: + offset += 24 * 60 * 60 * 1 + elif description[0] == "Q": + # "Q" Before or in N-th quarter² => At 8:00 on the last workday¹ of this year's N-th quarter² + # "Q" After N-th quarter² => At 8:00 on the last workday¹ of next year's N-th quarter² + var quarter = int(description.lstrip("Q")) + var current = ((now_details["month"] - 1) / 3) + 1 + var year_offset = QUARTERS[quarter - 1][0] + var month = QUARTERS[quarter - 1][1] + if current > quarter: + year_offset = 1 + now_details = {"year": now_details["year"] + year_offset, "month": month, "day": 1, "hour": 8, "minute": 0, "second": 0} + now_details = Time.get_datetime_dict_from_unix_time(Time.get_unix_time_from_datetime_dict(now_details) - 24 * 60 * 60 * 1) + + # Ensure it is not a weekend -- compute day of week and roll backward to Friday. + if now_details["weekday"] == Time.WEEKDAY_SATURDAY: + offset = 24 * 60 * 60 * -1 + elif now_details["weekday"] == Time.WEEKDAY_SUNDAY: + offset = 24 * 60 * 60 * -2 + + return Time.get_datetime_string_from_unix_time(Time.get_unix_time_from_datetime_dict(now_details) + offset) diff --git a/exercises/practice/swift-scheduling/.meta/template.j2 b/exercises/practice/swift-scheduling/.meta/template.j2 new file mode 100644 index 0000000..8e7336c --- /dev/null +++ b/exercises/practice/swift-scheduling/.meta/template.j2 @@ -0,0 +1,8 @@ +{% for case in cases %} +func test_{{ case.func_name }}(solution_script): + var got = solution_script.{{ case.property | camel_to_snake }}({{ case.input.meetingStart | togdscript }}, {{ case.input.description | togdscript }}) + var want = {{ case.expected | togdscript }} + return [got, want] + +{% endfor %} + diff --git a/exercises/practice/swift-scheduling/.meta/tests.toml b/exercises/practice/swift-scheduling/.meta/tests.toml new file mode 100644 index 0000000..f2475c9 --- /dev/null +++ b/exercises/practice/swift-scheduling/.meta/tests.toml @@ -0,0 +1,61 @@ +# This is an auto-generated file. +# +# Regenerating this file via `configlet sync` will: +# - Recreate every `description` key/value pair +# - Recreate every `reimplements` key/value pair, where they exist in problem-specifications +# - Remove any `include = true` key/value pair (an omitted `include` key implies inclusion) +# - Preserve any other key/value pair +# +# As user-added comments (using the # character) will be removed when this file +# is regenerated, comments can be added via a `comment` key. + +[1d0e6e72-f370-408c-bc64-5dafa9c6da73] +description = "NOW translates to two hours later" + +[93325e7b-677d-4d96-b017-2582af879dc2] +description = "ASAP before one in the afternoon translates to today at five in the afternoon" + +[cb4252a3-c4c1-41f6-8b8c-e7269733cef8] +description = "ASAP at one in the afternoon translates to tomorrow at one in the afternoon" + +[6fddc1ea-2fe9-4c60-81f7-9220d2f45537] +description = "ASAP after one in the afternoon translates to tomorrow at one in the afternoon" + +[25f46bf9-6d2a-4e95-8edd-f62dd6bc8a6e] +description = "EOW on Monday translates to Friday at five in the afternoon" + +[0b375df5-d198-489e-acee-fd538a768616] +description = "EOW on Tuesday translates to Friday at five in the afternoon" + +[4afbb881-0b5c-46be-94e1-992cdc2a8ca4] +description = "EOW on Wednesday translates to Friday at five in the afternoon" + +[e1341c2b-5e1b-4702-a95c-a01e8e96e510] +description = "EOW on Thursday translates to Sunday at eight in the evening" + +[bbffccf7-97f7-4244-888d-bdd64348fa2e] +description = "EOW on Friday translates to Sunday at eight in the evening" + +[d651fcf4-290e-407c-8107-36b9076f39b2] +description = "EOW translates to leap day" + +[439bf09f-3a0e-44e7-bad5-b7b6d0c4505a] +description = "2M before the second month of this year translates to the first workday of the second month of this year" + +[86d82e83-c481-4fb4-9264-625de7521340] +description = "11M in the eleventh month translates to the first workday of the eleventh month of next year" + +[0d0b8f6a-1915-46f5-a630-1ff06af9da08] +description = "4M in the ninth month translates to the first workday of the fourth month of next year" + +[06d401e3-8461-438f-afae-8d26aa0289e0] +description = "Q1 in the first quarter translates to the last workday of the first quarter of this year" + +[eebd5f32-b16d-4ecd-91a0-584b0364b7ed] +description = "Q4 in the second quarter translates to the last workday of the fourth quarter of this year" + +[c920886c-44ad-4d34-a156-dc4176186581] +description = "Q3 in the fourth quarter translates to the last workday of the third quarter of next year" + +[7c8f1616-be62-417e-bbd5-a60fa743eccc] +description = "Q2 starting in the last month of the second quarter translates to the last workday of the second quarter of this year" diff --git a/exercises/practice/swift-scheduling/lib/test_runner.gd b/exercises/practice/swift-scheduling/lib/test_runner.gd new file mode 100644 index 0000000..9274583 --- /dev/null +++ b/exercises/practice/swift-scheduling/lib/test_runner.gd @@ -0,0 +1,467 @@ +class_name TestRunner +extends SceneTree + +class ErrorReader: + """ErrorReader reads from a STDERR file (cmd 2>/file). + + It filters out the less useful Godot output and handles retrieving lines iteratively. + """ + + const EXPECTED_GODOT_ERRORS = [ + "ERROR: Could not create directory: /root/.local\n at: make_dir_recursive (core/io/dir_access.cpp:180)\n", + "ERROR: Error attempting to create data dir: /root/.local/share/godot/app_userdata/[unnamed project].\n at: ensure_user_data_dir (core/os/os.cpp:344)\n", + "ERROR: Could not create editor data directory: /root/.local/share/godot\n at: EditorPaths (editor/editor_paths.cpp:183)\n", + "ERROR: Could not create directory: /root/.config\n at: make_dir_recursive (core/io/dir_access.cpp:180)\n", + "ERROR: Could not create editor config directory: /root/.config/godot\n at: EditorPaths (editor/editor_paths.cpp:198)\n", + "ERROR: Could not create directory: /root/.cache\n at: make_dir_recursive (core/io/dir_access.cpp:180)\n", + "ERROR: Could not create editor cache directory: /root/.cache/godot\n at: EditorPaths (editor/editor_paths.cpp:219)\n", + "ERROR: Can't save resource to empty path. Provide non-empty path or a Resource with non-empty resource_path.\n at: save (core/io/resource_saver.cpp:105)\n", + "ERROR: Error saving editor settings to \n at: save (editor/editor_settings.cpp:1013)\n", + ] + var already_processed_bytes: int = 0 + var filename: String = "" + var color_sequence = RegEx.create_from_string("\\e\\[\\d[\\d;]*m") + + func _init(filename: String) -> void: + self.already_processed_bytes = 0 + self.filename = filename + if not self.filename.is_empty() and ResourceLoader.exists(self.filename): + self.get_error_message() + + func check() -> Error: + if not FileAccess.file_exists(self.filename): + push_error("STDERR must be redirected to '%s' when using '--json'" % self.filename) + return ERR_FILE_NOT_FOUND + if OS.get_stderr_type() != OS.STD_HANDLE_FILE: + push_error("STDERR must be redirected to '%s' when using '--json'" % self.filename) + return ERR_FILE_NOT_FOUND + return OK + + func get_error_message() -> String: + """ + Checks the contents of the STDERR file, where error output from the + currently running test suite should be stored. If there is any relevant output + there, this method will return it. If the file is empty, an empty string is returned. + + This method will remove previously read output from the returned message. It should + be called after executing every test, to ensure that the message contains only output + relevant for the given test. + + NOTE: running a Godot instance without full access to certain folders in the + home directory (`~/.config`, `~/.local`, `~/.cache`) will result in additional + errors. This happens when GDScript test runner is executed in Docker. However, + these errors do not stop the execution of the program, so they can be ignored. + This method filters them out, leaving only the errors caused by the actual tests. + """ + if self.filename.is_empty(): + return "" + + var error_output = FileAccess.get_file_as_string(self.filename) + error_output = error_output.erase(0, self.already_processed_bytes) + self.already_processed_bytes += len(error_output) + + # By default, Godot's error messages are printed with colors. To include the output in the + # `results.json` file, color markers need to be removed first. + error_output = self.color_sequence.sub(error_output, "", true) + + # Filter out the expected error messages + for expected_error in EXPECTED_GODOT_ERRORS: + error_output = error_output.replace(expected_error, "") + + return error_output + + +class ResultWriter: + + var json_file: String = "" + var stderr: ErrorReader = null + + func _init(json_file: String, stderr: ErrorReader) -> void: + self.json_file = json_file + self.stderr = stderr + + func delete_existing() -> Error: + """ + Removes the previous `results.json` file, to ensure that the current test suite will not use it. + """ + if self.json_file.is_empty(): + return OK + if FileAccess.file_exists(self.json_file): + DirAccess.remove_absolute(self.json_file) + var error_message: String = self.stderr.get_error_message() + if not error_message.is_empty(): + push_error(error_message) + return FAILED + return OK + + func write(results: Dictionary) -> Error: + """ + If output_dir_path is empty, outputs friendly-formatted test results to + stdout. + + Otherwise, writes JSON results to results.json in the given path. + """ + if self.json_file.is_empty(): + return print_friendly_results(results) + else: + return write_results_file(results) + + func print_friendly_results(results: Dictionary) -> Error: + """ + Prints rich results to stdout. + """ + var rich_results = [""] + if results.status == "pass": + rich_results.push_back("[color=yellow]Exercise passed! Nicely done![/color]") + else: + rich_results.push_back("Exercise has at least one failed test:") + for test in results.tests: + if test.status == "pass": + rich_results.push_back(" %s [color=yellow]passed ✔[/color]" % test.name) + else: + rich_results.push_back(" %s [color=pink]failed: %s[/color]" % [test.name, test.message]) + print_rich("\n".join(rich_results)) + return OK + + func write_results_file(results: Dictionary) -> Error: + """ + Saves a dictionary as a `results.json` file in the output directory. The file + is indented with 4 spaces, keys are not sorted. + + The `results` dictionary represents the full output of a single exercise's + test suite, according to the Test Runner Interface documentation: + + https://exercism.org/docs/building/tooling/test-runners/interface + + This method automatically adds `'version' = 2` to the results. + """ + # Due to keys not being sorted, `version` should be inserted first + var full_results = {"version": 2} + full_results.merge(results) + + var pretty_results = JSON.stringify(full_results, " ", false) + + var results_json = FileAccess.open(self.json_file, FileAccess.WRITE) + if results_json == null: + var err = FileAccess.get_open_error() + push_error("Failed to write the file: %s (%s)" % [self.json_file, error_string(err)]) + return err + + results_json.store_string(pretty_results + "\n") + return OK + + +class ArgParser: + """ArgParser reads the cmdline and parses inputs and options.""" + + var solution_script_path: String = "" + var test_suite_script_path: String = "" + var run_all: bool = false + var json: bool = false + var json_file: String = "" + + func usage(message: String) -> void: + print("Usage:") + print("test_runner [--all] [--json filename.json] ") + if not message.is_empty(): + print() + print(message) + + func parse_args() -> Error: + """ + Validates the number of args passed to the script and sets values of the following + global variables, based on the given args: + * `solution_script_path` + * `test_suite_script_path` + """ + var args = OS.get_cmdline_user_args() + var positional: Array = [] + + var idx := 0 + while idx < len(args): + var arg = args[idx] + if arg == "--all": + run_all = true + elif arg.begins_with("--json"): + if arg.contains("="): + var parts = arg.split("=", true, 1) + if parts[0] == "--json": + self.json = true + self.json_file = parts[1] + else: + usage("Invalid flag %s" % arg) + return ERR_INVALID_PARAMETER + elif idx + 1 < len(args): + self.json = true + self.json_file = args[idx + 1] + idx += 1 + else: + usage("Flag %s is missing a filename" % arg) + return ERR_INVALID_PARAMETER + elif arg.begins_with("--"): + usage("Invalid flag %s" % arg) + return ERR_INVALID_PARAMETER + else: + positional.append(arg) + idx += 1 + + if len(positional) != 2: + usage("Expecting 2 positional args but got %d" % len(positional)) + return ERR_INVALID_PARAMETER + + var slug = positional[0] + var solution_dir_path = positional[1] + + # Test folders use dashes, but test files use underscores + var gdscript_path = solution_dir_path.path_join(slug.replace("-", "_")) + + self.solution_script_path = gdscript_path + ".gd" + self.test_suite_script_path = gdscript_path + "_test.gd" + + return OK + + func files() -> Array: + return [self.solution_script_path, self.test_suite_script_path] + + +class Runner: + + var test_suite: Object = null + var solution: Object = null + var stderr: ErrorReader = null + var result_writer: ResultWriter = null + + func _init(test_suite: Object, solution: Object, stderr: ErrorReader, result_writer: ResultWriter) -> void: + self.test_suite = test_suite + self.solution = solution + self.stderr = stderr + self.result_writer = result_writer + + func extract_methods() -> Dictionary: + """Extract the source code of a method from a script.""" + var in_func: bool = false + var parts = [] + var methods: Dictionary = {} + var name: String = "" + var func_re: RegEx = RegEx.create_from_string('^func ([^(]+)\\(') + for line in self.test_suite.get_script().source_code.split("\n"): + if not in_func: + var matched = func_re.search(line) + if matched: + in_func = true + name = matched.get_strings()[1] + parts = [] + elif not line.begins_with("\t") and not line.begins_with(" "): + if in_func: + in_func = false + methods[name] = "\n".join(parts) + else: + parts.append(line) + + return methods + + func get_failed_test_message(actual, expected) -> String: + """ + Generates a message for a failed test. If given values are strings, + this method will wrap them in single quotes, to distinguish from + other types, like integers (e.g. '3' vs 3). + + This method should be used if the actual and expected values for a + given test are different, or if they have different types. + """ + var values = [] + + for value in [actual, expected]: + var formatted_value = str(value) + if typeof(value) == TYPE_STRING: + formatted_value = "'{0}'".format([formatted_value]) + values.append(formatted_value) + + return "Expected output was {1}, actual output was {0}.".format(values) + + func run_tests() -> Array: + """ + Runs the given test suite agains the given solution script. Returns an Array + representing the results of each test case, in the same order that they are defined + in the test suite. Each test result is a Dictionary containing 3 values: + + * `name`: the name of the test case + * `status`: 'pass', 'fail', or 'error' + * `message`: null if the test passed, otherwise it contains the details of the failure/error + + This method will look for methods starting with `test_` in the test script, and + then call them with the solution script as the only argument. The `test_` method's + name will be used as the `name` value for each test result. + + If any stderr output was generated during the execution of the `test_` method, + `status` will be set to 'error'. The test will also receive an 'error' status + if calling the `test_` method returned null (which indicates execution issues). + + Otherwise, the `test_` method should return an Array of 2 elements: the actual value, + and the expected value. The values will be compared and if they are the same, the test will + pass. Otherwise, it will fail, with a relevant message being set by this method. + """ + var test_results = [] + var test_codes = self.extract_methods() + + for method in self.test_suite.get_method_list(): + if not method["name"].begins_with("test_"): + continue + var test_method_name = method["name"] + print("-> " + test_method_name) + var test_method = self.test_suite.get(test_method_name) + + var output = test_method.call(self.solution) + var error_message = self.stderr.get_error_message() + + if output == null: + test_results.append({ + "name": test_method_name, + "status": "error", + "message": error_message, + }) + else: + var actual = output[0] + var expected = output[1] + + var passed = ( + typeof(actual) == typeof(expected) and + actual == expected and + error_message.is_empty() + ) + + var status = "error" + if error_message.is_empty(): + status = "pass" if passed else "fail" + error_message = null if passed else self.get_failed_test_message( + actual, expected + ) + + var test_code = test_codes[method["name"]] + # The code would be cleaner if the test suite actually checked the values. + # Instead, the test suite returns an array with two values which are compared (above). + # This `replace()` hides some of that implementation detail by communicating that the + # array elements are being compared for equality. + test_code = test_code.replace("return [", "assert_equal [") + test_code = test_code.replace("\t", " ") + test_code = test_code.dedent().strip_edges() + + test_results.append({ + "name": test_method_name, + "status": status, + "message": error_message, + "test_code": test_code, + }) + + return test_results + + func run(run_all: bool) -> void: + """ + Executes the current suite against the current solution. Stores the results in the + `results.json` file in the output dir. + """ + var test_results = run_tests() + var results = {} + + # Check if any tests were executed + if len(test_results) == 0: + results = {"status": "error", "message": "No tests were executed.", "tests": []} + else: + results = {"status": "pass", "tests": test_results} + + # If any of the tests failed, change the global status to `fail` + for test_result in test_results: + if test_result["status"] != "pass": + results["status"] = "fail" + break + + if not run_all and results["status"] != "pass": + var success_count: int = 0 + for test_result in test_results: + if test_result["status"] == "pass": + success_count += 1 + + var has_failure = false + var filtered_results = [] + for test_result in test_results: + if test_result["status"] == "pass": + filtered_results.append(test_result) + elif not has_failure: + has_failure = true + test_result["message"] = "Passes %d/%d tests. First failure: %s" % [ + success_count, len(test_results), test_result["message"], + ] + filtered_results.append(test_result) + results["tests"] = filtered_results + + self.result_writer.write(results) + + +func load_script(solution_script_path: String) -> Object: + """Load a script. This helper is needed to catch failures without aborting the error handling.""" + return load(solution_script_path).new() + + +func load_solution_script(solution_script_path: String, result_writer: ResultWriter, stderr: ErrorReader) -> Array: + """ + Loads the solution script and saves it in a global variable. In case of any + issues, this method will save the `results.json` file with `error` status. + """ + # Make sure the STDERR file is empty. + stderr.get_error_message() + var solution_script = load_script(solution_script_path) + + if solution_script == null: + var message: String = "The solution file could not be parsed." + # Extract the error message, up until the "Failed to load" line + # which is specific to the test runner, not the user's solution. + var error_message: String = stderr.get_error_message() + if not error_message.is_empty(): + var msg = [] + var stop = 'ERROR: Failed to load script "%s" with error "Parse error".' % solution_script_path + for line in error_message.split("\n"): + if line == stop: + break + msg.append(line) + message += "\n" + "\n".join(msg) + var results = {"status": "error", "message": message, "tests": []} + result_writer.write(results) + return [null, ERR_PARSE_ERROR] + + return [solution_script, OK] + + +func _init(): + # Calling `quit(1)` doesn't stop the program immediately, so a `return` is necessary. + # That's why errors are checked directly in `_init()`, instead of calling `quit(1)` + # in each method. + var args = ArgParser.new() + if args.parse_args() != OK: + quit(1) + return + for file in args.files(): + if not ResourceLoader.exists(file): + push_error("File %s not found." % file) + quit(1) + return + + var stderr = ErrorReader.new("/tmp/stderr") + if args.json and stderr.check() != OK: + quit(1) + return + + var result_writer = ResultWriter.new(args.json_file, stderr) + if result_writer.delete_existing() != OK: + quit(1) + return + + var r = load_solution_script(args.solution_script_path, result_writer, stderr) + if r[1] != OK: + quit(1) + return + var solution = r[0] + + var test_suite = load(args.test_suite_script_path).new() + + Runner.new(test_suite, solution, stderr, result_writer).run(args.run_all) + quit() diff --git a/exercises/practice/swift-scheduling/project.godot b/exercises/practice/swift-scheduling/project.godot new file mode 100644 index 0000000..266f4cd --- /dev/null +++ b/exercises/practice/swift-scheduling/project.godot @@ -0,0 +1,5 @@ +config_version=5 + +[application] +config/name="Exercism - Swift Scheduling" +config/features=PackedStringArray("4.7") diff --git a/exercises/practice/swift-scheduling/run_tests b/exercises/practice/swift-scheduling/run_tests new file mode 100755 index 0000000..ed18a90 --- /dev/null +++ b/exercises/practice/swift-scheduling/run_tests @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +slug=${PWD##*/} +[[ -f "${slug//-/_}.gd" ]] || die "Error! solution file is missing" +[[ -f "${slug//-/_}_test.gd" ]] || die "Error! test file is missing" +exec godot --headless --script ./lib/test_runner.gd -- "${slug}" "${PWD}" diff --git a/exercises/practice/swift-scheduling/swift_scheduling.gd b/exercises/practice/swift-scheduling/swift_scheduling.gd new file mode 100644 index 0000000..353afd7 --- /dev/null +++ b/exercises/practice/swift-scheduling/swift_scheduling.gd @@ -0,0 +1,2 @@ +func delivery_date(start: String, description: String): + pass diff --git a/exercises/practice/swift-scheduling/swift_scheduling_test.gd b/exercises/practice/swift-scheduling/swift_scheduling_test.gd new file mode 100644 index 0000000..27116e1 --- /dev/null +++ b/exercises/practice/swift-scheduling/swift_scheduling_test.gd @@ -0,0 +1,100 @@ +func test_now_translates_to_two_hours_later(solution_script): + var got = solution_script.delivery_date("2012-02-13T09:00:00", "NOW") + var want = "2012-02-13T11:00:00" + return [got, want] + + +func test_asap_before_one_in_the_afternoon_translates_to_today_at_five_in_the_afternoon(solution_script): + var got = solution_script.delivery_date("1999-06-03T09:45:00", "ASAP") + var want = "1999-06-03T17:00:00" + return [got, want] + + +func test_asap_at_one_in_the_afternoon_translates_to_tomorrow_at_one_in_the_afternoon(solution_script): + var got = solution_script.delivery_date("2008-12-21T13:00:00", "ASAP") + var want = "2008-12-22T13:00:00" + return [got, want] + + +func test_asap_after_one_in_the_afternoon_translates_to_tomorrow_at_one_in_the_afternoon(solution_script): + var got = solution_script.delivery_date("2008-12-21T14:50:00", "ASAP") + var want = "2008-12-22T13:00:00" + return [got, want] + + +func test_eow_on_monday_translates_to_friday_at_five_in_the_afternoon(solution_script): + var got = solution_script.delivery_date("2025-02-03T16:00:00", "EOW") + var want = "2025-02-07T17:00:00" + return [got, want] + + +func test_eow_on_tuesday_translates_to_friday_at_five_in_the_afternoon(solution_script): + var got = solution_script.delivery_date("1997-04-29T10:50:00", "EOW") + var want = "1997-05-02T17:00:00" + return [got, want] + + +func test_eow_on_wednesday_translates_to_friday_at_five_in_the_afternoon(solution_script): + var got = solution_script.delivery_date("2005-09-14T11:00:00", "EOW") + var want = "2005-09-16T17:00:00" + return [got, want] + + +func test_eow_on_thursday_translates_to_sunday_at_eight_in_the_evening(solution_script): + var got = solution_script.delivery_date("2011-05-19T08:30:00", "EOW") + var want = "2011-05-22T20:00:00" + return [got, want] + + +func test_eow_on_friday_translates_to_sunday_at_eight_in_the_evening(solution_script): + var got = solution_script.delivery_date("2022-08-05T14:00:00", "EOW") + var want = "2022-08-07T20:00:00" + return [got, want] + + +func test_eow_translates_to_leap_day(solution_script): + var got = solution_script.delivery_date("2008-02-25T10:30:00", "EOW") + var want = "2008-02-29T17:00:00" + return [got, want] + + +func test_2m_before_the_second_month_of_this_year_translates_to_the_first_workday_of_the_second_month_of_this_year(solution_script): + var got = solution_script.delivery_date("2007-01-02T14:15:00", "2M") + var want = "2007-02-01T08:00:00" + return [got, want] + + +func test_11m_in_the_eleventh_month_translates_to_the_first_workday_of_the_eleventh_month_of_next_year(solution_script): + var got = solution_script.delivery_date("2013-11-21T15:30:00", "11M") + var want = "2014-11-03T08:00:00" + return [got, want] + + +func test_4m_in_the_ninth_month_translates_to_the_first_workday_of_the_fourth_month_of_next_year(solution_script): + var got = solution_script.delivery_date("2019-11-18T15:15:00", "4M") + var want = "2020-04-01T08:00:00" + return [got, want] + + +func test_q1_in_the_first_quarter_translates_to_the_last_workday_of_the_first_quarter_of_this_year(solution_script): + var got = solution_script.delivery_date("2003-01-01T10:45:00", "Q1") + var want = "2003-03-31T08:00:00" + return [got, want] + + +func test_q4_in_the_second_quarter_translates_to_the_last_workday_of_the_fourth_quarter_of_this_year(solution_script): + var got = solution_script.delivery_date("2001-04-09T09:00:00", "Q4") + var want = "2001-12-31T08:00:00" + return [got, want] + + +func test_q3_in_the_fourth_quarter_translates_to_the_last_workday_of_the_third_quarter_of_next_year(solution_script): + var got = solution_script.delivery_date("2022-10-06T11:00:00", "Q3") + var want = "2023-09-29T08:00:00" + return [got, want] + + +func test_q2_starting_in_the_last_month_of_the_second_quarter_translates_to_the_last_workday_of_the_second_quarter_of_this_year(solution_script): + var got = solution_script.delivery_date("2019-06-15T09:50:00", "Q2") + var want = "2019-06-28T08:00:00" + return [got, want]