From 8a0a02f996ff80023830830c73cda314099c760c Mon Sep 17 00:00:00 2001 From: Danylo Negrienko Date: Thu, 7 Mar 2024 04:37:20 -0500 Subject: [PATCH] rpn-calculator-inspection --- .../.exercism/config.json | 22 + .../.exercism/metadata.json | 1 + .../rpn-calculator-inspection/.formatter.exs | 4 + elixir/rpn-calculator-inspection/.gitignore | 24 ++ elixir/rpn-calculator-inspection/HELP.md | 75 ++++ elixir/rpn-calculator-inspection/HINTS.md | 45 +++ elixir/rpn-calculator-inspection/README.md | 149 +++++++ .../lib/rpn_calculator_inspection.ex | 29 ++ elixir/rpn-calculator-inspection/mix.exs | 28 ++ .../test/rpn_calculator_inspection_test.exs | 378 ++++++++++++++++++ .../test/test_helper.exs | 2 + 11 files changed, 757 insertions(+) create mode 100644 elixir/rpn-calculator-inspection/.exercism/config.json create mode 100644 elixir/rpn-calculator-inspection/.exercism/metadata.json create mode 100644 elixir/rpn-calculator-inspection/.formatter.exs create mode 100644 elixir/rpn-calculator-inspection/.gitignore create mode 100644 elixir/rpn-calculator-inspection/HELP.md create mode 100644 elixir/rpn-calculator-inspection/HINTS.md create mode 100644 elixir/rpn-calculator-inspection/README.md create mode 100644 elixir/rpn-calculator-inspection/lib/rpn_calculator_inspection.ex create mode 100644 elixir/rpn-calculator-inspection/mix.exs create mode 100644 elixir/rpn-calculator-inspection/test/rpn_calculator_inspection_test.exs create mode 100644 elixir/rpn-calculator-inspection/test/test_helper.exs diff --git a/elixir/rpn-calculator-inspection/.exercism/config.json b/elixir/rpn-calculator-inspection/.exercism/config.json new file mode 100644 index 0000000..985fd08 --- /dev/null +++ b/elixir/rpn-calculator-inspection/.exercism/config.json @@ -0,0 +1,22 @@ +{ + "authors": [ + "angelikatyborska" + ], + "contributors": [ + "neenjaw" + ], + "files": { + "solution": [ + "lib/rpn_calculator_inspection.ex" + ], + "test": [ + "test/rpn_calculator_inspection_test.exs" + ], + "exemplar": [ + ".meta/exemplar.ex" + ] + }, + "language_versions": ">=1.10", + "icon": "instruments-of-texas", + "blurb": "Learn about tasks and links by inspecting a working prototype of your experimental Reverse Polish Notation calculator." +} diff --git a/elixir/rpn-calculator-inspection/.exercism/metadata.json b/elixir/rpn-calculator-inspection/.exercism/metadata.json new file mode 100644 index 0000000..9c6e85f --- /dev/null +++ b/elixir/rpn-calculator-inspection/.exercism/metadata.json @@ -0,0 +1 @@ +{"track":"elixir","exercise":"rpn-calculator-inspection","id":"92ce0808eeb9437c854595c53725a0f1","url":"https://exercism.org/tracks/elixir/exercises/rpn-calculator-inspection","handle":"negrienko","is_requester":true,"auto_approve":false} \ No newline at end of file diff --git a/elixir/rpn-calculator-inspection/.formatter.exs b/elixir/rpn-calculator-inspection/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/elixir/rpn-calculator-inspection/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/elixir/rpn-calculator-inspection/.gitignore b/elixir/rpn-calculator-inspection/.gitignore new file mode 100644 index 0000000..d343c0d --- /dev/null +++ b/elixir/rpn-calculator-inspection/.gitignore @@ -0,0 +1,24 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +errors-*.tar + diff --git a/elixir/rpn-calculator-inspection/HELP.md b/elixir/rpn-calculator-inspection/HELP.md new file mode 100644 index 0000000..ab09f08 --- /dev/null +++ b/elixir/rpn-calculator-inspection/HELP.md @@ -0,0 +1,75 @@ +# Help + +## Running the tests + +From the terminal, change to the base directory of the exercise then execute the tests with: + +```bash +$ mix test +``` + +This will execute the test file found in the `test` subfolder -- a file ending in `_test.exs` + +Documentation: + +* [`mix test` - Elixir's test execution tool](https://hexdocs.pm/mix/Mix.Tasks.Test.html) +* [`ExUnit` - Elixir's unit test library](https://hexdocs.pm/ex_unit/ExUnit.html) + +## Pending tests + +In test suites of practice exercises, all but the first test have been tagged to be skipped. + +Once you get a test passing, you can unskip the next one by commenting out the relevant `@tag :pending` with a `#` symbol. + +For example: + +```elixir +# @tag :pending +test "shouting" do + assert Bob.hey("WATCH OUT!") == "Whoa, chill out!" +end +``` + +If you wish to run all tests at once, you can include all skipped test by using the `--include` flag on the `mix test` command: + +```bash +$ mix test --include pending +``` + +Or, you can enable all the tests by commenting out the `ExUnit.configure` line in the file `test/test_helper.exs`. + +```elixir +# ExUnit.configure(exclude: :pending, trace: true) +``` + +## Useful `mix test` options + +* `test/.exs:LINENUM` - runs only a single test, the test from `.exs` whose definition is on line `LINENUM` +* `--failed` - runs only tests that failed the last time they ran +* `--max-failures` - the suite stops evaluating tests when this number of test failures +is reached +* `--seed 0` - disables randomization so the tests in a single file will always be ran +in the same order they were defined in + +## Submitting your solution + +You can submit your solution using the `exercism submit lib/rpn_calculator_inspection.ex` command. +This command will upload your solution to the Exercism website and print the solution page's URL. + +It's possible to submit an incomplete solution which allows you to: + +- See how others have completed the exercise +- Request help from a mentor + +## Need to get help? + +If you'd like help solving the exercise, check the following pages: + +- The [Elixir track's documentation](https://exercism.org/docs/tracks/elixir) +- The [Elixir track's programming category on the forum](https://forum.exercism.org/c/programming/elixir) +- [Exercism's programming category on the forum](https://forum.exercism.org/c/programming/5) +- The [Frequently Asked Questions](https://exercism.org/docs/using/faqs) + +Should those resources not suffice, you could submit your (incomplete) solution to request mentoring. + +If you're stuck on something, it may help to look at some of the [available resources](https://exercism.org/docs/tracks/elixir/resources) out there where answers might be found. \ No newline at end of file diff --git a/elixir/rpn-calculator-inspection/HINTS.md b/elixir/rpn-calculator-inspection/HINTS.md new file mode 100644 index 0000000..a94b7f9 --- /dev/null +++ b/elixir/rpn-calculator-inspection/HINTS.md @@ -0,0 +1,45 @@ +# Hints + +## General + +- Read about links and tasks in the official [Getting Started guide][getting-started-links]. +- Take a look at the [documentation of the `Task` module][task]. +- Read ["Demystifying processes in Elixir" on blog.appsignal.com][appsignal-processes]. +- Read ["Understanding Exit Signals in Erlang/Elixir" on crypt.codemancers.com][codemancers-exit-signals]. + +## 1. Start a reliability check for a single input + +- You don't need a task for this step, a regular linked process is enough. +- There is a [built-in function][spawn-link] that runs a given anonymous function in a new process and links it to the current process. + +## 2. Interpret the results of a reliability check + +- To receive a single message, call [`receive`][receive] once. It will read the first message that matches any of the given patterns, leaving other messages in the process inbox. +- Either a guard or [the pin operator][pin-operator] can be used to ensure that only messages from a process whose PID matches the PID given to `await_reliability_check_result/2` as an argument will be received. +- [`receive`][receive] accepts an `after` clause to handle timeouts. + +## 3. Run a concurrent reliability check for many inputs + +- The current process must start trapping exits before any new linked processes are spawned. +- Trapping exits in a process is achieved by setting a flag on that process. +- There is a [built-in function][process-flag] that sets a flag on a process and returns the old value of that flag. +- The flag for trapping exits is called `:trap_exit` and accepts a boolean value. +- Make use of `Enum` functions to first start a process for each input, and then await messages from each process. Use the functions implemented in the two previous steps. Note that the map returned by `start_reliability_check/2` matches the map that `await_reliability_check_result/2` expects as the first argument. + +## 4. Run a concurrent correctness check for many inputs + +- Use an asynchronous task for this step. +- There is a [built-in function][task-async] that starts an asynchronous task. +- There is a [built-in function][task-await] that waits for an asynchronous task to finish executing and returns its result. It accepts a timeout as a second argument. +- Make use of `Enum` functions to first start a task for each input, and then wait for each task. + +[spawn-link]: https://hexdocs.pm/elixir/Kernel.html#spawn_link/1 +[pin-operator]: https://hexdocs.pm/elixir/Kernel.SpecialForms.html#%5E/1 +[receive]: https://hexdocs.pm/elixir/Kernel.SpecialForms.html#receive/1 +[process-flag]: https://hexdocs.pm/elixir/Process.html#flag/2 +[task-async]: https://hexdocs.pm/elixir/Task.html#async/1 +[task-await]: https://hexdocs.pm/elixir/Task.html#await/2 +[task]: https://hexdocs.pm/elixir/Task.html +[appsignal-processes]: https://blog.appsignal.com/2017/05/18/elixir-alchemy-demystifying-processes-in-elixir.html +[getting-started-links]: https://hexdocs.pm/elixir/processes.html#links +[codemancers-exit-signals]: https://crypt.codemancers.com/posts/2016-01-24-understanding-exit-signals-in-erlang-slash-elixir/ \ No newline at end of file diff --git a/elixir/rpn-calculator-inspection/README.md b/elixir/rpn-calculator-inspection/README.md new file mode 100644 index 0000000..a9ab87f --- /dev/null +++ b/elixir/rpn-calculator-inspection/README.md @@ -0,0 +1,149 @@ +# RPN Calculator Inspection + +Welcome to RPN Calculator Inspection on Exercism's Elixir Track. +If you need help running the tests or submitting your code, check out `HELP.md`. +If you get stuck on the exercise, check out `HINTS.md`, but try and solve it without using those first :) + +## Introduction + +## Links + +Elixir [processes][exercism-processes] are isolated and don't share anything by default. When an unlinked child process crashes, its parent process is not affected. + +This behavior can be changed by _linking_ processes to one another. If two processes are linked, a failure in one process will be propagated to the other process. Links are **bidirectional**. + +Processes can be spawned already linked to the calling process using `spawn_link/1` which is an atomic operation, or they can be linked later with `Process.link/1`. + +Linking processes can be useful when doing parallelized work when each chunk of work shouldn't be continued in case another chunk fails to finish. + +### Trapping exits + +Linking can also be used for _supervising_ processes. If a process _traps exits_, it will not crash when a process to which it's linked crashes. It will instead receive a message about the crash. This allows it to deal with the crash gracefully, for example by restarting the crashed process. + +A process can be configured to trap exits by calling `Process.flag(:trap_exit, true)`. Note that `Process.flag/2` returns the _old_ value of the flag, not the new one. + +The message that will be sent to the process in case a linked process crashes will match the pattern `{:EXIT, from, reason}`, where `from` is a PID. If `reason` is anything other than the atom `:normal`, that means that the process crashed or was forcefully killed. + +## Tasks + +Tasks are [processes][exercism-processes] meant to execute one specific operation. +They usually don't communicate with other processes, but they can return a result to the process that started the task. + +Tasks are commonly used to parallelize work. + +### `async`/`await` + +To start a task, use `Task.async/1`. It takes an anonymous function as an argument and executes it in a new process that is linked to the caller process. It returns a `%Task{}` struct. + +To get the result of the execution, pass the `%Task{}` struct to `Task.await/2`. It will wait for the task to finish and return its result. The second argument is a timeout in milliseconds, defaulting to 5000. + +Note that between starting the task and awaiting the task, the process that started the task is not blocked and might do other operations. + +Any task started with `Task.async/1` should be awaited because it will send a message to the calling process. `Task.await/2` can be called for each task only once. + +### `start`/`start_link` + +If you want to start a task for side-effects only, use `Task.start/1` or `Task.start_link/1`. `Task.start/1` will start a task that is not linked to the calling process, and `Task.start_link/1` will start a task that is linked to the calling process. Both functions return a `{:ok, pid}` tuple. + +[exercism-processes]: https://exercism.org/tracks/elixir/concepts/processes + +## Instructions + +Your work at _Instruments of Texas_ on an experimental RPN calculator continues. Your team has built a few prototypes that need to undergo a thorough inspection, to choose the best one that can be mass-produced. + +You want to conduct two types of checks. + +Firstly, a reliability check that will detect inputs for which the calculator under inspection either crashes or doesn't respond fast enough. To isolate failures, the calculations for each input need to be run in a separate process. Linking and trapping exits in the caller process can be used to detect if the calculation finished or crashed. + +Secondly, a correctness check that will check if for a given input, the result returned by the calculator is as expected. Only calculators that already passed the reliability check will undergo a correctness check, so crashes are not a concern. However, the operations should be run concurrently to speed up the process, which makes it the perfect use case for asynchronous tasks. + +## 1. Start a reliability check for a single input + +Implement the `RPNCalculatorInspection.start_reliability_check/2` function. It should take 2 arguments, a function (the calculator), and an input for the calculator. It should return a map that contains the input and the PID of the spawned process. + +The spawned process should call the given calculator function with the given input. The process should be linked to the caller process. + +```elixir +RPNCalculatorInspection.start_reliability_check(fn _ -> 0 end, "2 3 +") +# => %{input: "2 3 +", pid: #PID<0.169.0>} +``` + +## 2. Interpret the results of a reliability check + +Implement the `RPNCalculatorInspection.await_reliability_check_result/2` function. It should take two arguments. The first argument is a map with the input of the reliability check and the PID of the process running the reliability check for this input, as returned by `RPNCalculatorInspection.start_reliability_check/2`. The second argument is a map that serves as an accumulator for the results of reliability checks with different inputs. + +The function should wait for an exit message. + +If it receives an exit message (`{:EXIT, from, reason}`) with the reason `:normal` from the same process that runs the reliability check, it should return the results map with the value `:ok` added under the key `input`. + +If it receives an exit message with a different reason from the same process that runs the reliability check, it should return the results map with the value `:error` added under the key `input`. + +If it doesn't receive any messages matching those criteria in 100ms, it should return the results map with the value `:timeout` added under the key `input`. + +```elixir +# when an exit message is waiting for the process in its inbox +send(self(), {:EXIT, pid, :normal}) + +RPNCalculatorInspection.await_reliability_check_result( + %{input: "5 7 -", pid: pid}, + %{} +) + +# => %{"5 7 -" => :ok} + +# when there are no messages in the process inbox +RPNCalculatorInspection.await_reliability_check_result( + %{input: "3 2 *", pid: pid}, + %{"5 7 -" => :ok} +) + +# => %{"5 7 -" => :ok, "3 2 *" => :timeout} +``` + +## 3. Run a concurrent reliability check for many inputs + +Implement the `RPNCalculatorInspection.reliability_check/2` function. It should take 2 arguments, a function (the calculator), and a list of inputs for the calculator. + +For every input on the list, it should start the reliability check in a new linked process by using `start_reliability_check/2`. Then, for every process started this way, it should await its results by using `await_reliability_check_result/2`. + +Before starting any processes, the function needs to flag the current process to trap exits, to be able to receive exit messages. Afterwards, it should reset this flag to its original value. + +The function should return a map with the results of reliability checks of all the inputs. + +```elixir +fake_broken_calculator = fn input -> + if String.ends_with?(input, "*"), do: raise("oops") +end + +inputs = ["2 3 +", "10 3 *", "20 2 /"] + +RPNCalculatorInspection.reliability_check(fake_broken_calculator, inputs) +# => %{ +# "2 3 +" => :ok, +# "10 3 *" => :error, +# "20 2 /" => :ok +# } +``` + +## 4. Run a concurrent correctness check for many inputs + +Implement the `RPNCalculatorInspection.correctness_check/2` function. It should take 2 arguments, a function (the calculator), and a list of inputs for the calculator. + +For every input on the list, it should start an asynchronous task that will call the calculator with the given input. Then, for every task started this way, it should await its results for 100ms. + +```elixir +fast_cheating_calculator = fn input -> 14 end +inputs = ["13 1 +", "50 2 *", "1000 2 /"] +RPNCalculatorInspection.correctness_check(fast_cheating_calculator, inputs) +# => [14, 14, 14] +``` + +## Source + +### Created by + +- @angelikatyborska + +### Contributed to by + +- @neenjaw \ No newline at end of file diff --git a/elixir/rpn-calculator-inspection/lib/rpn_calculator_inspection.ex b/elixir/rpn-calculator-inspection/lib/rpn_calculator_inspection.ex new file mode 100644 index 0000000..137fe00 --- /dev/null +++ b/elixir/rpn-calculator-inspection/lib/rpn_calculator_inspection.ex @@ -0,0 +1,29 @@ +defmodule RPNCalculatorInspection do + def start_reliability_check(calculator, input) do + %{input: input, pid: spawn_link(fn -> calculator.(input) end)} + end + + def await_reliability_check_result(%{pid: pid, input: input}, results) do + receive do + {:EXIT, ^pid, :normal} -> Map.put(results, input, :ok) + {:EXIT, ^pid, _reason} -> Map.put(results, input, :error) + after + 100 -> Map.put(results, input, :timeout) + end + end + + def reliability_check(calculator, inputs) do + trap_exit_state = Process.flag(:trap_exit, true) + + inputs + |> Enum.map(&start_reliability_check(calculator, &1)) + |> Enum.reduce(%{}, &await_reliability_check_result/2) + |> tap(fn _ -> Process.flag(:trap_exit, trap_exit_state) end) + end + + def correctness_check(calculator, inputs) do + inputs + |> Enum.map(&Task.async(fn -> calculator.(&1) end)) + |> Enum.map(&Task.await(&1, 100)) + end +end diff --git a/elixir/rpn-calculator-inspection/mix.exs b/elixir/rpn-calculator-inspection/mix.exs new file mode 100644 index 0000000..dcc0cbf --- /dev/null +++ b/elixir/rpn-calculator-inspection/mix.exs @@ -0,0 +1,28 @@ +defmodule RPNCalculator.MixProject do + use Mix.Project + + def project do + [ + app: :rpn_calculator_inspection, + version: "0.1.0", + # elixir: "~> 1.10", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + # {:dep_from_hexpm, "~> 0.3.0"}, + # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + ] + end +end diff --git a/elixir/rpn-calculator-inspection/test/rpn_calculator_inspection_test.exs b/elixir/rpn-calculator-inspection/test/rpn_calculator_inspection_test.exs new file mode 100644 index 0000000..5dc81f4 --- /dev/null +++ b/elixir/rpn-calculator-inspection/test/rpn_calculator_inspection_test.exs @@ -0,0 +1,378 @@ +defmodule RPNCalculatorInspectionTest do + use ExUnit.Case, async: false + + defmodule RPNCalculator do + def unsafe_division(input) do + [_, a, b] = Regex.run(~r/^(\d*) (\d*) \/$/, input) + String.to_integer(a) / String.to_integer(b) + end + end + + defp flush_messages() do + receive do + _ -> + flush_messages() + after + 50 -> + nil + end + end + + setup_all do + # turning off the logger to avoid error logs spamming the output + # when the functions are expected to crash + Logger.configure(level: :none) + end + + setup do + # just in case, we clear the test process inbox before each test + flush_messages() + %{} + end + + describe "start_reliability_check" do + @tag task_id: 1 + test "returns a map with test data" do + calculator = fn _ -> 0 end + input = "1 2 +" + result = RPNCalculatorInspection.start_reliability_check(calculator, input) + assert is_map(result) + assert is_pid(result.pid) + assert result.input == input + end + + @tag task_id: 1 + test "starts a linked process" do + old_value = Process.flag(:trap_exit, true) + + calculator = fn _ -> :timer.sleep(50) end + input = "1 2 +" + + %{pid: pid} = RPNCalculatorInspection.start_reliability_check(calculator, input) + + assert pid in Keyword.get(Process.info(self()), :links) + assert_receive {:EXIT, ^pid, :normal} + + Process.flag(:trap_exit, old_value) + end + + @tag task_id: 1 + test "the process runs the calculator function with the given input" do + caller_process_pid = self() + + calculator = fn input -> send(caller_process_pid, input) end + input = "7 3 +" + + RPNCalculatorInspection.start_reliability_check(calculator, input) + + assert_receive ^input + end + end + + describe "await_reliability_check_result" do + @tag task_id: 2 + test "adds `input` => :ok to the results after a normal exit" do + caller_process_pid = self() + test_data = %{pid: caller_process_pid, input: "2 3 +"} + check_results_so_far = %{"2 0 /" => :error} + expected_result = %{"2 0 /" => :error, "2 3 +" => :ok} + + send(caller_process_pid, {:EXIT, caller_process_pid, :normal}) + + assert RPNCalculatorInspection.await_reliability_check_result( + test_data, + check_results_so_far + ) == + expected_result + end + + @tag task_id: 2 + test "adds `input` => :error to the results after an abnormal exit" do + caller_process_pid = self() + test_data = %{pid: caller_process_pid, input: "3 0 /"} + check_results_so_far = %{"1 1 +" => :ok} + expected_result = %{"1 1 +" => :ok, "3 0 /" => :error} + + send(caller_process_pid, {:EXIT, caller_process_pid, {%ArithmeticError{}, []}}) + + assert RPNCalculatorInspection.await_reliability_check_result( + test_data, + check_results_so_far + ) == + expected_result + end + + @tag task_id: 2 + test "adds `input` => :timeout to the results if no message arrives in 100ms" do + caller_process_pid = self() + test_data = %{pid: caller_process_pid, input: "24 12 /"} + check_results_so_far = %{"3 1 +" => :ok} + expected_result = %{"3 1 +" => :ok, "24 12 /" => :timeout} + + task = + Task.async(fn -> + :timer.sleep(200) + # this message should arrive too late + send(caller_process_pid, {:EXIT, caller_process_pid, {%ArithmeticError{}, []}}) + end) + + assert RPNCalculatorInspection.await_reliability_check_result( + test_data, + check_results_so_far + ) == + expected_result + + Task.await(task) + end + + @tag task_id: 2 + test "normal exit messages from processes whose pids don't match stay in the inbox" do + caller_process_pid = self() + other_process_pid = spawn(fn -> nil end) + test_data = %{pid: caller_process_pid, input: "5 0 /"} + check_results_so_far = %{"5 0 +" => :ok} + expected_result = %{"5 0 +" => :ok, "5 0 /" => :error} + + send(caller_process_pid, {:EXIT, other_process_pid, :normal}) + send(caller_process_pid, {:EXIT, caller_process_pid, {%ArithmeticError{}, []}}) + + assert RPNCalculatorInspection.await_reliability_check_result( + test_data, + check_results_so_far + ) == + expected_result + + assert Keyword.get(Process.info(self()), :message_queue_len) == 1 + end + + @tag task_id: 2 + test "abnormal exit messages from processes whose pids don't match stay in the inbox" do + caller_process_pid = self() + other_process_pid = spawn(fn -> nil end) + test_data = %{pid: caller_process_pid, input: "2 2 +"} + check_results_so_far = %{"0 0 /" => :error} + expected_result = %{"0 0 /" => :error, "2 2 +" => :ok} + + send(caller_process_pid, {:EXIT, other_process_pid, {%ArithmeticError{}, []}}) + send(caller_process_pid, {:EXIT, caller_process_pid, :normal}) + + assert RPNCalculatorInspection.await_reliability_check_result( + test_data, + check_results_so_far + ) == + expected_result + + assert Keyword.get(Process.info(self()), :message_queue_len) == 1 + end + + @tag task_id: 2 + test "any other messages stay in the inbox" do + caller_process_pid = self() + test_data = %{pid: caller_process_pid, input: "4 2 /"} + check_results_so_far = %{"4 0 /" => :error} + expected_result = %{"4 0 /" => :error, "4 2 /" => :ok} + + send(caller_process_pid, {:exit, caller_process_pid, {%ArithmeticError{}, []}}) + send(caller_process_pid, {:something_else, caller_process_pid, {%ArithmeticError{}, []}}) + send(caller_process_pid, :something_else) + send(caller_process_pid, {:EXIT, caller_process_pid, :normal}) + + assert RPNCalculatorInspection.await_reliability_check_result( + test_data, + check_results_so_far + ) == + expected_result + + assert Keyword.get(Process.info(self()), :message_queue_len) == 3 + end + + @tag task_id: 2 + test "doesn't change the trap_exit flag of the caller process" do + caller_process_pid = self() + Process.flag(:trap_exit, false) + + test_data = %{pid: caller_process_pid, input: "30 3 /"} + check_results_so_far = %{} + expected_result = %{"30 3 /" => :ok} + + send(caller_process_pid, {:EXIT, caller_process_pid, :normal}) + + assert RPNCalculatorInspection.await_reliability_check_result( + test_data, + check_results_so_far + ) == + expected_result + + assert Keyword.get(Process.info(self()), :trap_exit) == false + Process.flag(:trap_exit, true) + + send(caller_process_pid, {:EXIT, caller_process_pid, :normal}) + + assert RPNCalculatorInspection.await_reliability_check_result( + test_data, + check_results_so_far + ) == + expected_result + + assert Keyword.get(Process.info(self()), :trap_exit) == true + end + end + + describe "reliability_check" do + @tag task_id: 3 + test "returns an empty map when input list empty" do + inputs = [] + calculator = &RPNCalculator.unsafe_division/1 + outputs = %{} + assert RPNCalculatorInspection.reliability_check(calculator, inputs) == outputs + end + + @tag task_id: 3 + test "returns a map with inputs as keys and :ok as values" do + inputs = ["4 2 /", "8 2 /", "6 3 /"] + calculator = &RPNCalculator.unsafe_division/1 + + outputs = %{ + "4 2 /" => :ok, + "8 2 /" => :ok, + "6 3 /" => :ok + } + + assert RPNCalculatorInspection.reliability_check(calculator, inputs) == outputs + end + + @tag task_id: 3 + test "returns a map when input list has 1000 elements" do + inputs = Enum.map(1..1000, &"#{2 * &1} #{&1} /") + calculator = &RPNCalculator.unsafe_division/1 + outputs = 1..1000 |> Enum.map(&{"#{2 * &1} #{&1} /", :ok}) |> Enum.into(%{}) + + assert RPNCalculatorInspection.reliability_check(calculator, inputs) == outputs + end + + @tag task_id: 3 + test "returns a map when input list has 1000 elements and the calculator takes 50ms for each calculation" do + inputs = Enum.map(1..1000, &"#{2 * &1} #{&1} /") + parent_pid = self() + calculator = fn input -> :timer.sleep(50) && RPNCalculator.unsafe_division(input) end + + Task.start_link(fn -> + outputs = RPNCalculatorInspection.reliability_check(calculator, inputs) + send(parent_pid, {:outputs, outputs}) + end) + + expected = 1..1000 |> Enum.map(&{"#{2 * &1} #{&1} /", :ok}) |> Enum.into(%{}) + + assert_receive( + {:outputs, ^expected}, + 5000, + "This test shouldn't take this long to complete. Make sure to start all tasks first before awaiting them." + ) + end + + @tag task_id: 3 + test "returns :error values for inputs that cause the calculator to crash" do + inputs = ["3 0 /", "22 11 /", "4 0 /"] + calculator = &RPNCalculator.unsafe_division/1 + + outputs = %{ + "3 0 /" => :error, + "22 11 /" => :ok, + "4 0 /" => :error + } + + assert RPNCalculatorInspection.reliability_check(calculator, inputs) == outputs + end + + @tag task_id: 3 + test "returns a map when input list has 1000 elements and all of them crash" do + inputs = Enum.map(1..1000, &"#{2 * &1} 0 /") + calculator = &RPNCalculator.unsafe_division/1 + outputs = 1..1000 |> Enum.map(&{"#{2 * &1} 0 /", :error}) |> Enum.into(%{}) + + assert RPNCalculatorInspection.reliability_check(calculator, inputs) == outputs + end + + @tag task_id: 3 + test "restores the original value of the trap_exit flag" do + inputs = ["3 0 /", "22 11 /", "4 0 /"] + calculator = &RPNCalculator.unsafe_division/1 + + outputs = %{ + "3 0 /" => :error, + "22 11 /" => :ok, + "4 0 /" => :error + } + + Process.flag(:trap_exit, false) + assert RPNCalculatorInspection.reliability_check(calculator, inputs) == outputs + assert Keyword.get(Process.info(self()), :trap_exit) == false + + Process.flag(:trap_exit, true) + assert RPNCalculatorInspection.reliability_check(calculator, inputs) == outputs + assert Keyword.get(Process.info(self()), :trap_exit) == true + end + end + + describe "correctness_check" do + @tag task_id: 4 + test "returns an empty list when input list empty" do + inputs = [] + calculator = &RPNCalculator.unsafe_division/1 + outputs = [] + assert RPNCalculatorInspection.correctness_check(calculator, inputs) == outputs + end + + @tag task_id: 4 + test "returns a list of results" do + inputs = ["3 2 /", "4 2 /", "5 2 /"] + calculator = &RPNCalculator.unsafe_division/1 + outputs = [1.5, 2, 2.5] + assert RPNCalculatorInspection.correctness_check(calculator, inputs) == outputs + end + + @tag task_id: 4 + test "returns a list of results when input list has 1000 elements" do + inputs = Enum.map(1..1000, &"100 #{&1} /") + calculator = &RPNCalculator.unsafe_division/1 + outputs = Enum.map(1..1000, &(100 / &1)) + assert RPNCalculatorInspection.correctness_check(calculator, inputs) == outputs + end + + @tag task_id: 4 + test "returns a list of results when input list has 1000 elements and the calculator takes 50ms for each calculation" do + inputs = Enum.map(1..1000, &"100 #{&1} /") + parent_pid = self() + calculator = fn input -> :timer.sleep(50) && RPNCalculator.unsafe_division(input) end + + Task.start_link(fn -> + outputs = RPNCalculatorInspection.correctness_check(calculator, inputs) + send(parent_pid, {:outputs, outputs}) + end) + + expected = Enum.map(1..1000, &(100 / &1)) + + assert_receive( + {:outputs, ^expected}, + 5000, + "This test shouldn't take this long to complete. Make sure to start all tasks first before awaiting them." + ) + end + + @tag task_id: 4 + test "awaits a single task for 100ms" do + inputs = ["1 1 /1"] + calculator = fn _ -> :timer.sleep(500) end + + Process.flag(:trap_exit, true) + pid = spawn_link(fn -> RPNCalculatorInspection.correctness_check(calculator, inputs) end) + + assert_receive {:EXIT, ^pid, {:timeout, {Task, task_fn, [_task, 100]}}} + when task_fn in [:await, :await_many], + 150, + "expected to receive a timemout message from Task.await/2 or Task.await_many/2" + + Process.flag(:trap_exit, false) + end + end +end diff --git a/elixir/rpn-calculator-inspection/test/test_helper.exs b/elixir/rpn-calculator-inspection/test/test_helper.exs new file mode 100644 index 0000000..e8677a3 --- /dev/null +++ b/elixir/rpn-calculator-inspection/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +ExUnit.configure(exclude: :pending, trace: true, seed: 0)