Skip to content

Allow reading multiple file solutions and exemploids #298

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jun 12, 2022
Merged
2 changes: 1 addition & 1 deletion elixir
62 changes: 53 additions & 9 deletions lib/elixir_analyzer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -165,19 +165,49 @@ defmodule ElixirAnalyzer do

defp do_init(params, exercise_config) do
meta_config = Path.join(params.path, @meta_config) |> File.read!() |> Jason.decode!()
relative_code_path = meta_config["files"]["solution"] |> hd()
code_path = Path.join(params.path, relative_code_path)
solution_path = meta_config["files"]["solution"] |> Enum.map(&Path.join(params.path, &1))
if Enum.empty?(solution_path), do: raise("No solution file specified")

code_path =
params.path
|> Path.join("lib")
|> ls_r()
|> Enum.filter(&String.ends_with?(&1, ".ex"))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially read ls_r as "list files right" and didn't get it for a while... maybe the function could be named ls_recursive?

On the other hand, I wonder if this function can be replaced by:

Path.join([params.path, "lib", "**", "*.ex"]) |> Path.wildcard()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, that's nice!

|> Enum.concat(solution_path)
|> Enum.uniq()
|> Enum.sort()

editor_path = Map.get(meta_config["files"], "editor", [])

{exercise_type, exemploid_path} =
case meta_config["files"] do
%{"exemplar" => [path | _]} -> {:concept, Path.join(params.path, path)}
%{"example" => [path | _]} -> {:practice, Path.join(params.path, path)}
%{"exemplar" => path} -> {:concept, path}
%{"example" => path} -> {:practice, path}
end

