diff --git a/.gitignore b/.gitignore index 755b6055..da26a192 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /deps erl_crash.dump *.ez +.tool-versions diff --git a/lib/koans/05_tuples.ex b/lib/koans/05_tuples.ex index 70a65bb7..0f855698 100644 --- a/lib/koans/05_tuples.ex +++ b/lib/koans/05_tuples.ex @@ -24,8 +24,10 @@ defmodule Tuples do assert Tuple.insert_at({:a, "hi"}, 1, :new_thing) == ___ end - koan "Add things at the end" do - assert Tuple.append({"Huey", "Dewey"}, "Louie") == ___ + koan "Add things at the end (by constructing a new tuple)" do + {first, second} = {"Huey", "Dewey"} + extended = {first, second, "Louie"} + assert extended == ___ end koan "Or remove them" do diff --git a/lib/koans/12_pattern_matching.ex b/lib/koans/12_pattern_matching.ex index e43c92b7..76dda2d5 100644 --- a/lib/koans/12_pattern_matching.ex +++ b/lib/koans/12_pattern_matching.ex @@ -83,15 +83,15 @@ defmodule PatternMatching do end koan "Errors are shaped differently than successful results" do - dog = %{type: "dog"} + dog = %{type: "barking"} - result = + type = case Map.fetch(dog, :type) do {:ok, value} -> value :error -> "not present" end - assert result == ___ + assert type == ___ end defmodule Animal do @@ -166,4 +166,42 @@ defmodule PatternMatching do ^a = ___ end end + + koan "Pattern matching works with nested data structures" do + user = %{ + profile: %{ + personal: %{name: "Alice", age: 30}, + settings: %{theme: "dark", notifications: true} + } + } + + %{profile: %{personal: %{age: age}, settings: %{theme: theme}}} = user + assert age == ___ + assert theme == ___ + end + + koan "Lists can be pattern matched with head and tail" do + numbers = [1, 2, 3, 4, 5] + + [first, second | rest] = numbers + assert first == ___ + assert second == ___ + assert rest == ___ + + [head | _tail] = numbers + assert head == ___ + end + + koan "Pattern matching can extract values from function return tuples" do + divide = fn + _, 0 -> {:error, :division_by_zero} + x, y -> {:ok, x / y} + end + + {:ok, result} = divide.(10, 2) + assert result == ___ + + {:error, reason} = divide.(10, 0) + assert reason == ___ + end end diff --git a/lib/koans/13_functions.ex b/lib/koans/13_functions.ex index a9c63500..00258dff 100644 --- a/lib/koans/13_functions.ex +++ b/lib/koans/13_functions.ex @@ -110,6 +110,28 @@ defmodule Functions do assert result == ___ end + koan "Pipes make data transformation pipelines readable" do + numbers = [1, 2, 3, 4, 5] + + result = + numbers + |> Enum.filter(&(&1 > 2)) + |> Enum.map(&(&1 * 2)) + |> Enum.sum() + + assert result == ___ + + user_input = " Hello World " + + cleaned = + user_input + |> String.trim() + |> String.downcase() + |> String.replace(" ", "_") + + assert cleaned == ___ + end + koan "Conveniently keyword lists can be used for function options" do transform = fn str, opts -> if opts[:upcase] do @@ -122,4 +144,16 @@ defmodule Functions do assert transform.("good", upcase: true) == ___ assert transform.("good", upcase: false) == ___ end + + koan "Anonymous functions can use the & capture syntax for very concise definitions" do + add_one = &(&1 + 1) + multiply_by_two = &(&1 * 2) + + result = 5 |> add_one.() |> multiply_by_two.() + assert result == ___ + + # You can also capture existing functions + string_length = &String.length/1 + assert string_length.("hello") == ___ + end end diff --git a/lib/koans/14_enums.ex b/lib/koans/14_enums.ex index e158aa7c..23c4e9ca 100644 --- a/lib/koans/14_enums.ex +++ b/lib/koans/14_enums.ex @@ -8,8 +8,18 @@ defmodule Enums do assert Enum.count([1, 2, 3]) == ___ end - koan "Depending on the type, it counts pairs" do - assert Enum.count(%{a: :foo, b: :bar}) == ___ + koan "Counting is similar to length" do + assert length([1, 2, 3]) == ___ + end + + koan "But it allows you to count certain elements" do + assert Enum.count([1, 2, 3], &(&1 == 2)) == ___ + end + + koan "Depending on the type, it counts pairs while length does not" do + map = %{a: :foo, b: :bar} + assert Enum.count(map) == ___ + assert_raise ___, fn -> length(map) end end def less_than_five?(n), do: n < 5 @@ -34,7 +44,7 @@ defmodule Enums do def multiply_by_ten(n), do: 10 * n - koan "Map converts each element of a list by running some function with it" do + koan "Mapping converts each element of a list by running some function with it" do assert Enum.map([1, 2, 3], &multiply_by_ten/1) == ___ end @@ -66,7 +76,7 @@ defmodule Enums do assert Enum.zip(letters, numbers) == ___ end - koan "When you want to find that one pesky element" do + koan "When you want to find that one pesky element, it returns the first" do assert Enum.find([1, 2, 3, 4], &even?/1) == ___ end @@ -83,4 +93,38 @@ defmodule Enums do koan "Collapse an entire list of elements down to a single one by repeating a function." do assert Enum.reduce([1, 2, 3], 0, fn element, accumulator -> element + accumulator end) == ___ end + + koan "Enum.chunk_every splits lists into smaller lists of fixed size" do + assert Enum.chunk_every([1, 2, 3, 4, 5, 6], 2) == ___ + assert Enum.chunk_every([1, 2, 3, 4, 5], 3) == ___ + end + + koan "Enum.flat_map transforms and flattens in one step" do + result = + [1, 2, 3] + |> Enum.flat_map(&[&1, &1 * 10]) + + assert result == ___ + end + + koan "Enum.group_by organizes elements by a grouping function" do + words = ["apple", "banana", "cherry", "apricot", "blueberry"] + grouped = Enum.group_by(words, &String.first/1) + + assert grouped["a"] == ___ + assert grouped["b"] == ___ + end + + koan "Stream provides lazy enumeration for large datasets" do + # Streams are lazy - they don't execute until you call Enum on them + stream = + 1..1_000_000 + |> Stream.filter(&even?/1) + |> Stream.map(&(&1 * 2)) + |> Stream.take(3) + + # Nothing has been computed yet! + result = Enum.to_list(stream) + assert result == ___ + end end diff --git a/lib/koans/18_genservers.ex b/lib/koans/18_genservers.ex index 752a4f4f..20232796 100644 --- a/lib/koans/18_genservers.ex +++ b/lib/koans/18_genservers.ex @@ -160,4 +160,88 @@ defmodule GenServers do :ok = Laptop.stop() end + + defmodule TimeoutServer do + @moduledoc false + use GenServer + + def start_link(timeout) do + GenServer.start_link(__MODULE__, timeout, name: __MODULE__) + end + + def init(timeout) do + {:ok, %{count: 0}, timeout} + end + + def get_count do + GenServer.call(__MODULE__, :get_count) + end + + def handle_call(:get_count, _from, state) do + {:reply, state.count, state} + end + + def handle_info(:timeout, state) do + new_state = %{state | count: state.count + 1} + {:noreply, new_state} + end + end + + koan "GenServers can handle info messages and timeouts" do + {:ok, _pid} = TimeoutServer.start_link(100) + # Wait for timeout to occur + :timer.sleep(101) + count = TimeoutServer.get_count() + assert count == ___ + + GenServer.stop(TimeoutServer) + end + + defmodule CrashableServer do + @moduledoc false + use GenServer + + def start_link(initial) do + GenServer.start_link(__MODULE__, initial, name: __MODULE__) + end + + def init(initial) do + {:ok, initial} + end + + def crash do + GenServer.cast(__MODULE__, :crash) + end + + def get_state do + GenServer.call(__MODULE__, :get_state) + end + + def handle_call(:get_state, _from, state) do + {:reply, state, state} + end + + def handle_cast(:crash, _state) do + raise "Intentional crash for testing" + end + end + + koan "GenServers can be supervised and restarted" do + # Start under a supervisor + children = [{CrashableServer, "the state"}] + {:ok, supervisor} = Supervisor.start_link(children, strategy: :one_for_one) + + # Server should be running + initial_state = CrashableServer.get_state() + assert initial_state == ___ + + :ok = CrashableServer.crash() + # Wait for recovery + :timer.sleep(100) + + state_after_crash_recovery = CrashableServer.get_state() + assert state_after_crash_recovery == ___ + + Supervisor.stop(supervisor) + end end diff --git a/lib/koans/21_control_flow.ex b/lib/koans/21_control_flow.ex new file mode 100644 index 00000000..3d0470d3 --- /dev/null +++ b/lib/koans/21_control_flow.ex @@ -0,0 +1,158 @@ +# credo:disable-for-this-file Credo.Check.Refactor.UnlessWithElse +# credo:disable-for-this-file Credo.Check.Refactor.CondStatements +defmodule ControlFlow do + @moduledoc false + use Koans + + @intro "Control Flow - Making decisions and choosing paths" + + koan "If statements evaluate conditions" do + result = if true, do: "yes", else: "no" + assert result == ___ + end + + koan "If can be written in block form" do + result = + if 1 + 1 == 2 do + "math works" + else + "math is broken" + end + + assert result == ___ + end + + koan "Unless is the opposite of if" do + result = unless false, do: "will execute", else: "will not execute" + assert result == ___ + end + + koan "Nil and false are falsy, everything else is truthy" do + assert if(nil, do: "truthy", else: "falsy") == ___ + assert if(false, do: "truthy", else: "falsy") == ___ + assert if(0, do: "truthy", else: "falsy") == ___ + assert if("", do: "truthy", else: "falsy") == ___ + assert if([], do: "truthy", else: "falsy") == ___ + end + + koan "Case matches against patterns" do + result = + case {1, 2, 3} do + {4, 5, 6} -> "no match" + {1, x, 3} -> "matched with x = #{x}" + end + + assert result == ___ + end + + koan "Case can have multiple clauses with different patterns" do + check_number = fn x -> + case x do + 0 -> "zero" + n when n > 0 -> "positive" + n when n < 0 -> "negative" + end + end + + assert check_number.(5) == ___ + assert check_number.(0) == ___ + assert check_number.(-3) == ___ + end + + koan "Case clauses are tried in order until one matches" do + check_list = fn list -> + case list do + [] -> "empty" + [_] -> "one element" + [_, _] -> "two elements" + _ -> "many elements" + end + end + + assert check_list.([]) == ___ + assert check_list.([:a]) == ___ + assert check_list.([:a, :b]) == ___ + assert check_list.([:a, :b, :c, :d]) == ___ + end + + koan "Cond evaluates conditions until one is truthy" do + temperature = 25 + + weather = + cond do + temperature < 0 -> "freezing" + temperature < 10 -> "cold" + temperature < 25 -> "cool" + temperature < 30 -> "warm" + true -> "hot" + end + + assert weather == ___ + end + + koan "Cond requires at least one clause to be true" do + safe_divide = fn x, y -> + cond do + y == 0 -> {:error, "division by zero"} + true -> {:ok, x / y} + end + end + + assert safe_divide.(10, 2) == ___ + assert safe_divide.(10, 0) == ___ + end + + koan "Case can destructure complex patterns" do + parse_response = fn response -> + case response do + {:ok, %{status: 200, body: body}} -> "Success: #{body}" + {:ok, %{status: status}} when status >= 400 -> "Client error: #{status}" + {:ok, %{status: status}} when status >= 500 -> "Server error: #{status}" + {:error, reason} -> "Request failed: #{reason}" + end + end + + assert parse_response.({:ok, %{status: 200, body: "Hello"}}) == ___ + assert parse_response.({:ok, %{status: 404}}) == ___ + assert parse_response.({:error, :timeout}) == ___ + end + + koan "Guards in case can use complex expressions" do + categorize = fn number -> + case number do + n when is_integer(n) and n > 0 and rem(n, 2) == 0 -> "positive even integer" + n when is_integer(n) and n > 0 and rem(n, 2) == 1 -> "positive odd integer" + n when is_integer(n) and n < 0 -> "negative integer" + n when is_float(n) -> "float" + _ -> "other" + end + end + + assert categorize.(4) == ___ + assert categorize.(3) == ___ + assert categorize.(-5) == ___ + assert categorize.(3.14) == ___ + assert categorize.("hello") == ___ + end + + koan "Multiple conditions can be checked in sequence" do + process_user = fn user -> + if user.active do + if user.verified do + if user.premium do + "premium verified active user" + else + "verified active user" + end + else + "unverified active user" + end + else + "inactive user" + end + end + + user = %{active: true, verified: true, premium: false} + assert process_user.(user) == ___ + end +end diff --git a/lib/koans/22_error_handling.ex b/lib/koans/22_error_handling.ex new file mode 100644 index 00000000..a26cd02e --- /dev/null +++ b/lib/koans/22_error_handling.ex @@ -0,0 +1,201 @@ +defmodule ErrorHandling do + @moduledoc false + use Koans + + @intro "Error Handling - Dealing gracefully with things that go wrong" + + koan "Result tuples are a common pattern for success and failure" do + parse_number = fn string -> + case Integer.parse(string) do + {number, ""} -> {:ok, number} + _ -> {:error, :invalid_format} + end + end + + assert parse_number.("123") == ___ + assert parse_number.("abc") == ___ + end + + koan "Pattern matching makes error handling elegant" do + divide = fn x, y -> + case y do + 0 -> {:error, :division_by_zero} + _ -> {:ok, x / y} + end + end + + result = + case divide.(10, 2) do + {:ok, value} -> "Result: #{value}" + {:error, reason} -> "Error: #{reason}" + end + + assert result == ___ + end + + koan "Try-rescue catches runtime exceptions" do + result = + try do + 10 / 0 + rescue + ArithmeticError -> "Cannot divide by zero!" + end + + assert result == ___ + end + + koan "Try-rescue can catch specific exception types" do + safe_list_access = fn list, index -> + try do + {:ok, Enum.at(list, index)} + rescue + FunctionClauseError -> {:error, :invalid_argument} + e in Protocol.UndefinedError -> {:error, "#{e.value} is not a list"} + end + end + + assert safe_list_access.([1, 2, 3], 1) == ___ + assert safe_list_access.([1, 2, 3], "a") == ___ + assert safe_list_access.("abc", 0) == ___ + end + + koan "Multiple rescue clauses handle different exceptions" do + risky_operation = fn input -> + try do + case input do + "divide" -> 10 / 0 + "access" -> Map.fetch!(%{}, :missing_key) + "convert" -> String.to_integer("not_a_number") + _ -> {:ok, "success"} + end + rescue + ArithmeticError -> {:error, :arithmetic} + KeyError -> {:error, :missing_key} + ArgumentError -> {:error, :invalid_argument} + end + end + + assert risky_operation.("divide") == ___ + assert risky_operation.("access") == ___ + assert risky_operation.("convert") == ___ + assert risky_operation.("safe") == ___ + end + + koan "Try-catch handles thrown values" do + result = + try do + throw(:early_return) + "this won't be reached" + catch + :early_return -> "caught thrown value" + end + + assert result == ___ + end + + koan "After clause always executes for cleanup" do + cleanup_called = + try do + raise "something went wrong" + rescue + RuntimeError -> :returned_value + after + IO.puts("Executed but not returned") + end + + assert cleanup_called == ___ + end + + koan "After executes even when there's no error" do + {result, value} = + try do + {:success, "it worked"} + after + IO.puts("Executed but not returned") + end + + assert result == ___ + assert value == ___ + end + + defmodule CustomError do + defexception message: "something custom went wrong" + end + + koan "Custom exceptions can be defined and raised" do + result = + try do + raise CustomError, message: "custom failure" + rescue + e in CustomError -> "caught custom error: #{e.message}" + end + + assert result == ___ + end + + koan "Bang functions raise exceptions on failure" do + result = + try do + Map.fetch!(%{a: 1}, :b) + rescue + KeyError -> "key not found" + end + + assert result == ___ + end + + koan "Exit signals can be caught and handled" do + result = + try do + exit(:normal) + catch + :exit, :normal -> "caught normal exit" + end + + assert result == ___ + end + + koan "Multiple clauses can handle different error patterns" do + handle_database_operation = fn operation -> + try do + case operation do + :connection_error -> raise "connection failed" + :timeout -> exit(:timeout) + :invalid_query -> throw(:bad_query) + :success -> {:ok, "data retrieved"} + end + rescue + e in RuntimeError -> {:error, {:exception, e.message}} + catch + :exit, :timeout -> {:error, :timeout} + :bad_query -> {:error, :invalid_query} + end + end + + assert handle_database_operation.(:connection_error) == ___ + assert handle_database_operation.(:timeout) == ___ + assert handle_database_operation.(:invalid_query) == ___ + assert handle_database_operation.(:success) == ___ + end + + koan "Error information can be preserved and enriched" do + enriched_error = fn -> + try do + String.to_integer("not a number") + rescue + e in ArgumentError -> + {:error, + %{ + type: :conversion_error, + original: e, + context: "user input processing", + message: "Failed to convert string to integer" + }} + end + end + + {:error, error_info} = enriched_error.() + assert error_info.type == ___ + assert error_info.context == ___ + end +end diff --git a/lib/koans/23_pipe_operator.ex b/lib/koans/23_pipe_operator.ex new file mode 100644 index 00000000..0379ff75 --- /dev/null +++ b/lib/koans/23_pipe_operator.ex @@ -0,0 +1,205 @@ +# credo:disable-for-this-file Credo.Check.Warning.IoInspect +# credo:disable-for-this-file Credo.Check.Refactor.MapJoin +defmodule PipeOperator do + @moduledoc false + use Koans + + @intro "The Pipe Operator - Making data transformation elegant and readable" + + koan "The pipe operator passes the result of one function to the next" do + result = + "hello world" + |> String.upcase() + |> String.split(" ") + |> Enum.join("-") + + assert result == ___ + end + + koan "Without pipes, nested function calls can be hard to read" do + nested_result = Enum.join(String.split(String.downcase("Hello World"), " "), "_") + piped_result = "Hello World" |> String.downcase() |> String.split(" ") |> Enum.join("_") + + assert nested_result == piped_result + assert piped_result == ___ + end + + koan "Pipes pass the result as the first argument to the next function" do + result = + [1, 2, 3, 4, 5] + |> Enum.filter(&(&1 > 2)) + |> Enum.map(&(&1 * 2)) + + assert result == ___ + end + + koan "Additional arguments can be passed to piped functions" do + result = + "hello world" + |> String.split(" ") + |> Enum.join(", ") + + assert result == ___ + end + + koan "Pipes work with anonymous functions too" do + double = fn x -> x * 2 end + add_ten = fn x -> x + 10 end + + result = + 5 + |> double.() + |> add_ten.() + + assert result == ___ + end + + koan "You can pipe into function captures" do + result = + [1, 2, 3] + |> Enum.map(&Integer.to_string/1) + |> Enum.join("-") + + assert result == ___ + end + + koan "Complex data transformations become readable with pipes" do + users = [ + %{name: "Bob", age: 25, active: false}, + %{name: "Charlie", age: 35, active: true}, + %{name: "Alice", age: 30, active: true} + ] + + active_names = + users + |> Enum.filter(& &1.active) + |> Enum.map(& &1.name) + |> Enum.sort() + + assert active_names == ___ + end + + koan "Pipes can be split across multiple lines for readability" do + result = + "the quick brown fox jumps over the lazy dog" + |> String.split(" ") + |> Enum.filter(&(String.length(&1) > 3)) + |> Enum.map(&String.upcase/1) + |> Enum.take(3) + + assert result == ___ + end + + koan "The then/2 function is useful when you need to call a function that doesn't take the piped value as first argument" do + result = + [1, 2, 3] + |> Enum.map(&(&1 * 2)) + |> then(&Enum.zip([:a, :b, :c], &1)) + + assert result == ___ + end + + koan "Pipes can be used with case statements" do + process_number = fn x -> + x + |> Integer.parse() + |> case do + {num, ""} -> {:ok, num * 2} + _ -> {:error, :invalid_number} + end + end + + assert process_number.("42") == ___ + assert process_number.("abc") == ___ + end + + koan "Conditional pipes can use if/unless" do + process_string = fn str, should_upcase -> + str + |> String.trim() + |> then(&if should_upcase, do: String.upcase(&1), else: &1) + |> String.split(" ") + end + + assert process_string.(" hello world ", true) == ___ + assert process_string.(" hello world ", false) == ___ + end + + koan "Pipes work great with Enum functions for data processing" do + sales_data = [ + %{product: "Widget", amount: 100, month: "Jan"}, + %{product: "Gadget", amount: 200, month: "Jan"}, + %{product: "Widget", amount: 150, month: "Feb"}, + %{product: "Gadget", amount: 180, month: "Feb"} + ] + + widget_total = + sales_data + |> Enum.filter(&(&1.product == "Widget")) + |> Enum.map(& &1.amount) + |> Enum.sum() + + assert widget_total == ___ + end + + koan "Tap lets you perform side effects without changing the pipeline" do + result = + [1, 2, 3] + |> Enum.map(&(&1 * 2)) + |> tap(&IO.inspect(&1, label: "After doubling")) + |> Enum.sum() + + assert result == ___ + end + + koan "Multiple transformations can be chained elegantly" do + text = "The quick brown fox dumped over the lazy dog" + + word_stats = + text + |> String.downcase() + |> String.split(" ") + |> Enum.group_by(&String.first/1) + |> Enum.map(fn {letter, words} -> {letter, length(words)} end) + |> Enum.into(%{}) + + assert word_stats["d"] == ___ + assert word_stats["t"] == ___ + assert word_stats["q"] == ___ + end + + koan "Pipes can be used in function definitions for clean APIs" do + defmodule TextProcessor do + @moduledoc false + def clean_and_count(text) do + text + |> String.trim() + |> String.downcase() + |> String.replace(~r/[^\w\s]/, "") + |> String.split() + |> length() + end + end + + assert TextProcessor.clean_and_count(" Hello, World! How are you? ") == ___ + end + + koan "Error handling can be integrated into pipelines" do + safe_divide = fn + {x, 0} -> {:error, :division_by_zero} + {x, y} -> {:ok, x / y} + end + + pipeline = fn x, y -> + {x, y} + |> safe_divide.() + |> case do + {:ok, result} -> "Result: #{result}" + {:error, reason} -> "Error: #{reason}" + end + end + + assert pipeline.(10, 2) == ___ + assert pipeline.(10, 0) == ___ + end +end diff --git a/lib/koans/24_with_statement.ex b/lib/koans/24_with_statement.ex new file mode 100644 index 00000000..90d70d93 --- /dev/null +++ b/lib/koans/24_with_statement.ex @@ -0,0 +1,187 @@ +defmodule WithStatement do + @moduledoc false + use Koans + + @intro "The With Statement - Elegant error handling and happy path programming" + + koan "With lets you chain operations that might fail" do + parse_and_add = fn str1, str2 -> + with {a, ""} <- Integer.parse(str1), + {b, ""} <- Integer.parse(str2) do + {:ok, a + b} + else + :error -> {:error, :invalid_number} + end + end + + assert parse_and_add.("5", "4") == ___ + assert parse_and_add.("abc", "1") == ___ + end + + koan "With short-circuits on the first non-matching pattern" do + process_user = fn user_data -> + with {:ok, name} <- Map.fetch(user_data, :name), + {:ok, age} <- Map.fetch(user_data, :age), + true <- age >= 18 do + {:ok, "Adult user: #{name}"} + else + :error -> {:error, :missing_data} + false -> {:error, :underage} + end + end + + assert process_user.(%{name: "Alice", age: 25}) == ___ + assert process_user.(%{name: "Bob", age: 16}) == ___ + assert process_user.(%{age: 25}) == ___ + end + + defp safe_divide(_, 0), do: {:error, :division_by_zero} + defp safe_divide(x, y), do: {:ok, x / y} + + defp safe_sqrt(x) when x < 0, do: {:error, :negative_sqrt} + defp safe_sqrt(x), do: {:ok, :math.sqrt(x)} + + koan "With can handle multiple different error patterns" do + divide_and_sqrt = fn x, y -> + with {:ok, division} <- safe_divide(x, y), + {:ok, sqrt} <- safe_sqrt(division) do + {:ok, sqrt} + else + {:error, :division_by_zero} -> {:error, "Cannot divide by zero"} + {:error, :negative_sqrt} -> {:error, "Cannot take square root of negative number"} + end + end + + assert divide_and_sqrt.(16, 4) == ___ + assert divide_and_sqrt.(10, 0) == ___ + assert divide_and_sqrt.(-16, 4) == ___ + end + + koan "With works great for nested data extraction" do + get_user_email = fn data -> + with {:ok, user} <- Map.fetch(data, :user), + {:ok, profile} <- Map.fetch(user, :profile), + {:ok, email} <- Map.fetch(profile, :email), + true <- String.contains?(email, "@") do + {:ok, email} + else + :error -> {:error, :missing_data} + false -> {:error, :invalid_email} + end + end + + valid_data = %{ + user: %{ + profile: %{ + email: "user@example.com" + } + } + } + + invalid_email_data = %{ + user: %{ + profile: %{ + email: "notanemail" + } + } + } + + assert get_user_email.(valid_data) == ___ + assert get_user_email.(invalid_email_data) == ___ + assert get_user_email.(%{}) == ___ + end + + koan "With can combine pattern matching with guards" do + process_number = fn input -> + with {num, ""} <- Integer.parse(input), + true <- num > 0, + result when result < 1000 <- num * 10 do + {:ok, result} + else + :error -> {:error, :not_a_number} + false -> {:error, :not_positive} + result when result >= 100 -> {:error, :result_too_large} + end + end + + assert process_number.("5") == ___ + assert process_number.("-5") == ___ + assert process_number.("150") == ___ + assert process_number.("abc") == ___ + end + + koan "With clauses can have side effects and assignments" do + register_user = fn user_data -> + with {:ok, email} <- validate_email(user_data[:email]), + {:ok, password} <- validate_password(user_data[:password]), + hashed_password = hash_password(password), + {:ok, user} <- save_user(email, hashed_password) do + {:ok, user} + else + {:error, reason} -> {:error, reason} + end + end + + user_data = %{email: "test@example.com", password: "secure123"} + assert ___ = register_user.(user_data) + end + + defp validate_email(email) when is_binary(email) and byte_size(email) > 0 do + if String.contains?(email, "@"), do: {:ok, email}, else: {:error, :invalid_email} + end + + defp validate_email(_), do: {:error, :invalid_email} + + defp validate_password(password) when is_binary(password) and byte_size(password) >= 6 do + {:ok, password} + end + + defp validate_password(_), do: {:error, :weak_password} + + defp hash_password(password), do: "hashed_" <> password + + defp save_user(email, hashed_password) do + {:ok, %{id: 1, email: email, password: hashed_password}} + end + + koan "With can be used without an else clause for simpler cases" do + simple_calculation = fn x, y -> + with num1 when is_number(num1) <- x, + num2 when is_number(num2) <- y do + num1 + num2 + end + end + + assert simple_calculation.(5, 3) == ___ + # When pattern doesn't match and no else, returns the non-matching value + assert simple_calculation.("5", 3) == ___ + end + + koan "With can handle complex nested error scenarios" do + complex_workflow = fn data -> + with {:ok, step1} <- step_one(data), + {:ok, step2} <- step_two(step1), + {:ok, step3} <- step_three(step2) do + {:ok, step3} + else + {:error, :step1_failed} -> {:error, "Failed at step 1: invalid input"} + {:error, :step2_failed} -> {:error, "Failed at step 2: processing error"} + {:error, :step3_failed} -> {:error, "Failed at step 3: final validation error"} + other -> {:error, "Unexpected error: #{inspect(other)}"} + end + end + + assert complex_workflow.("valid") == ___ + assert complex_workflow.("step1_fail") == ___ + assert complex_workflow.("step2_fail") == ___ + end + + defp step_one("step1_fail"), do: {:error, :step1_failed} + defp step_one(data), do: {:ok, "step1_" <> data} + + defp step_two("step1_step2_fail"), do: {:error, :step2_failed} + defp step_two(data), do: {:ok, "step2_" <> data} + + defp step_three("step2_step1_step3_fail"), do: {:error, :step3_failed} + defp step_three(data), do: {:ok, "step3_" <> data} +end diff --git a/test/koans/control_flow_koans_test.exs b/test/koans/control_flow_koans_test.exs new file mode 100644 index 00000000..c4ac8fc9 --- /dev/null +++ b/test/koans/control_flow_koans_test.exs @@ -0,0 +1,24 @@ +defmodule ControlFlowTests do + use ExUnit.Case + import TestHarness + + test "Control Flow" do + answers = [ + "yes", + "math works", + "will execute", + {:multiple, ["falsy", "falsy", "truthy", "truthy", "truthy"]}, + "matched with x = 2", + {:multiple, ["positive", "zero", "negative"]}, + {:multiple, ["empty", "one element", "two elements", "many elements"]}, + "warm", + {:multiple, [{:ok, 5}, {:error, "division by zero"}]}, + {:multiple, ["Success: Hello", "Client error: 404", "Request failed: timeout"]}, + {:multiple, + ["positive even integer", "positive odd integer", "negative integer", "float", "other"]}, + "verified active user" + ] + + test_all(ControlFlow, answers) + end +end diff --git a/test/koans/enum_koans_test.exs b/test/koans/enum_koans_test.exs index cdfea9eb..5fbab949 100644 --- a/test/koans/enum_koans_test.exs +++ b/test/koans/enum_koans_test.exs @@ -5,7 +5,9 @@ defmodule EnumTests do test "Enums" do answers = [ 3, - 2, + 3, + 1, + {:multiple, [2, ArgumentError]}, {:multiple, [true, false]}, {:multiple, [true, false]}, {:multiple, [true, false]}, @@ -19,7 +21,11 @@ defmodule EnumTests do 2, nil, :no_such_element, - 6 + 6, + {:multiple, [[[1, 2], [3, 4], [5, 6]], [[1, 2, 3], [4, 5]]]}, + [1, 10, 2, 20, 3, 30], + {:multiple, [["apple", "apricot"], ["banana", "blueberry"]]}, + [4, 8, 12] ] test_all(Enums, answers) diff --git a/test/koans/error_handling_koans_test.exs b/test/koans/error_handling_koans_test.exs new file mode 100644 index 00000000..30dc7478 --- /dev/null +++ b/test/koans/error_handling_koans_test.exs @@ -0,0 +1,36 @@ +defmodule ErrorHandlingTests do + use ExUnit.Case + import TestHarness + + test "Error Handling" do + answers = [ + {:multiple, [{:ok, 123}, {:error, :invalid_format}]}, + "Result: 5.0", + "Cannot divide by zero!", + {:multiple, [{:ok, 2}, {:error, :invalid_argument}, {:error, "abc is not a list"}]}, + {:multiple, + [ + {:error, :arithmetic}, + {:error, :missing_key}, + {:error, :invalid_argument}, + {:ok, "success"} + ]}, + "caught thrown value", + :returned_value, + {:multiple, [:success, "it worked"]}, + "caught custom error: custom failure", + "key not found", + "caught normal exit", + {:multiple, + [ + {:error, {:exception, "connection failed"}}, + {:error, :timeout}, + {:error, :invalid_query}, + {:ok, "data retrieved"} + ]}, + {:multiple, [:conversion_error, "user input processing"]} + ] + + test_all(ErrorHandling, answers) + end +end diff --git a/test/koans/functions_koans_test.exs b/test/koans/functions_koans_test.exs index 4e504ad1..2ad0bf70 100644 --- a/test/koans/functions_koans_test.exs +++ b/test/koans/functions_koans_test.exs @@ -19,7 +19,9 @@ defmodule FunctionsTests do 100, 1000, "Full Name", - {:multiple, ["GOOD", "good"]} + {:multiple, [24, "hello_world"]}, + {:multiple, ["GOOD", "good"]}, + {:multiple, [12, 5]} ] test_all(Functions, answers) diff --git a/test/koans/genservers_koans_test.exs b/test/koans/genservers_koans_test.exs index 3a6569e2..619c6cd2 100644 --- a/test/koans/genservers_koans_test.exs +++ b/test/koans/genservers_koans_test.exs @@ -12,7 +12,9 @@ defmodule GenServersTests do {:error, "Incorrect password!"}, "Congrats! Your process was successfully named.", {:ok, "Laptop unlocked!"}, - {:multiple, ["Laptop unlocked!", "Incorrect password!", "Jack Sparrow"]} + {:multiple, ["Laptop unlocked!", "Incorrect password!", "Jack Sparrow"]}, + 1, + {:multiple, ["the state", "the state"]} ] test_all(GenServers, answers) diff --git a/test/koans/patterns_koans_test.exs b/test/koans/patterns_koans_test.exs index 2d9fa364..8286f585 100644 --- a/test/koans/patterns_koans_test.exs +++ b/test/koans/patterns_koans_test.exs @@ -15,7 +15,7 @@ defmodule PatternsTests do [1, 2, 3], {:multiple, ["Meow", "Woof", "Eh?"]}, {:multiple, ["Mickey", "Donald", "I need a name!"]}, - "dog", + "barking", "Max", {:multiple, [true, false]}, "Max", @@ -23,7 +23,10 @@ defmodule PatternsTests do 2, {:multiple, ["The number One", "The number Two", "The number 3"]}, "same", - 2 + 2, + {:multiple, [30, "dark"]}, + {:multiple, [1, 2, [3, 4, 5], 1]}, + {:multiple, [5, :division_by_zero]} ] test_all(PatternMatching, answers) diff --git a/test/koans/pipe_operator_koans_test.exs b/test/koans/pipe_operator_koans_test.exs new file mode 100644 index 00000000..2ab34c10 --- /dev/null +++ b/test/koans/pipe_operator_koans_test.exs @@ -0,0 +1,27 @@ +defmodule PipeOperatorTests do + use ExUnit.Case + import TestHarness + + test "Pipe Operator" do + answers = [ + "HELLO-WORLD", + "hello_world", + [6, 8, 10], + "hello, world", + 20, + "1-2-3", + ["Alice", "Charlie"], + ["QUICK", "BROWN", "JUMPS"], + [a: 2, b: 4, c: 6], + {:multiple, [{:ok, 84}, {:error, :invalid_number}]}, + {:multiple, [["HELLO", "WORLD"], ["hello", "world"]]}, + 250, + 12, + {:multiple, [2, 2, 1]}, + 5, + {:multiple, ["Result: 5.0", "Error: division_by_zero"]} + ] + + test_all(PipeOperator, answers) + end +end diff --git a/test/koans/with_statement_koans_test.exs b/test/koans/with_statement_koans_test.exs new file mode 100644 index 00000000..0a8a5cde --- /dev/null +++ b/test/koans/with_statement_koans_test.exs @@ -0,0 +1,30 @@ +defmodule WithStatementTests do + use ExUnit.Case + import TestHarness + + test "With Statement" do + answers = [ + {:multiple, [{:ok, 9}, {:error, :invalid_number}]}, + {:multiple, [{:ok, "Adult user: Alice"}, {:error, :underage}, {:error, :missing_data}]}, + {:multiple, + [ + {:ok, 2}, + {:error, "Cannot divide by zero"}, + {:error, "Cannot take square root of negative number"} + ]}, + {:multiple, [{:ok, "user@example.com"}, {:error, :invalid_email}, {:error, :missing_data}]}, + {:multiple, + [{:ok, 50}, {:error, :not_positive}, {:error, :result_too_large}, {:error, :not_a_number}]}, + {:ok, %{id: 1}}, + {:multiple, [8, "5"]}, + {:multiple, + [ + {:ok, "step3_step2_step1_valid"}, + {:error, "Failed at step 1: invalid input"}, + {:error, "Failed at step 2: processing error"} + ]} + ] + + test_all(WithStatement, answers) + end +end