take_a_number_deluxe

This commit is contained in:
Danil Negrienko 2024-03-10 00:20:16 -05:00
parent 5203753bd5
commit def44c9d46
13 changed files with 1157 additions and 0 deletions

View File

@ -0,0 +1,26 @@
{
"authors": [
"angelikatyborska"
],
"contributors": [
"jiegillet"
],
"files": {
"solution": [
"lib/take_a_number_deluxe.ex"
],
"test": [
"test/take_a_number_deluxe_test.exs"
],
"exemplar": [
".meta/exemplar.ex"
],
"editor": [
"lib/take_a_number_deluxe/state.ex",
"lib/take_a_number_deluxe/queue.ex"
]
},
"language_versions": ">=1.10",
"icon": "take-a-number",
"blurb": "Learn about GenServers by writing an embedded system for a Take-A-Number machine."
}

View File

@ -0,0 +1 @@
{"track":"elixir","exercise":"take-a-number-deluxe","id":"0c30c0b2b5744d3ca17d64d02cb34d6d","url":"https://exercism.org/tracks/elixir/exercises/take-a-number-deluxe","handle":"negrienko","is_requester":true,"auto_approve":false}

View File

@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

24
elixir/take-a-number-deluxe/.gitignore vendored Normal file
View File

@ -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").
nil-*.tar

View File

@ -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/<FILE>.exs:LINENUM` - runs only a single test, the test from `<FILE>.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/take_a_number_deluxe.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.

View File