exemploid_path =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The singular variable names confuse me now... may I suggest renaming?

  • solution_path -> solution_files
  • editor_path -> editor_files
  • exemploid_path- > exemploid_files
  • code_path -> all_compiled_files or all_analyzable_files or all_files_for_analysis? "code" is very vague 🤔 (I know, you didn't choose this name initially)

Maybe the keys in the Source struct should be renamed too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I got lazy there :D
I left code_string and code_ast in Source as they are, still vague but not really confusing in context.

(editor_path ++ exemploid_path) |> Enum.sort() |> Enum.map(&Path.join(params.path, &1))

{code_path, exercise_type, exemploid_path,
exercise_config[:analyzer_module] || ElixirAnalyzer.TestSuite.Default}
end

defp ls_r(path) do
cond do
File.regular?(path) ->
[path]

File.dir?(path) ->
File.ls!(path)
|> Enum.map(&Path.join(path, &1))
|> Enum.map(&ls_r/1)
|> Enum.concat()

true ->
[]
end
end

# Check
# - check if the file exists
# - read in the code
Expand All @@ -190,15 +220,15 @@ defmodule ElixirAnalyzer do
end

defp check(%Submission{source: source} = submission, _params) do
Logger.info("Attempting to read code file", code_file_path: source.code_path)
Logger.info("Attempting to read code files", code_file_path: source.code_path)

with {:code_read, {:ok, code_string}} <- {:code_read, File.read(source.code_path)},
with {:code_read, {:ok, code_string}} <- {:code_read, read_files(source.code_path)},
source <- %{source | code_string: code_string},
Logger.info("Code file read successfully"),
Logger.info("Code files read successfully"),
Logger.info("Attempting to read exemploid", exemploid_path: source.exemploid_path),
{:exemploid_read, _, {:ok, exemploid_string}} <-
{:exemploid_read, source, File.read(source.exemploid_path)},
Logger.info("Exemploid file read successfully, attempting to parse"),
{:exemploid_read, source, read_files(source.exemploid_path)},
Logger.info("Exemploid files read successfully, attempting to parse"),
{:exemploid_ast, _, {:ok, exemploid_ast}} <-
{:exemploid_ast, source, Code.string_to_quoted(exemploid_string)} do
Logger.info("Exemploid file parsed successfully")
Expand Down Expand Up @@ -238,6 +268,20 @@ defmodule ElixirAnalyzer do
end
end

defp read_files(paths) do
Enum.reduce_while(
paths,
{:ok, nil},
fn path, {:ok, code} ->
case File.read(path) do
{:ok, file} when is_nil(code) -> {:cont, {:ok, file}}
{:ok, file} -> {:cont, {:ok, code <> "\n" <> file}}
Comment on lines +256 to +260
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume that the case is_nil(code) is only there because of the first iteration and initial value of the accumulator. What if we did something like this, appending new lines instead of prepending? We would end up with one extra newline at the very end, but it shouldn't matter, right? (suggested code not tested)

Suggested change
{:ok, nil},
fn path, {:ok, code} ->
case File.read(path) do
{:ok, file} when is_nil(code) -> {:cont, {:ok, file}}
{:ok, file} -> {:cont, {:ok, code <> "\n" <> file}}
{:ok, ""},
fn path, {:ok, code} ->
case File.read(path) do
{:ok, file} -> {:cont, {:ok, code <> file <> "\n"}}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It almost doesn't matter, but in some unlikely cases it makes a line in an error message off-by-one (see CI fail before reverted commit). Line number kind of loses meaning when you have multiple files, but after reflection, I'd still like to keep the nil.

{:error, err} -> {:halt, {:error, err}}
end
end
)
end

# Analyze
# - Start the static analysis
defp analyze(%Submission{halted: true} = submission, _params) do
Expand Down
2 changes: 1 addition & 1 deletion lib/elixir_analyzer/exercise_test/common_checks.ex
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ defmodule ElixirAnalyzer.ExerciseTest.CommonChecks do
VariableNames.run(code_ast),
ModuleAttributeNames.run(code_ast),
ModulePascalCase.run(code_ast),
CompilerWarnings.run(code_path, code_ast),
CompilerWarnings.run(code_path),
BooleanFunctions.run(code_ast),
FunctionAnnotationOrder.run(code_ast),
ExemplarComparison.run(code_ast, type, exemploid_ast),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,30 @@ defmodule ElixirAnalyzer.ExerciseTest.CommonChecks.CompilerWarnings do
alias ElixirAnalyzer.Constants
alias ElixirAnalyzer.Comment

def run(code_path, _code_ast) do
import ExUnit.CaptureIO
Application.put_env(:elixir, :ansi_enabled, false)
def run(code_path) do
Logger.configure(level: :critical)

warnings =
capture_io(:stderr, fn ->
try do
Code.compile_file(code_path)
|> Enum.each(fn {module, _binary} ->
case Kernel.ParallelCompiler.compile(code_path) do
{:ok, modules, warnings} ->
Enum.each(modules, fn module ->
:code.delete(module)
:code.purge(module)
end)
rescue
# There are too many compile errors for tests, so we filter them out
# We assume that real code passed the tests and therefore compiles
_ -> nil
end
end)

warnings

{:error, _errors, _warnings} ->
# This should not happen, as real code is assumed to have compiled and
# passed the tests
[]
end

Logger.configure(level: :warn)

Application.put_env(:elixir, :ansi_enabled, true)

if warnings == "" do
if Enum.empty?(warnings) do
[]
else
[
Expand All @@ -35,9 +37,20 @@ defmodule ElixirAnalyzer.ExerciseTest.CommonChecks.CompilerWarnings do
type: :actionable,
name: Constants.solution_compiler_warnings(),
comment: Constants.solution_compiler_warnings(),
params: %{warnings: warnings}
params: %{warnings: Enum.map_join(warnings, &format_warning/1)}
}}
]
end
end

defp format_warning({filepath, line, warning}) do
[_ | after_lib] = String.split(filepath, "/lib/")
filepath = "lib/" <> Enum.join(after_lib)

