From e4c9a25279f573dd885aa1f5bbfc8de5fb859196 Mon Sep 17 00:00:00 2001 From: Isaac Good Date: Sun, 25 Oct 2026 22:37:30 -0700 Subject: [PATCH 1/2] [custom-set] Add new practice exercise --- config.json | 8 + .../practice/custom-set/.docs/instructions.md | 7 + .../practice/custom-set/.meta/config.json | 20 + .../practice/custom-set/.meta/example.gd | 57 ++ .../practice/custom-set/.meta/template.j2 | 24 + .../practice/custom-set/.meta/tests.toml | 130 +++++ exercises/practice/custom-set/custom_set.gd | 31 + .../practice/custom-set/custom_set_test.gd | 310 ++++++++++ .../practice/custom-set/lib/test_runner.gd | 541 ++++++++++++++++++ exercises/practice/custom-set/project.godot | 5 + exercises/practice/custom-set/run_tests | 6 + 11 files changed, 1139 insertions(+) create mode 100644 exercises/practice/custom-set/.docs/instructions.md create mode 100644 exercises/practice/custom-set/.meta/config.json create mode 100644 exercises/practice/custom-set/.meta/example.gd create mode 100644 exercises/practice/custom-set/.meta/template.j2 create mode 100644 exercises/practice/custom-set/.meta/tests.toml create mode 100644 exercises/practice/custom-set/custom_set.gd create mode 100644 exercises/practice/custom-set/custom_set_test.gd create mode 100644 exercises/practice/custom-set/lib/test_runner.gd create mode 100644 exercises/practice/custom-set/project.godot create mode 100755 exercises/practice/custom-set/run_tests diff --git a/config.json b/config.json index ac95069e..0cd046e9 100644 --- a/config.json +++ b/config.json @@ -430,6 +430,14 @@ "prerequisites": [], "difficulty": 5 }, + { + "slug": "custom-set", + "name": "Custom Set", + "uuid": "603c4faf-4246-4ede-aaba-97ee56986d00", + "practices": [], + "prerequisites": [], + "difficulty": 5 + }, { "slug": "game-of-life", "name": "Conway's Game of Life", diff --git a/exercises/practice/custom-set/.docs/instructions.md b/exercises/practice/custom-set/.docs/instructions.md new file mode 100644 index 00000000..33b90e28 --- /dev/null +++ b/exercises/practice/custom-set/.docs/instructions.md @@ -0,0 +1,7 @@ +# Instructions + +Create a custom set type. + +Sometimes it is necessary to define a custom data structure of some type, like a set. +In this exercise you will define your own set. +How it works internally doesn't matter, as long as it behaves like a set of unique elements. diff --git a/exercises/practice/custom-set/.meta/config.json b/exercises/practice/custom-set/.meta/config.json new file mode 100644 index 00000000..94a6dcee --- /dev/null +++ b/exercises/practice/custom-set/.meta/config.json @@ -0,0 +1,20 @@ +{ + "authors": [ + "IsaacG" + ], + "files": { + "solution": [ + "custom_set.gd" + ], + "test": [ + "custom_set_test.gd", + "run_tests", + "lib/test_runner.gd", + "project.godot" + ], + "example": [ + ".meta/example.gd" + ] + }, + "blurb": "Create a custom set type." +} diff --git a/exercises/practice/custom-set/.meta/example.gd b/exercises/practice/custom-set/.meta/example.gd new file mode 100644 index 00000000..901dcf8c --- /dev/null +++ b/exercises/practice/custom-set/.meta/example.gd @@ -0,0 +1,57 @@ +class Set: + + var _elements = {} + + func _init(elements): + for element in elements: + _elements[element] = null + + func empty(): + return len(_elements) == 0 + + func contains(element): + return element in _elements + + func subset(other): + for element in _elements: + if not other.contains(element): + return false + return true + + func disjoint(other): + for element in _elements: + if other.contains(element): + return false + return true + + func equal(other): + return subset(other) and other.subset(self) + + func add(element): + var elements = [] + for e in _elements: + elements.append(e) + elements.append(element) + return Set.new(elements) + + func intersection(other): + var elements = [] + for element in _elements: + if other.contains(element): + elements.append(element) + return Set.new(elements) + + func difference(other): + var elements = [] + for element in _elements: + if not other.contains(element): + elements.append(element) + return Set.new(elements) + + func union(other): + var elements = [] + for element in self._elements: + elements.append(element) + for element in other._elements: + elements.append(element) + return Set.new(elements) diff --git a/exercises/practice/custom-set/.meta/template.j2 b/exercises/practice/custom-set/.meta/template.j2 new file mode 100644 index 00000000..3f938805 --- /dev/null +++ b/exercises/practice/custom-set/.meta/template.j2 @@ -0,0 +1,24 @@ +{% for case in cases %} +func test_{{ case.func_name }}(solution_script): +{%- if "set" in case.input %} + var set = solution_script.Set.new({{ case.input.set | togdscript }}) + {%- if "element" in case.input %} + var got = set.{{ case.property }}({{ case.input.element }}) + {%- else %} + var got = set.{{ case.property }}() + {%- endif %} +{%- else %} + var set_one = solution_script.Set.new({{ case.input.set1 | togdscript }}) + var set_two = solution_script.Set.new({{ case.input.set2 | togdscript }}) + var got = set_one.{{ case.property }}(set_two) +{%- endif %} +{%- if case.expected is sequence and case.expected is not string%} + var want = solution_script.Set.new({{ case.expected | togdscript }}) + return [got.equal(want), true] +{%- else %} + var want = {{ case.expected }} + return [got, want] +{%- endif %} + +{% endfor %} + diff --git a/exercises/practice/custom-set/.meta/tests.toml b/exercises/practice/custom-set/.meta/tests.toml new file mode 100644 index 00000000..430c139e --- /dev/null +++ b/exercises/practice/custom-set/.meta/tests.toml @@ -0,0 +1,130 @@ +# 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. + +[20c5f855-f83a-44a7-abdd-fe75c6cf022b] +description = "Returns true if the set contains no elements -> sets with no elements are empty" + +[d506485d-5706-40db-b7d8-5ceb5acf88d2] +description = "Returns true if the set contains no elements -> sets with elements are not empty" + +[759b9740-3417-44c3-8ca3-262b3c281043] +description = "Sets can report if they contain an element -> nothing is contained in an empty set" + +[f83cd2d1-2a85-41bc-b6be-80adbff4be49] +description = "Sets can report if they contain an element -> when the element is in the set" + +[93423fc0-44d0-4bc0-a2ac-376de8d7af34] +description = "Sets can report if they contain an element -> when the element is not in the set" + +[c392923a-637b-4495-b28e-34742cd6157a] +description = "A set is a subset if all of its elements are contained in the other set -> empty set is a subset of another empty set" + +[5635b113-be8c-4c6f-b9a9-23c485193917] +description = "A set is a subset if all of its elements are contained in the other set -> empty set is a subset of non-empty set" + +[832eda58-6d6e-44e2-92c2-be8cf0173cee] +description = "A set is a subset if all of its elements are contained in the other set -> non-empty set is not a subset of empty set" + +[c830c578-8f97-4036-b082-89feda876131] +description = "A set is a subset if all of its elements are contained in the other set -> set is a subset of set with exact same elements" + +[476a4a1c-0fd1-430f-aa65-5b70cbc810c5] +description = "A set is a subset if all of its elements are contained in the other set -> set is a subset of larger set with same elements" + +[d2498999-3e46-48e4-9660-1e20c3329d3d] +description = "A set is a subset if all of its elements are contained in the other set -> set is not a subset of set that does not contain its elements" + +[7d38155e-f472-4a7e-9ad8-5c1f8f95e4cc] +description = "Sets are disjoint if they share no elements -> the empty set is disjoint with itself" + +[7a2b3938-64b6-4b32-901a-fe16891998a6] +description = "Sets are disjoint if they share no elements -> empty set is disjoint with non-empty set" + +[589574a0-8b48-48ea-88b0-b652c5fe476f] +description = "Sets are disjoint if they share no elements -> non-empty set is disjoint with empty set" + +[febeaf4f-f180-4499-91fa-59165955a523] +description = "Sets are disjoint if they share no elements -> sets are not disjoint if they share an element" + +[0de20d2f-c952-468a-88c8-5e056740f020] +description = "Sets are disjoint if they share no elements -> sets are disjoint if they share no elements" + +[4bd24adb-45da-4320-9ff6-38c044e9dff8] +description = "Sets with the same elements are equal -> empty sets are equal" + +[f65c0a0e-6632-4b2d-b82c-b7c6da2ec224] +description = "Sets with the same elements are equal -> empty set is not equal to non-empty set" + +[81e53307-7683-4b1e-a30c-7e49155fe3ca] +description = "Sets with the same elements are equal -> non-empty set is not equal to empty set" + +[d57c5d7c-a7f3-48cc-a162-6b488c0fbbd0] +description = "Sets with the same elements are equal -> sets with the same elements are equal" + +[dd61bafc-6653-42cc-961a-ab071ee0ee85] +description = "Sets with the same elements are equal -> sets with different elements are not equal" + +[06059caf-9bf4-425e-aaff-88966cb3ea14] +description = "Sets with the same elements are equal -> set is not equal to larger set with same elements" + +[d4a1142f-09aa-4df9-8b83-4437dcf7ec24] +description = "Sets with the same elements are equal -> set is equal to a set constructed from an array with duplicates" + +[8a677c3c-a658-4d39-bb88-5b5b1a9659f4] +description = "Unique elements can be added to a set -> add to empty set" + +[0903dd45-904d-4cf2-bddd-0905e1a8d125] +description = "Unique elements can be added to a set -> add to non-empty set" + +[b0eb7bb7-5e5d-4733-b582-af771476cb99] +description = "Unique elements can be added to a set -> adding an existing element does not change the set" + +[893d5333-33b8-4151-a3d4-8f273358208a] +description = "Intersection returns a set of all shared elements -> intersection of two empty sets is an empty set" + +[d739940e-def2-41ab-a7bb-aaf60f7d782c] +description = "Intersection returns a set of all shared elements -> intersection of an empty set and non-empty set is an empty set" + +[3607d9d8-c895-4d6f-ac16-a14956e0a4b7] +description = "Intersection returns a set of all shared elements -> intersection of a non-empty set and an empty set is an empty set" + +[b5120abf-5b5e-41ab-aede-4de2ad85c34e] +description = "Intersection returns a set of all shared elements -> intersection of two sets with no shared elements is an empty set" + +[af21ca1b-fac9-499c-81c0-92a591653d49] +description = "Intersection returns a set of all shared elements -> intersection of two sets with shared elements is a set of the shared elements" + +[c5e6e2e4-50e9-4bc2-b89f-c518f015b57e] +description = "Difference (or Complement) of a set is a set of all elements that are only in the first set -> difference of two empty sets is an empty set" + +[2024cc92-5c26-44ed-aafd-e6ca27d6fcd2] +description = "Difference (or Complement) of a set is a set of all elements that are only in the first set -> difference of empty set and non-empty set is an empty set" + +[e79edee7-08aa-4c19-9382-f6820974b43e] +description = "Difference (or Complement) of a set is a set of all elements that are only in the first set -> difference of a non-empty set and an empty set is the non-empty set" + +[c5ac673e-d707-4db5-8d69-7082c3a5437e] +description = "Difference (or Complement) of a set is a set of all elements that are only in the first set -> difference of two non-empty sets is a set of elements that are only in the first set" + +[20d0a38f-7bb7-4c4a-ac15-90c7392ecf2b] +description = "Difference (or Complement) of a set is a set of all elements that are only in the first set -> difference removes all duplicates in the first set" + +[c45aed16-5494-455a-9033-5d4c93589dc6] +description = "Union returns a set of all elements in either set -> union of empty sets is an empty set" + +[9d258545-33c2-4fcb-a340-9f8aa69e7a41] +description = "Union returns a set of all elements in either set -> union of an empty set and non-empty set is the non-empty set" + +[3aade50c-80c7-4db8-853d-75bac5818b83] +description = "Union returns a set of all elements in either set -> union of a non-empty set and empty set is the non-empty set" + +[a00bb91f-c4b4-4844-8f77-c73e2e9df77c] +description = "Union returns a set of all elements in either set -> union of non-empty sets contains all unique elements" diff --git a/exercises/practice/custom-set/custom_set.gd b/exercises/practice/custom-set/custom_set.gd new file mode 100644 index 00000000..725e3c65 --- /dev/null +++ b/exercises/practice/custom-set/custom_set.gd @@ -0,0 +1,31 @@ +class Set: + + func _init(elements): + pass + + func empty(): + pass + + func contains(element): + pass + + func subset(other): + pass + + func disjoint(other): + pass + + func equal(other): + pass + + func add(element): + pass + + func intersection(other): + pass + + func difference(other): + pass + + func union(other): + pass diff --git a/exercises/practice/custom-set/custom_set_test.gd b/exercises/practice/custom-set/custom_set_test.gd new file mode 100644 index 00000000..b0923d2b --- /dev/null +++ b/exercises/practice/custom-set/custom_set_test.gd @@ -0,0 +1,310 @@ +func test_sets_with_no_elements_are_empty(solution_script): + var set = solution_script.Set.new([]) + var got = set.empty() + var want = true + return [got, want] + + +func test_sets_with_elements_are_not_empty(solution_script): + var set = solution_script.Set.new([1]) + var got = set.empty() + var want = false + return [got, want] + + +func test_nothing_is_contained_in_an_empty_set(solution_script): + var set = solution_script.Set.new([]) + var got = set.contains(1) + var want = false + return [got, want] + + +func test_when_the_element_is_in_the_set(solution_script): + var set = solution_script.Set.new([1, 2, 3]) + var got = set.contains(1) + var want = true + return [got, want] + + +func test_when_the_element_is_not_in_the_set(solution_script): + var set = solution_script.Set.new([1, 2, 3]) + var got = set.contains(4) + var want = false + return [got, want] + + +func test_empty_set_is_a_subset_of_another_empty_set(solution_script): + var set_one = solution_script.Set.new([]) + var set_two = solution_script.Set.new([]) + var got = set_one.subset(set_two) + var want = true + return [got, want] + + +func test_empty_set_is_a_subset_of_non_empty_set(solution_script): + var set_one = solution_script.Set.new([]) + var set_two = solution_script.Set.new([1]) + var got = set_one.subset(set_two) + var want = true + return [got, want] + + +func test_non_empty_set_is_not_a_subset_of_empty_set(solution_script): + var set_one = solution_script.Set.new([1]) + var set_two = solution_script.Set.new([]) + var got = set_one.subset(set_two) + var want = false + return [got, want] + + +func test_set_is_a_subset_of_set_with_exact_same_elements(solution_script): + var set_one = solution_script.Set.new([1, 2, 3]) + var set_two = solution_script.Set.new([1, 2, 3]) + var got = set_one.subset(set_two) + var want = true + return [got, want] + + +func test_set_is_a_subset_of_larger_set_with_same_elements(solution_script): + var set_one = solution_script.Set.new([1, 2, 3]) + var set_two = solution_script.Set.new([4, 1, 2, 3]) + var got = set_one.subset(set_two) + var want = true + return [got, want] + + +func test_set_is_not_a_subset_of_set_that_does_not_contain_its_elements(solution_script): + var set_one = solution_script.Set.new([1, 2, 3]) + var set_two = solution_script.Set.new([4, 1, 3]) + var got = set_one.subset(set_two) + var want = false + return [got, want] + + +func test_the_empty_set_is_disjoint_with_itself(solution_script): + var set_one = solution_script.Set.new([]) + var set_two = solution_script.Set.new([]) + var got = set_one.disjoint(set_two) + var want = true + return [got, want] + + +func test_empty_set_is_disjoint_with_non_empty_set(solution_script): + var set_one = solution_script.Set.new([]) + var set_two = solution_script.Set.new([1]) + var got = set_one.disjoint(set_two) + var want = true + return [got, want] + + +func test_non_empty_set_is_disjoint_with_empty_set(solution_script): + var set_one = solution_script.Set.new([1]) + var set_two = solution_script.Set.new([]) + var got = set_one.disjoint(set_two) + var want = true + return [got, want] + + +func test_sets_are_not_disjoint_if_they_share_an_element(solution_script): + var set_one = solution_script.Set.new([1, 2]) + var set_two = solution_script.Set.new([2, 3]) + var got = set_one.disjoint(set_two) + var want = false + return [got, want] + + +func test_sets_are_disjoint_if_they_share_no_elements(solution_script): + var set_one = solution_script.Set.new([1, 2]) + var set_two = solution_script.Set.new([3, 4]) + var got = set_one.disjoint(set_two) + var want = true + return [got, want] + + +func test_empty_sets_are_equal(solution_script): + var set_one = solution_script.Set.new([]) + var set_two = solution_script.Set.new([]) + var got = set_one.equal(set_two) + var want = true + return [got, want] + + +func test_empty_set_is_not_equal_to_non_empty_set(solution_script): + var set_one = solution_script.Set.new([]) + var set_two = solution_script.Set.new([1, 2, 3]) + var got = set_one.equal(set_two) + var want = false + return [got, want] + + +func test_non_empty_set_is_not_equal_to_empty_set(solution_script): + var set_one = solution_script.Set.new([1, 2, 3]) + var set_two = solution_script.Set.new([]) + var got = set_one.equal(set_two) + var want = false + return [got, want] + + +func test_sets_with_the_same_elements_are_equal(solution_script): + var set_one = solution_script.Set.new([1, 2]) + var set_two = solution_script.Set.new([2, 1]) + var got = set_one.equal(set_two) + var want = true + return [got, want] + + +func test_sets_with_different_elements_are_not_equal(solution_script): + var set_one = solution_script.Set.new([1, 2, 3]) + var set_two = solution_script.Set.new([1, 2, 4]) + var got = set_one.equal(set_two) + var want = false + return [got, want] + + +func test_set_is_not_equal_to_larger_set_with_same_elements(solution_script): + var set_one = solution_script.Set.new([1, 2, 3]) + var set_two = solution_script.Set.new([1, 2, 3, 4]) + var got = set_one.equal(set_two) + var want = false + return [got, want] + + +func test_set_is_equal_to_a_set_constructed_from_an_array_with_duplicates(solution_script): + var set_one = solution_script.Set.new([1]) + var set_two = solution_script.Set.new([1, 1]) + var got = set_one.equal(set_two) + var want = true + return [got, want] + + +func test_add_to_empty_set(solution_script): + var set = solution_script.Set.new([]) + var got = set.add(3) + var want = solution_script.Set.new([3]) + return [got.equal(want), true] + + +func test_add_to_non_empty_set(solution_script): + var set = solution_script.Set.new([1, 2, 4]) + var got = set.add(3) + var want = solution_script.Set.new([1, 2, 3, 4]) + return [got.equal(want), true] + + +func test_adding_an_existing_element_does_not_change_the_set(solution_script): + var set = solution_script.Set.new([1, 2, 3]) + var got = set.add(3) + var want = solution_script.Set.new([1, 2, 3]) + return [got.equal(want), true] + + +func test_intersection_of_two_empty_sets_is_an_empty_set(solution_script): + var set_one = solution_script.Set.new([]) + var set_two = solution_script.Set.new([]) + var got = set_one.intersection(set_two) + var want = solution_script.Set.new([]) + return [got.equal(want), true] + + +func test_intersection_of_an_empty_set_and_non_empty_set_is_an_empty_set(solution_script): + var set_one = solution_script.Set.new([]) + var set_two = solution_script.Set.new([3, 2, 5]) + var got = set_one.intersection(set_two) + var want = solution_script.Set.new([]) + return [got.equal(want), true] + + +func test_intersection_of_a_non_empty_set_and_an_empty_set_is_an_empty_set(solution_script): + var set_one = solution_script.Set.new([1, 2, 3, 4]) + var set_two = solution_script.Set.new([]) + var got = set_one.intersection(set_two) + var want = solution_script.Set.new([]) + return [got.equal(want), true] + + +func test_intersection_of_two_sets_with_no_shared_elements_is_an_empty_set(solution_script): + var set_one = solution_script.Set.new([1, 2, 3]) + var set_two = solution_script.Set.new([4, 5, 6]) + var got = set_one.intersection(set_two) + var want = solution_script.Set.new([]) + return [got.equal(want), true] + + +func test_intersection_of_two_sets_with_shared_elements_is_a_set_of_the_shared_elements(solution_script): + var set_one = solution_script.Set.new([1, 2, 3, 4]) + var set_two = solution_script.Set.new([3, 2, 5]) + var got = set_one.intersection(set_two) + var want = solution_script.Set.new([2, 3]) + return [got.equal(want), true] + + +func test_difference_of_two_empty_sets_is_an_empty_set(solution_script): + var set_one = solution_script.Set.new([]) + var set_two = solution_script.Set.new([]) + var got = set_one.difference(set_two) + var want = solution_script.Set.new([]) + return [got.equal(want), true] + + +func test_difference_of_empty_set_and_non_empty_set_is_an_empty_set(solution_script): + var set_one = solution_script.Set.new([]) + var set_two = solution_script.Set.new([3, 2, 5]) + var got = set_one.difference(set_two) + var want = solution_script.Set.new([]) + return [got.equal(want), true] + + +func test_difference_of_a_non_empty_set_and_an_empty_set_is_the_non_empty_set(solution_script): + var set_one = solution_script.Set.new([1, 2, 3, 4]) + var set_two = solution_script.Set.new([]) + var got = set_one.difference(set_two) + var want = solution_script.Set.new([1, 2, 3, 4]) + return [got.equal(want), true] + + +func test_difference_of_two_non_empty_sets_is_a_set_of_elements_that_are_only_in_the_first_set(solution_script): + var set_one = solution_script.Set.new([3, 2, 1]) + var set_two = solution_script.Set.new([2, 4]) + var got = set_one.difference(set_two) + var want = solution_script.Set.new([1, 3]) + return [got.equal(want), true] + + +func test_difference_removes_all_duplicates_in_the_first_set(solution_script): + var set_one = solution_script.Set.new([1, 1]) + var set_two = solution_script.Set.new([1]) + var got = set_one.difference(set_two) + var want = solution_script.Set.new([]) + return [got.equal(want), true] + + +func test_union_of_empty_sets_is_an_empty_set(solution_script): + var set_one = solution_script.Set.new([]) + var set_two = solution_script.Set.new([]) + var got = set_one.union(set_two) + var want = solution_script.Set.new([]) + return [got.equal(want), true] + + +func test_union_of_an_empty_set_and_non_empty_set_is_the_non_empty_set(solution_script): + var set_one = solution_script.Set.new([]) + var set_two = solution_script.Set.new([2]) + var got = set_one.union(set_two) + var want = solution_script.Set.new([2]) + return [got.equal(want), true] + + +func test_union_of_a_non_empty_set_and_empty_set_is_the_non_empty_set(solution_script): + var set_one = solution_script.Set.new([1, 3]) + var set_two = solution_script.Set.new([]) + var got = set_one.union(set_two) + var want = solution_script.Set.new([1, 3]) + return [got.equal(want), true] + + +func test_union_of_non_empty_sets_contains_all_unique_elements(solution_script): + var set_one = solution_script.Set.new([1, 3]) + var set_two = solution_script.Set.new([2, 3]) + var got = set_one.union(set_two) + var want = solution_script.Set.new([3, 2, 1]) + return [got.equal(want), true] diff --git a/exercises/practice/custom-set/lib/test_runner.gd b/exercises/practice/custom-set/lib/test_runner.gd new file mode 100644 index 00000000..be91311a --- /dev/null +++ b/exercises/practice/custom-set/lib/test_runner.gd @@ -0,0 +1,541 @@ +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) + 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_solution(path: String) -> Object: + """Load a script. This helper is needed to catch failures without aborting the error handling.""" + if not FileAccess.file_exists(path): + return null + var re = RegEx.create_from_string("^( )+") + var replace = RegEx.create_from_string("( )+") + var changed = false + var lines = [] + for line in FileAccess.open(path, FileAccess.READ).get_as_text().split("\n"): + var m = re.search(line) + if m == null: + lines.append(line) + else: + changed = true + var new = [] + for i in range(m.get_end() / 4): + new.append("\t") + new.append(line.substr(m.get_end())) + lines.append("".join(new)) + if not changed: + return load(path).new() + + path = OS.get_temp_dir() + "/solution.gd" + FileAccess.open(path, FileAccess.WRITE).store_string("\n".join(lines)) + var solution = load(path).new() + DirAccess.remove_absolute(path) + return solution + + +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_solution(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/custom-set/project.godot b/exercises/practice/custom-set/project.godot new file mode 100644 index 00000000..62898976 --- /dev/null +++ b/exercises/practice/custom-set/project.godot @@ -0,0 +1,5 @@ +config_version=5 + +[application] +config/name="Exercism - Custom Set" +config/features=PackedStringArray("4.7") diff --git a/exercises/practice/custom-set/run_tests b/exercises/practice/custom-set/run_tests new file mode 100755 index 00000000..ed18a903 --- /dev/null +++ b/exercises/practice/custom-set/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}" From 66a3b7317234b3a61cba7879b332956ffc6f1515 Mon Sep 17 00:00:00 2001 From: Isaac Good Date: Mon, 26 Oct 2026 08:40:24 -0700 Subject: [PATCH 2/2] Sort by name, not slug --- config.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/config.json b/config.json index 0cd046e9..59611da5 100644 --- a/config.json +++ b/config.json @@ -431,17 +431,17 @@ "difficulty": 5 }, { - "slug": "custom-set", - "name": "Custom Set", - "uuid": "603c4faf-4246-4ede-aaba-97ee56986d00", + "slug": "game-of-life", + "name": "Conway's Game of Life", + "uuid": "12b85347-80c9-4a74-ae1d-258bb1a92f24", "practices": [], "prerequisites": [], "difficulty": 5 }, { - "slug": "game-of-life", - "name": "Conway's Game of Life", - "uuid": "12b85347-80c9-4a74-ae1d-258bb1a92f24", + "slug": "custom-set", + "name": "Custom Set", + "uuid": "603c4faf-4246-4ede-aaba-97ee56986d00", "practices": [], "prerequisites": [], "difficulty": 5