@ -0,0 +1,81 @@
# Hints
## General
- Read about `GenServer` in the official [Getting Started guide][getting-started-genserver].
- Read about `GenServer` on [elixirschool.com][elixir-school-genserver].
- Read about the `GenServer` behaviour [in the documentation][genserver].
## 1. Start the machine
- Remember to [use][use] the [`GenServer` behaviour][genserver].
- There is [a built-in function][start-link] that starts a linked `GenServer` process. The only thing that `TakeANumberDeluxe.start_link/2` needs to do is call that function with the right arguments.
- `__MODULE__` is a special variable that holds the name of the current module.
- Implement the [`GenServer` callback used when starting the process][init].
- The callback should return either `{:ok, state}` or `{:stop, reason}`.
- Read the options from the `init_arg` keyword list.
- There is [a built-in function][keyword-get] to get a value from a keyword list.
- Use `TakeANumberDeluxe.State.new/2` to get the initial state.
- Use `@impl` above your callback implementation to mark which behaviour this callback comes from.
## 2. Report machine state
- There is [a built-in function][call] that sends a message to a `GenServer` process and receives a reply. The only thing that `TakeANumberDeluxe.report_state/1` needs to do is call that function with the right arguments.
- The messages sent to a server can be anything, but atoms are best.
- Implement the [`GenServer` callback used when handling messages that need a reply][handle-call].
- The callback should return `{:reply, reply, state}`.
- Pass the state as the reply.
- Use `@impl` above your callback implementation to mark which behaviour this callback comes from.
## 3. Queue new numbers
- There is [a built-in function][call] that sends a message to a `GenServer` process and receives a reply. The only thing that `TakeANumberDeluxe.queue_new_number/1` needs to do is call that function with the right arguments.
- The messages sent to a server can be anything, but atoms are best.
- Implement the [`GenServer` callback used when handling messages that need a reply][handle-call].
- The callback should return `{:reply, reply, state}`.
- Get the reply and the new state by calling `TakeANumberDeluxe.State.queue_new_number/1`. Use a [`case`][case] expression to pattern match the return value.
- The reply should be either `{:ok, new_number}` or `{:error, error}`.
- Use `@impl` above your callback implementation to mark which behaviour this callback comes from.
## 4. Serve next queued number
- There is [a built-in function][call] that sends a message to a `GenServer` process and receives a reply. The only thing that `TakeANumberDeluxe.serve_next_queued_number/2` needs to do is call that function with the right arguments.
- The messages sent to a server can be anything, but tuples are best if an argument needs to be sent in the message. Use a message like this: `{:my_message_name, some_argument}`.
- Implement the [`GenServer` callback used when handling messages that need a reply][handle-call].
- The callback should return `{:reply, reply, state}`.
- Get the reply and the new state by calling `TakeANumberDeluxe.State.serve_next_queued_number/2`. Use a [`case`][case] expression to pattern match the return value.
- The reply should be either `{:ok, next_number}` or `{:error, error}`.
- Use `@impl` above your callback implementation to mark which behaviour this callback comes from.
## 5. Reset state
- There is [a built-in function][cast] that sends a message to a `GenServer` process and does not wait for a reply. The only thing that `TakeANumberDeluxe.reset_state/1` needs to do is call that function with the right arguments.
- The messages sent to a server can be anything, but atoms are best.
- Implement the [`GenServer` callback used when handling messages that do not need a reply][handle-cast].
- The callback should return `{:noreply, state}`.
- Use `TakeANumberDeluxe.State.new/2` to get the new state, just like in `init/1`.
- Use `@impl` above your callback implementation to mark which behaviour this callback comes from.
## 6. Implement auto shutdown
- Extend all `init/1` and `handle_*` callbacks to return one extra element in their tuples. Its value should be `state.auto_shutdown_timeout`.
- The return value `{:stop, reason}` of `init/1` does not need a timeout.
- Implement the [`GenServer` callback used when handling messages that weren't sent in the usual `GenServer` way][handle-info].
- This callback needs to handle `:timeout` messages and exit the process, but also catch and ignore any other messages.
- To exit a GenServer process, return `{:stop, reason, state}` from the callback.
- The exit reason should be `:normal`.
[getting-started-genserver]: https://hexdocs.pm/elixir/genservers.html
[elixir-school-genserver]: https://elixirschool.com/en/lessons/advanced/otp_concurrency
[genserver]: https://hexdocs.pm/elixir/GenServer.html
[use]: https://hexdocs.pm/elixir/Kernel.html#use/2
[impl]: https://hexdocs.pm/elixir/Module.html#module-impl
[start-link]: https://hexdocs.pm/elixir/GenServer.html#start_link/3
[call]: https://hexdocs.pm/elixir/GenServer.html#call/2
[cast]: https://hexdocs.pm/elixir/GenServer.html#cast/2
[init]: https://hexdocs.pm/elixir/GenServer.html#c:init/1
[handle-call]: https://hexdocs.pm/elixir/GenServer.html#c:handle_call/3
[handle-cast]: https://hexdocs.pm/elixir/GenServer.html#c:handle_cast/2
[handle-info]: https://hexdocs.pm/elixir/GenServer.html#c:handle_info/2
[keyword-get]: https://hexdocs.pm/elixir/Keyword.html#get/3
[case]: https://hexdocs.pm/elixir/Kernel.SpecialForms.html#case/2

View File

@ -0,0 +1,293 @@
# Take-A-Number Deluxe
Welcome to Take-A-Number Deluxe 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
## GenServer
`GenServer` (generic server) is a [behaviour][concept-behaviours] that abstracts common client-server interactions between Elixir processes.
Remember the receive loop from when we learned about [processes][concept-processes]? The `GenServer` behaviour provides abstractions for implementing such loops, and for exchanging messages with a process that runs such a loop. It makes it easier to keep state and execute asynchronous code.
~~~~exercism/note
Be warned that the name `GenServer` is loaded. It is also used to describe a _module_ that _uses_ the `GenServer` behaviour, as well as a _process_ that was started from a module that _uses_ the `GenServer` behaviour.
~~~~
The `GenServer` behaviour defines one required callback, `init/1`, and a few interesting optional callbacks: `handle_call/3`, `handle_cast/2`, and `handle_info/3`. The _clients_ using a `GenServer` aren't supposed to call those callbacks directly. Instead, the `GenServer` module provides functions that clients can use to communicate with a `GenServer` process.
Often, a single module defines both a _client API_, a set of functions that other parts of your Elixir app can call to communicate with this `GenServer` process, and _server callback implementations_, which contain this `GenServer`'s logic.
Let's take a look at a simple example of a `GenServer` first, and then learn what each callback means.
### Example
This is an example server that can respond to the repetitive inquisitions of annoying passengers during a long road trip, more exactly the question: "are we there yet?". It keeps track of how many times this question has been asked, returning increasingly more annoyed responses.
```elixir
defmodule AnnoyingPassengerAutoresponder do
use GenServer
# Client API
def start_link(init_arg) do
GenServer.start_link(__MODULE__, init_arg)
end
def are_we_there_yet?(pid) do
GenServer.call(pid, :are_we_there_yet?)
end
# Server callbacks
@impl GenServer
def init(_init_arg) do
# the initial count of questions asked is always 0
state = 0
{:ok, state}
end
@impl GenServer
def handle_call(:are_we_there_yet?, _from, state) do
reply =
cond do
state <= 3 -> "No."
state <= 10 -> "I told you #{state} times already. No."
true -> "..."
end
# increase the count of questions asked
new_state = state + 1
# reply to the caller
{:reply, reply, new_state}
end
end
```
### Callbacks
#### `init/1`
A server can be started by calling `GenServer.start/3` or `GenServer.start_link/3`. We learned about the difference between those functions in the [links concept][concept-links].
Those two functions:
- Accept a module implementing the `GenServer` behaviour as the first argument.
- Accept anything as the second argument called `init_arg`. As the name suggest, this argument gets passed to the `init/1` callback.
- Accept an optional third argument with advanced options for running the process that we won't cover now.
Starting a server by calling `GenServer.start/3` or `GenServer.start_link/3` will invoke the `init/1` callback in a blocking way. The return value of `init/1` dictates if the server can be started successfully.
The `init/1` callback usually returns one of those values:
- `{:ok, state}`. The server will start its receive loop using `state` as its initial state. `state` can be of any type.
- `{:stop, reason}`. `reason` can be of any type. The server will not start its receive loop. The process will exit with the given reason.
There are also more advanced possibilities that we won't cover now.
If the server's receive loop starts, the functions `GenServer.start/3` and `GenServer.start_link/3` return an `{:ok, pid}` tuple. Otherwise they return `{:error, reason}`
#### `handle_call/3`
A message that requires a reply can be sent to a server process with `GenServer.call/2`. This function expects the `pid` of a running server process as the first argument, and the message as the second argument. The message can be of any type.
The `handle_call/3` callback is responsible for handling and responding to synchronous messages. It receives three arguments:
1. `message` - the value passed as the second argument to `GenServer.call/2`.
2. `from` - the `pid` of the process calling `GenServer.call/2`. Most often this argument can be ignored.
3. `state` - the current state of the server. Remember that its initial value was set in the `init/1` callback.
The `handle_call/3` callback usually returns a 3 tuple of `{:reply, reply, state}`. This means that the second element in the tuple, a `reply` that can be of any type, will be sent back to the caller. The third element in the tuple, `state`, is the new state of the server after handling this message.
There are also more advanced possibilities that we won't cover now.
~~~~exercism/note
To memorize what this callback does by its name,
think of it as "calling" somebody on the phone.
If that person is available, you'll receive a reply immediately (synchronously).
~~~~
#### `handle_cast/2`
A message that doesn't require a reply can be sent to a server process with `GenServer.cast/2`. Its arguments are identical to those of `GenServer.call/2`.
The `handle_cast/2` callback is responsible for handling those messages. It receives two arguments, `message` and `state`, which are the same arguments as in the `handle_call/3` callback (except for `from`).
The `handle_cast/2` callback usually returns a 2 tuple of `{:noreply, state}`.
There are also more advanced possibilities that we won't cover now.
~~~~exercism/note
To memorize what this callback does by its name,
remember that "to cast" also means "to throw".
If you throw a message in a bottle into the sea,
you don't expect to receive a reply immediately,
or maybe ever.
~~~~
#### Should I use `call` or `cast`?
Almost always use `call` even if your client code doesn't need the reply from the server.
Using `call` waits for the reply, which serves as a backpressure mechanism (to prevent clients from sending too many messages at once). Receiving a reply from the server is also the only way to be sure that the server received and handled the client's message.
#### `handle_info/2`
Messages can also end up in the server's inbox by means other than calling `GenServer.call/2` or `GenServer.cast/2`, for example calling the plain `send/2` function.
To handle such messages, use the `handle_info/2` callback. This callback works in exactly the same way as `handle_cast/2`.
The `GenServer` behaviour provides a catch-all implementation of `handle_info/2` that logs errors about unexpected messages. If you override that default implementation, make sure to always include your own catch-all implementation. If you forget, the server will crash if it receives an unexpected message.
### Timeouts
The return value of each of the four callbacks described above can be extended by one more tuple element, a timeout. E.g. instead of returning `{:ok, state}` from `init/1`, return `{:ok, state, timeout}`.
The timeout can be used to detect a lack of messages in the mailbox for a specific period. If the server returns a timeout from one of its callbacks, and the specified number of milliseconds have elapsed with no message arriving, `handle_info/2` is called with `:timeout` as the first argument.
[concept-behaviours]: https://exercism.org/tracks/elixir/concepts/behaviours
[concept-processes]: https://exercism.org/tracks/elixir/concepts/processes
[concept-links]: https://exercism.org/tracks/elixir/concepts/links
## Instructions
The basic Take-A-Number machine was selling really well, but some users were complaining about its lack of advanced features compared to other models available on the market.
The manufacturer listened to user feedback and decided to release a deluxe model with more features, and you once again were tasked with writing the software for this machine.
The new features added to the deluxe model include:
- Keeping track of currently queued numbers.
- Setting the minimum and maximum number. This will allow using multiple deluxe Take-A-Number machines for queueing customers to different departments at the same facility, and to tell apart the departments by the number range.
- Allowing certain numbers to skip the queue to provide priority service to pregnant women and the elderly.
- Auto shutdown to prevent accidentally leaving the machine on for the whole weekend and wasting energy.
The business logic of the machine was already implemented by your colleague and can be found in the module `TakeANumberDeluxe.State`. Now your task is to wrap it in a `GenServer`.
## 1. Start the machine
Use the `GenServer` behaviour in the `TakeANumberDeluxe` module.
Implement the `start_link/1` function and the necessary `GenServer` callback.
The argument passed to `start_link/1` is a keyword list. It contains the keys `:min_number` and `:max_number`. The values under those keys need to be passed to the function `TakeANumberDeluxe.State.new/2`.
If `TakeANumberDeluxe.State.new/2` returns an `{:ok, state}` tuple, the machine should start, using the returned state as its state. If it returns an `{:error, error}` tuple instead, the machine should stop, giving the returned error as the reason for stopping.
```elixir
TakeANumberDeluxe.start_link(min_number: 1, max_number: 9)
# => {:ok, #PID<0.174.0>}
TakeANumberDeluxe.start_link(min_number: 9, max_number: 1)
# => {:error, :invalid_configuration}
```
You might have noticed that the function `TakeANumberDeluxe.State.new/2` also takes an optional third argument, `auto_shutdown_timeout`. We will use it in the last step of this exercise.
## 2. Report machine state
Implement the `report_state/1` function and the necessary `GenServer` callback. The machine should reply to the caller with its current state.
```elixir
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.report_state(machine)
# => %TakeANumberDeluxe.State{
# max_number: 10,
# min_number: 1,
# queue: %TakeANumberDeluxe.Queue{in: [], out: []},
# auto_shutdown_timeout: :infinity,
# }
```
## 3. Queue new numbers
Implement the `queue_new_number/1` function and the necessary `GenServer` callback.
It should call the `TakeANumberDeluxe.State.queue_new_number/1` function with the current state of the machine.
If `TakeANumberDeluxe.State.queue_new_number/1` returns an `{:ok, new_number, new_state}` tuple, the machine should reply to the caller with the new number and set the new state as its state. If it returns a `{:error, error}` tuple instead, the machine should reply to the caller with the error and not change its state.
```elixir
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 2)
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 1}
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 2}
TakeANumberDeluxe.queue_new_number(machine)
# => {:error, :all_possible_numbers_are_in_use}
```
## 4. Serve next queued number
Implement the `serve_next_queued_number/2` function and the necessary `GenServer` callback.
It should call the `TakeANumberDeluxe.State.serve_next_queued_number/2` function with the current state of the machine and its second optional argument, `priority_number`.
If `TakeANumberDeluxe.State.serve_next_queued_number/2` returns an `{:ok, next_number, new_state}` tuple, the machine should reply to the caller with the next number and set the new state as its state. If it returns a `{:error, error}` tuple instead, the machine should reply to the caller with the error and not change its state.
```elixir
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 1}
TakeANumberDeluxe.serve_next_queued_number(machine)
# => {:ok, 1}
TakeANumberDeluxe.serve_next_queued_number(machine)
# => {:error, :empty_queue}
```
## 5. Reset state
Implement the `reset_state/1` function and the necessary `GenServer` callback.
It should call the `TakeANumberDeluxe.State.new/2` function to create a new state using the current state's `min_number` and `max_number`. The machine should set the new state as its state. It should not reply to the caller.
```elixir
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.reset_state(machine)
# => :ok
```
## 6. Implement auto shutdown
Modify starting the machine. It should read the value under the key `:auto_shutdown_timeout` in the keyword list passed as `init_arg` and pass it as the third argument to `TakeANumberDeluxe.State.new/3`. Use the default value of `:infinity` if `:auto_shutdown_timeout` was not given.
Modify resetting the machine state to also pass `auto_shutdown_timeout` to `TakeANumberDeluxe.State.new/3`.
Modify the return values of all implemented callbacks (`init/1` and all `handle_*` callbacks) to set a timeout. Use the value under the key `:auto_shutdown_timeout` in the current machine state. Do not add the timeout to the `{:stop, reason}` return value of `init/1` - timeouts only apply after the server has started its receive loop.
Implement a `GenServer` callback to handle the `:timeout` message that will be sent to the machine if it doesn't receive any other messages within the given timeout. It should exit the process with reason `:normal`.
Make sure to also handle any unexpected messages by ignoring them.
```elixir
{:ok, machine} =
TakeANumberDeluxe.start_link(
min_number: 1,
max_number: 10,
auto_shutdown_timeout: :timer.hours(2)
)
# after 3 hours...
TakeANumberDeluxe.queue_new_number(machine)
# => ** (exit) exited in: GenServer.call(#PID<0.171.0>, :queue_new_number, 5000)
# ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
# (elixir 1.13.0) lib/gen_server.ex:1030: GenServer.call/3
```
## Source
### Created by
- @angelikatyborska
### Contributed to by
- @jiegillet

View File

@ -0,0 +1,94 @@
defmodule TakeANumberDeluxe do
alias TakeANumberDeluxe.State
use GenServer
# Client API
@spec start_link(keyword()) :: {:ok, pid()} | {:error, atom()}
def start_link(init_arg) do
GenServer.start_link(__MODULE__, init_arg)
end
@spec report_state(pid()) :: State.t()
def report_state(machine) do
GenServer.call(machine, :report_state)
end
@spec queue_new_number(pid()) :: {:ok, integer()} | {:error, atom()}
def queue_new_number(machine) do
GenServer.call(machine, :queue_new_number)
end
@spec serve_next_queued_number(pid(), integer() | nil) :: {:ok, integer()} | {:error, atom()}
def serve_next_queued_number(machine, priority_number \\ nil) do
GenServer.call(machine, {:serve_next_queued_number, priority_number})
end
@spec reset_state(pid()) :: :ok
def reset_state(machine) do
GenServer.cast(machine, :reset_state)
end
# Server callbacks
@impl GenServer
def init(options) do
with min <- Keyword.get(options, :min_number),
max <- Keyword.get(options, :max_number),
shutdown <- Keyword.get(options, :auto_shutdown_timeout, :infinity),
{:ok, state} <- State.new(min, max, shutdown) do
{:ok, state, shutdown}
else
_ -> {:stop, :invalid_configuration}
end
end
@impl GenServer
def handle_call(:report_state, _from, state) do
{:reply, state, state, state.auto_shutdown_timeout}
end
@impl GenServer
def handle_call(:queue_new_number, _from, state) do
case State.queue_new_number(state) do
{:ok, new_number, new_state} -> {:reply, {:ok, new_number}, new_state, new_state.auto_shutdown_timeout}
error -> {:reply, error, state, state.auto_shutdown_timeout}
end
end
@impl GenServer
def handle_call({:serve_next_queued_number, priority_number}, _from, state) do
case State.serve_next_queued_number(state, priority_number) do
{:ok, new_number, new_state} -> {:reply, {:ok, new_number}, new_state, new_state.auto_shutdown_timeout}
error -> {:reply, error, state, state.auto_shutdown_timeout}
end
end
@impl GenServer
def handle_call(_request, _from, state) do
{:reply, :unknown_command, state, state.auto_shutdown_timeout}
end
@impl GenServer
def handle_cast(
:reset_state,
%State{min_number: min_number, max_number: max_number, auto_shutdown_timeout: auto_shutdown_timeout}
) do
{:ok, new_state} = State.new(min_number, max_number, auto_shutdown_timeout)
{:noreply, new_state, new_state.auto_shutdown_timeout}
end
@impl GenServer
def handle_cast(_request, state) do
{:noreply, state, state.auto_shutdown_timeout}
end
@impl GenServer
def handle_info(:timeout, state) do
{:stop, :normal, state}
end
@impl GenServer
def handle_info(_request, state) do
{:noreply, state, state.auto_shutdown_timeout}
end
end

View File

@ -0,0 +1,42 @@
defmodule TakeANumberDeluxe.Queue do
# You don't need to read this module to solve this exercise.
# We would have used Erlang's queue module instead
# (https://www.erlang.org/doc/man/queue.html),
# but it lacks a `delete` function before OTP 24,
# and we want this exercise to work on older versions too.
defstruct in: [], out: []
@type t :: %__MODULE__{}
@spec new() :: t()
def new(), do: %__MODULE__{}
@spec push(t(), any()) :: t()
def push(%__MODULE__{in: in_q} = q, a), do: %__MODULE__{q | in: [a | in_q]}
@spec out(t()) :: {{:value, any()}, t()} | {:empty, t()}
def out(%__MODULE__{in: [], out: []} = q), do: {:empty, q}
def out(%__MODULE__{out: [head | tail]} = q), do: {{:value, head}, %__MODULE__{q | out: tail}}
def out(%__MODULE__{in: in_q}), do: out(%__MODULE__{out: Enum.reverse(in_q)})
@spec empty?(t()) :: boolean()
def empty?(%__MODULE__{in: [], out: []}), do: true
def empty?(%__MODULE__{}), do: false
@spec member?(t(), any()) :: boolean()
def member?(%__MODULE__{in: in_q, out: out}, a), do: a in in_q or a in out
@spec delete(t(), any()) :: t()
def delete(%__MODULE__{in: in_q, out: out}, a) do
out = out ++ Enum.reverse(in_q)
out = List.delete(out, a)
%__MODULE__{out: out}
end
@spec from_list([any()]) :: t()
def from_list(list), do: %__MODULE__{out: list}
@spec to_list(t()) :: [any()]
def to_list(%__MODULE__{in: in_q, out: out}), do: out ++ Enum.reverse(in_q)
end

View File

@ -0,0 +1,87 @@
defmodule TakeANumberDeluxe.State do
defstruct min_number: 1, max_number: 999, queue: nil, auto_shutdown_timeout: :infinity
@type t :: %__MODULE__{}
alias TakeANumberDeluxe.Queue
@spec new(integer, integer, timeout) :: {:ok, TakeANumberDeluxe.State.t()} | {:error, atom()}
def new(min_number, max_number, auto_shutdown_timeout \\ :infinity) do
if min_and_max_numbers_valid?(min_number, max_number) and
timeout_valid?(auto_shutdown_timeout) do
{:ok,
%__MODULE__{
min_number: min_number,
max_number: max_number,
queue: Queue.new(),
auto_shutdown_timeout: auto_shutdown_timeout
}}
else
{:error, :invalid_configuration}
end
end
@spec queue_new_number(TakeANumberDeluxe.State.t()) ::
{:ok, integer(), TakeANumberDeluxe.State.t()} | {:error, atom()}
def queue_new_number(%__MODULE__{} = state) do
case find_next_available_number(state) do
{:ok, next_available_number} ->
{:ok, next_available_number,
%{state | queue: Queue.push(state.queue, next_available_number)}}
{:error, error} ->
{:error, error}
end
end
@spec serve_next_queued_number(TakeANumberDeluxe.State.t(), integer() | nil) ::
{:ok, integer(), TakeANumberDeluxe.State.t()} | {:error, atom()}
def serve_next_queued_number(%__MODULE__{} = state, priority_number) do
cond do
Queue.empty?(state.queue) ->
{:error, :empty_queue}
is_nil(priority_number) ->
{{:value, next_number}, new_queue} = Queue.out(state.queue)
{:ok, next_number, %{state | queue: new_queue}}
Queue.member?(state.queue, priority_number) ->
{:ok, priority_number, %{state | queue: Queue.delete(state.queue, priority_number)}}
true ->
{:error, :priority_number_not_found}
end
end
defp min_and_max_numbers_valid?(min_number, max_number) do
is_integer(min_number) and is_integer(max_number) and min_number < max_number
end
defp timeout_valid?(timeout) do
timeout == :infinity || (is_integer(timeout) && timeout >= 0)
end
defp find_next_available_number(state) do
all_numbers_in_use = Queue.to_list(state.queue)
all_numbers = Enum.to_list(state.min_number..state.max_number)
case all_numbers_in_use do
[] ->
{:ok, state.min_number}
list when length(list) == length(all_numbers) ->
{:error, :all_possible_numbers_are_in_use}
_ ->
current_highest_number = Enum.max(all_numbers_in_use)
next_available_number =
if current_highest_number < state.max_number do
current_highest_number + 1
else
Enum.min(all_numbers -- all_numbers_in_use)
end
{:ok, next_available_number}
end
end
end

View File

@ -0,0 +1,28 @@
defmodule TakeANumber.MixProject do
use Mix.Project
def project do
[
app: :take_a_number_deluxe,
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

View File

@ -0,0 +1,400 @@
defmodule TakeANumberDeluxeTest do
use ExUnit.Case
alias TakeANumberDeluxe.Queue
describe "start_link/1" do
@tag task_id: 1
test "starts a new process" do
{:ok, pid} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 99)
assert is_pid(pid)
assert pid != self()
assert Process.alive?(pid)
end
@tag task_id: 1
test "the process doesn't have a name" do
{:ok, pid} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 99)
assert Process.info(pid, :registered_name) == {:registered_name, []}
end
@tag task_id: 1
test "min and max numbers get validated" do
Process.flag(:trap_exit, true)
assert TakeANumberDeluxe.start_link(min_number: 999, max_number: 99) ==
{:error, :invalid_configuration}
assert TakeANumberDeluxe.start_link(min_number: :not_a_number, max_number: 99) ==
{:error, :invalid_configuration}
assert TakeANumberDeluxe.start_link(min_number: 1, max_number: "not a number") ==
{:error, :invalid_configuration}
Process.flag(:trap_exit, false)
end
@tag task_id: 1
test "mix and max numbers can be passed in any order" do
{:ok, pid} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 99)
assert is_pid(pid)
{:ok, pid} = TakeANumberDeluxe.start_link(max_number: 333, min_number: 100)
assert is_pid(pid)
end
@tag task_id: 1
test "the init/1 GenServer callback is defined" do
Code.ensure_loaded(TakeANumberDeluxe)
assert function_exported?(TakeANumberDeluxe, :init, 1)
end
end
describe "report_state/1" do
@tag task_id: 2
test "returns the state of the current process" do
{:ok, pid} = TakeANumberDeluxe.start_link(min_number: 4, max_number: 55)
{:ok, expected_state} = TakeANumberDeluxe.State.new(4, 55)
assert TakeANumberDeluxe.report_state(pid) == expected_state
end
@tag task_id: 2
test "different processes have different states" do
{:ok, pid1} = TakeANumberDeluxe.start_link(min_number: 4, max_number: 55)
{:ok, pid2} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 19)
assert TakeANumberDeluxe.report_state(pid1) != TakeANumberDeluxe.report_state(pid2)
end
@tag task_id: 2
test "the handle_call/3 GenServer callback is defined" do
Code.ensure_loaded(TakeANumberDeluxe)
assert function_exported?(TakeANumberDeluxe, :handle_call, 3)
end
end
describe "queue_new_number/1" do
@tag task_id: 3
test "returns the newly queued number" do
{:ok, pid} = TakeANumberDeluxe.start_link(min_number: 4, max_number: 55)
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 4}
end
@tag task_id: 3
test "can queue multiple numbers" do
{:ok, pid} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 99)
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 1}
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 2}
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 3}
end
@tag task_id: 3
test "returns an error when there are no more available numbers" do
{:ok, pid} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 3)
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 1}
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 2}
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 3}
assert TakeANumberDeluxe.queue_new_number(pid) == {:error, :all_possible_numbers_are_in_use}
end
@tag task_id: 3
test "updates the state accordingly" do
{:ok, pid} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 99)
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 1}
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 2}
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 3}
assert TakeANumberDeluxe.report_state(pid) ==
%TakeANumberDeluxe.State{
min_number: 1,
max_number: 99,
queue: Queue.new() |> Queue.push(1) |> Queue.push(2) |> Queue.push(3)
}
end
end
describe "serve_next_queued_number/1" do
@tag task_id: 4
test "returns the new number being served" do
{:ok, pid} = TakeANumberDeluxe.start_link(min_number: 100, max_number: 999)
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 100}
assert TakeANumberDeluxe.serve_next_queued_number(pid) == {:ok, 100}
end
@tag task_id: 4
test "can serve multiple numbers" do
{:ok, pid} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 99)
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 1}
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 2}
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 3}
assert TakeANumberDeluxe.serve_next_queued_number(pid) == {:ok, 1}
assert TakeANumberDeluxe.serve_next_queued_number(pid) == {:ok, 2}
assert TakeANumberDeluxe.serve_next_queued_number(pid) == {:ok, 3}
end
@tag task_id: 4
test "returns an error when there are no more queued" do
{:ok, pid} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 3)
assert TakeANumberDeluxe.serve_next_queued_number(pid) ==
{:error, :empty_queue}
end
@tag task_id: 4
test "accepts a priority number to skip the queue" do
{:ok, pid} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 99)
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 1}
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 2}
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 3}
assert TakeANumberDeluxe.serve_next_queued_number(pid, 3) == {:ok, 3}
end
@tag task_id: 4
test "returns an error when the priority number is not in the queue" do
{:ok, pid} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 3)
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 1}
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 2}
assert TakeANumberDeluxe.serve_next_queued_number(pid, 7) ==
{:error, :priority_number_not_found}
end
@tag task_id: 4
test "updates the state accordingly" do
{:ok, pid} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 99)
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 1}
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 2}
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 3}
assert TakeANumberDeluxe.serve_next_queued_number(pid) == {:ok, 1}
assert TakeANumberDeluxe.serve_next_queued_number(pid) == {:ok, 2}
assert TakeANumberDeluxe.report_state(pid) ==
%TakeANumberDeluxe.State{
min_number: 1,
max_number: 99,
queue: Queue.from_list([3])
}
end
end
describe "reset_state/1" do
@tag task_id: 5
test "returns :ok" do
{:ok, pid} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 99)
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 1}
assert TakeANumberDeluxe.reset_state(pid) == :ok
end
@tag task_id: 5
test "updates the state accordingly" do
{:ok, pid} = TakeANumberDeluxe.start_link(min_number: 7, max_number: 77)
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 7}
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 8}
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 9}
assert TakeANumberDeluxe.serve_next_queued_number(pid) == {:ok, 7}
assert TakeANumberDeluxe.serve_next_queued_number(pid) == {:ok, 8}
assert TakeANumberDeluxe.reset_state(pid) == :ok
assert TakeANumberDeluxe.report_state(pid) ==
%TakeANumberDeluxe.State{
min_number: 7,
max_number: 77,
queue: Queue.new()
}
end
@tag task_id: 5
test "the handle_cast/2 GenServer callback is defined" do
Code.ensure_loaded(TakeANumberDeluxe)
assert function_exported?(TakeANumberDeluxe, :handle_cast, 2)
end
end
describe "auto shutdown handling unexpected messages" do
@tag task_id: 6
test "auto shutdown works after initializing" do
timeout = 50
{:ok, pid} =
TakeANumberDeluxe.start_link(
min_number: 1,
max_number: 99,
auto_shutdown_timeout: timeout
)
assert Process.alive?(pid)
:timer.sleep(timeout * 2)
refute Process.alive?(pid)
end
@tag task_id: 6
test "auto shutdown works after reporting state" do
timeout = 50
{:ok, pid} =
TakeANumberDeluxe.start_link(
min_number: 1,
max_number: 99,
auto_shutdown_timeout: timeout
)
assert Process.alive?(pid)
assert TakeANumberDeluxe.report_state(pid)
assert Process.alive?(pid)
:timer.sleep(timeout * 2)
refute Process.alive?(pid)
end
@tag task_id: 6
test "auto shutdown works after taking a new number" do
timeout = 50
{:ok, pid} =
TakeANumberDeluxe.start_link(
min_number: 1,
max_number: 99,
auto_shutdown_timeout: timeout
)
assert Process.alive?(pid)
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 1}
assert Process.alive?(pid)
:timer.sleep(timeout * 2)
refute Process.alive?(pid)
end
@tag task_id: 6
test "auto shutdown works after an error when taking a new number" do
timeout = 50
{:ok, pid} =
TakeANumberDeluxe.start_link(
min_number: 1,
max_number: 2,
auto_shutdown_timeout: timeout
)
assert Process.alive?(pid)
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 1}
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 2}
assert TakeANumberDeluxe.queue_new_number(pid) == {:error, :all_possible_numbers_are_in_use}
assert Process.alive?(pid)
:timer.sleep(timeout * 2)
refute Process.alive?(pid)
end
@tag task_id: 6
test "auto shutdown works after serving the next queued number" do
timeout = 50
{:ok, pid} =
TakeANumberDeluxe.start_link(
min_number: 1,
max_number: 99,
auto_shutdown_timeout: timeout
)
assert Process.alive?(pid)
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 1}
assert TakeANumberDeluxe.serve_next_queued_number(pid) == {:ok, 1}
assert Process.alive?(pid)
:timer.sleep(timeout * 2)
refute Process.alive?(pid)
end
@tag task_id: 6
test "auto shutdown works after an error when serving the next queued number" do
timeout = 50
{:ok, pid} =
TakeANumberDeluxe.start_link(
min_number: 1,
max_number: 99,
auto_shutdown_timeout: timeout
)
assert Process.alive?(pid)
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 1}
assert TakeANumberDeluxe.serve_next_queued_number(pid) == {:ok, 1}
assert TakeANumberDeluxe.serve_next_queued_number(pid) == {:error, :empty_queue}
assert Process.alive?(pid)
:timer.sleep(timeout * 2)
refute Process.alive?(pid)
end
@tag task_id: 6
test "resetting state preserves the auto shutdown timeout" do
timeout = 50
{:ok, pid} =
TakeANumberDeluxe.start_link(
min_number: 1,
max_number: 99,
auto_shutdown_timeout: timeout
)
{:ok, expected_state} = TakeANumberDeluxe.State.new(1, 99, timeout)
assert TakeANumberDeluxe.report_state(pid) == expected_state
assert TakeANumberDeluxe.reset_state(pid)
assert TakeANumberDeluxe.report_state(pid) == expected_state
end
@tag task_id: 6
test "auto shutdown works after resetting state" do
timeout = 50
{:ok, pid} =
TakeANumberDeluxe.start_link(
min_number: 1,
max_number: 99,
auto_shutdown_timeout: timeout
)
assert Process.alive?(pid)
assert TakeANumberDeluxe.reset_state(pid) == :ok
assert Process.alive?(pid)
:timer.sleep(timeout * 2)
refute Process.alive?(pid)
end
@tag task_id: 6
test "unexpected messages do not affect the state" do
{:ok, pid} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 99)
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 1}
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 2}
assert TakeANumberDeluxe.queue_new_number(pid) == {:ok, 3}
assert TakeANumberDeluxe.serve_next_queued_number(pid) == {:ok, 1}
old_state = TakeANumberDeluxe.report_state(pid)
send(pid, {:hello, "there"})
assert TakeANumberDeluxe.report_state(pid) == old_state
end
@tag task_id: 6
test "auto shutdown works after handling unexpected messages" do
timeout = 50
{:ok, pid} =
TakeANumberDeluxe.start_link(
min_number: 1,
max_number: 99,
auto_shutdown_timeout: timeout
)
assert Process.alive?(pid)
send(pid, {:hello, "there"})
assert Process.alive?(pid)
:timer.sleep(timeout * 2)
refute Process.alive?(pid)
end
@tag task_id: 6
test "the handle_info/2 GenServer callback is defined" do
Code.ensure_loaded(TakeANumberDeluxe)
assert function_exported?(TakeANumberDeluxe, :handle_info, 2)
end
end
end

View File

@ -0,0 +1,2 @@
ExUnit.start()
ExUnit.configure(exclude: :pending, trace: true, seed: 0)