"""
warning: #{warning}
#{filepath}:#{line}

"""
end
end
4 changes: 2 additions & 2 deletions lib/elixir_analyzer/source.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ defmodule ElixirAnalyzer.Source do
@type t() :: %__MODULE__{
slug: String.t(),
path: String.t(),
code_path: String.t(),
code_path: [String.t()],
code_string: String.t(),
code_ast: Macro.t(),
exercise_type: :concept | :practice,
exemploid_path: String.t(),
exemploid_path: [String.t()],
exemploid_string: String.t(),
exemploid_ast: Macro.t()
}
Expand Down
4 changes: 1 addition & 3 deletions lib/elixir_analyzer/test_suite/take_a_number_deluxe.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@ defmodule ElixirAnalyzer.TestSuite.TakeANumberDeluxe do
alias ElixirAnalyzer.Constants
alias ElixirAnalyzer.Source

use ElixirAnalyzer.ExerciseTest,
# this is temporary until we include editor files in compilation
suppress_tests: [Constants.solution_compiler_warnings()]
use ElixirAnalyzer.ExerciseTest

feature "uses GenServer" do
type :actionable
Expand Down
4 changes: 2 additions & 2 deletions test/elixir_analyzer/cli_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ defmodule ElixirAnalyzer.CLITest do
source: %Source{
code_ast: {:defmodule, _, _},
code_string: "defmodule Lasagna" <> _,
code_path: @lasagna_path <> "/lib/lasagna.ex",
code_path: [@lasagna_path <> "/lib/lasagna.ex"],
exemploid_ast: {:defmodule, _, _},
exemploid_string: "defmodule Lasagna" <> _,
exemploid_path: @lasagna_path <> "/.meta/exemplar.ex",
exemploid_path: [@lasagna_path <> "/.meta/exemplar.ex"],
exercise_type: :concept,
path: @lasagna_path,
slug: "lasagna"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ defmodule ElixirAnalyzer.ExerciseTest.CommonChecks.CompilerWarningsTest do
alias ElixirAnalyzer.ExerciseTest.CommonChecks.CompilerWarnings

test "Implementing a protocol doesn't trigger a compiler warning" do
filepath = "test_data/clock/lib/clock.ex"
assert CompilerWarnings.run(filepath, nil) == []
filepath = "test_data/clock/perfect_solution/lib/clock.ex"
assert CompilerWarnings.run([filepath]) == []
end
end
21 changes: 16 additions & 5 deletions test/elixir_analyzer_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ defmodule ElixirAnalyzerTest do
analyzed_exercise = ElixirAnalyzer.analyze_exercise(exercise, path, path, @options)

expected_output =
"{\"comments\":[{\"comment\":\"elixir.two-fer.use_of_function_header\",\"type\":\"actionable\"},{\"comment\":\"elixir.solution.use_specification\",\"type\":\"actionable\"},{\"comment\":\"elixir.solution.raise_fn_clause_error\",\"type\":\"actionable\"},{\"comment\":\"elixir.solution.variable_name_snake_case\",\"params\":{\"actual\":\"_nameInPascalCase\",\"expected\":\"_name_in_pascal_case\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.module_attribute_name_snake_case\",\"params\":{\"actual\":\"someUnusedModuleAttribute\",\"expected\":\"some_unused_module_attribute\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.module_pascal_case\",\"params\":{\"actual\":\"My_empty_module\",\"expected\":\"MyEmptyModule\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.compiler_warnings\",\"params\":{\"warnings\":\"warning: module attribute @someUnusedModuleAttribute was set but never used\\n test_data/two_fer/imperfect_solution/lib/two_fer.ex:2\\n\\n\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.use_module_doc\",\"type\":\"informative\"},{\"comment\":\"elixir.solution.indentation\",\"type\":\"informative\"},{\"comment\":\"elixir.solution.private_helper_functions\",\"params\":{\"actual\":\"def public_helper(_)\",\"expected\":\"defp public_helper(_)\"},\"type\":\"informative\"},{\"comment\":\"elixir.general.feedback_request\",\"type\":\"informative\"}],\"summary\":\"Check the comments for some suggestions. 📣\"}"
"{\"comments\":[{\"comment\":\"elixir.two-fer.use_of_function_header\",\"type\":\"actionable\"},{\"comment\":\"elixir.solution.use_specification\",\"type\":\"actionable\"},{\"comment\":\"elixir.solution.raise_fn_clause_error\",\"type\":\"actionable\"},{\"comment\":\"elixir.solution.variable_name_snake_case\",\"params\":{\"actual\":\"_nameInPascalCase\",\"expected\":\"_name_in_pascal_case\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.module_attribute_name_snake_case\",\"params\":{\"actual\":\"someUnusedModuleAttribute\",\"expected\":\"some_unused_module_attribute\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.module_pascal_case\",\"params\":{\"actual\":\"My_empty_module\",\"expected\":\"MyEmptyModule\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.compiler_warnings\",\"params\":{\"warnings\":\"warning: module attribute @someUnusedModuleAttribute was set but never used\\n lib/two_fer.ex:2\\n\\n\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.use_module_doc\",\"type\":\"informative\"},{\"comment\":\"elixir.solution.indentation\",\"type\":\"informative\"},{\"comment\":\"elixir.solution.private_helper_functions\",\"params\":{\"actual\":\"def public_helper(_)\",\"expected\":\"defp public_helper(_)\"},\"type\":\"informative\"},{\"comment\":\"elixir.general.feedback_request\",\"type\":\"informative\"}],\"summary\":\"Check the comments for some suggestions. 📣\"}"

assert Submission.to_json(analyzed_exercise) == expected_output
end
Expand Down Expand Up @@ -91,7 +91,7 @@ defmodule ElixirAnalyzerTest do
analyzed_exercise = ElixirAnalyzer.analyze_exercise(exercise, path, path, @options)

expected_output =
"{\"comments\":[{\"comment\":\"elixir.solution.raise_fn_clause_error\",\"type\":\"actionable\"},{\"comment\":\"elixir.solution.variable_name_snake_case\",\"params\":{\"actual\":\"_nameInPascalCase\",\"expected\":\"_name_in_pascal_case\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.module_attribute_name_snake_case\",\"params\":{\"actual\":\"someUnusedModuleAttribute\",\"expected\":\"some_unused_module_attribute\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.module_pascal_case\",\"params\":{\"actual\":\"My_empty_module\",\"expected\":\"MyEmptyModule\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.compiler_warnings\",\"params\":{\"warnings\":\"warning: module attribute @someUnusedModuleAttribute was set but never used\\n test_data/two_fer/imperfect_solution/lib/two_fer.ex:2\\n\\n\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.indentation\",\"type\":\"informative\"},{\"comment\":\"elixir.solution.private_helper_functions\",\"params\":{\"actual\":\"def public_helper(_)\",\"expected\":\"defp public_helper(_)\"},\"type\":\"informative\"},{\"comment\":\"elixir.general.feedback_request\",\"type\":\"informative\"}],\"summary\":\"Check the comments for some suggestions. 📣\"}"
"{\"comments\":[{\"comment\":\"elixir.solution.raise_fn_clause_error\",\"type\":\"actionable\"},{\"comment\":\"elixir.solution.variable_name_snake_case\",\"params\":{\"actual\":\"_nameInPascalCase\",\"expected\":\"_name_in_pascal_case\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.module_attribute_name_snake_case\",\"params\":{\"actual\":\"someUnusedModuleAttribute\",\"expected\":\"some_unused_module_attribute\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.module_pascal_case\",\"params\":{\"actual\":\"My_empty_module\",\"expected\":\"MyEmptyModule\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.compiler_warnings\",\"params\":{\"warnings\":\"warning: module attribute @someUnusedModuleAttribute was set but never used\\n lib/two_fer.ex:2\\n\\n\"},\"type\":\"actionable\"},{\"comment\":\"elixir.solution.indentation\",\"type\":\"informative\"},{\"comment\":\"elixir.solution.private_helper_functions\",\"params\":{\"actual\":\"def public_helper(_)\",\"expected\":\"defp public_helper(_)\"},\"type\":\"informative\"},{\"comment\":\"elixir.general.feedback_request\",\"type\":\"informative\"}],\"summary\":\"Check the comments for some suggestions. 📣\"}"

assert Submission.to_json(analyzed_exercise) == String.trim(expected_output)
end
Expand Down Expand Up @@ -119,6 +119,17 @@ defmodule ElixirAnalyzerTest do
assert Submission.to_json(analyzed_exercise) == String.trim(expected_output)
end

test "perfect solution for exercise with multiple solution files" do
exercise = "dancing_dots"
path = "./test_data/dancing-dots/split_solution/"
analyzed_exercise = ElixirAnalyzer.analyze_exercise(exercise, path, path, @options)

expected_output =
"{\"comments\":[],\"summary\":\"Submission analyzed. No automated suggestions found.\"}"

assert Submission.to_json(analyzed_exercise) == String.trim(expected_output)
end

test "failing solution with comments" do
exercise = "lasagna"
path = "./test_data/lasagna/failing_solution/"
Expand All @@ -136,7 +147,7 @@ defmodule ElixirAnalyzerTest do
analyzed_exercise = ElixirAnalyzer.analyze_exercise(exercise, path, path, @options)

expected_output =
"{\"comments\":[{\"comment\":\"elixir.solution.compiler_warnings\",\"params\":{\"warnings\":\"warning: Behaviour.defcallback/1 is deprecated. Use the @callback module attribute instead\\n test_data/lasagna/deprecated_modules/lib/lasagna.ex:4: Lasagna\\n\\nwarning: HashDict.new/0 is deprecated. Use maps and the Map module instead\\n test_data/lasagna/deprecated_modules/lib/lasagna.ex:7: Lasagna.expected_minutes_in_oven/0\\n\\nwarning: HashSet.member?/2 is deprecated. Use the MapSet module instead\\n test_data/lasagna/deprecated_modules/lib/lasagna.ex:12: Lasagna.remaining_minutes_in_oven/1\\n\\nwarning: HashSet.new/0 is deprecated. Use the MapSet module instead\\n test_data/lasagna/deprecated_modules/lib/lasagna.ex:12: Lasagna.remaining_minutes_in_oven/1\\n\\n\"},\"type\":\"actionable\"},{\"comment\":\"elixir.general.feedback_request\",\"type\":\"informative\"}],\"summary\":\"Check the comments for some suggestions. 📣\"}"
"{\"comments\":[{\"comment\":\"elixir.solution.compiler_warnings\",\"params\":{\"warnings\":\"warning: Behaviour.defcallback/1 is deprecated. Use the @callback module attribute instead\\n lib/lasagna.ex:4\\n\\nwarning: HashDict.new/0 is deprecated. Use maps and the Map module instead\\n lib/lasagna.ex:7\\n\\nwarning: HashSet.member?/2 is deprecated. Use the MapSet module instead\\n lib/lasagna.ex:12\\n\\nwarning: HashSet.new/0 is deprecated. Use the MapSet module instead\\n lib/lasagna.ex:12\\n\\n\"},\"type\":\"actionable\"},{\"comment\":\"elixir.general.feedback_request\",\"type\":\"informative\"}],\"summary\":\"Check the comments for some suggestions. 📣\"}"

assert Submission.to_json(analyzed_exercise) == expected_output
end
Expand Down Expand Up @@ -268,10 +279,10 @@ defmodule ElixirAnalyzerTest do

assert %Submission{
halted: true,
halt_reason: "Analysis skipped, unexpected error Elixir.ArgumentError"
halt_reason: "Analysis skipped, unexpected error Elixir.RuntimeError"
} = analyzed_exercise
end) =~
"[error_message: \"errors were found at the given arguments:\\n\\n * 1st argument: not a nonempty list\\n\"] [warning] TestSuite halted, Elixir.ArgumentError"
"[error_message: \"No solution file specified\"] [warning] TestSuite halted, Elixir.RuntimeError"
end
end

Expand Down
31 changes: 22 additions & 9 deletions test/support/exercise_test_case.ex
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ defmodule ElixirAnalyzer.ExerciseTestCase do
@practice_exercise_path "elixir/exercises/practice"
@meta_config ".meta/config.json"
def find_source(test_module) do
%Source{}
%Source{code_path: []}
|> find_source_slug(test_module)
|> find_source_type
|> find_source_exemploid_path
Expand Down Expand Up @@ -192,33 +192,32 @@ defmodule ElixirAnalyzer.ExerciseTestCase do
end

defp find_source_exemploid_path(%Source{slug: slug, exercise_type: :concept} = source) do
%{"files" => %{"exemplar" => [exemploid_path | _]}} =
%{"files" => %{"exemplar" => exemploid_path}} =
[@concept_exercise_path, slug, @meta_config]
|> Path.join()
|> File.read!()
|> Jason.decode!()

exemploid_path = Path.join([@concept_exercise_path, slug, exemploid_path])
%{source | exemploid_path: exemploid_path}
exemploid_path = Enum.map(exemploid_path, &Path.join([@concept_exercise_path, slug, &1]))
%{source | exemploid_path: [exemploid_path]}
end

defp find_source_exemploid_path(%Source{slug: slug, exercise_type: :practice} = source) do
%{"files" => %{"example" => [exemploid_path | _]}} =
%{"files" => %{"example" => exemploid_path}} =
[@practice_exercise_path, slug, @meta_config]
|> Path.join()
|> File.read!()
|> Jason.decode!()

exemploid_path = Path.join([@practice_exercise_path, slug, exemploid_path])

exemploid_path = Enum.map(exemploid_path, &Path.join([@practice_exercise_path, slug, &1]))
%{source | exemploid_path: exemploid_path}
end

defp find_source_exemploid_path(source), do: source

defp find_source_exemploid(%Source{exemploid_path: exemploid_path} = source)
when is_binary(exemploid_path) do
exemploid_string = File.read!(exemploid_path)
when is_list(exemploid_path) do
{:ok, exemploid_string} = read_files(exemploid_path)

exemploid_ast =
exemploid_string
Expand All @@ -230,4 +229,18 @@ defmodule ElixirAnalyzer.ExerciseTestCase do
end

defp find_source_exemploid(source), do: source

defp read_files(paths) do
Enum.reduce_while(
paths,
{:ok, nil},
fn path, {:ok, code} ->
case File.read(path) do
{:ok, file} when is_nil(code) -> {:cont, {:ok, file}}
{:ok, file} -> {:cont, {:ok, code <> "\n" <> file}}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line brings the coverage down a bit, I can't test it because we don't have a multiple file exemploid.

{:error, err} -> {:halt, {:error, err}}
end
end
)
end
end
29 changes: 29 additions & 0 deletions test_data/dancing-dots/split_solution/.meta/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"authors": [
"angelikatyborska"
],
"contributors": [
"jiegillet"
],
"files": {
"solution": [
"lib/dancing_dots/animation.ex",
"lib/dancing_dots/flicker.ex",
"lib/dancing_dots/zoom.ex"
],
"test": [
"test/dancing_dots/animation_test.exs"
],
"exemplar": [
".meta/exemplar/animation.ex",
".meta/exemplar/flicker.ex",
".meta/exemplar/zoom.ex"
],
"editor": [
"lib/dancing_dots/dot.ex",
"lib/dancing_dots/dot_group.ex"
]
},
"language_versions": ">=1.10",
"blurb": "Learn about behaviours by writing animations for dot-based generative art."
}
Loading