This Phoenix LiveView project is being created with the aim of putting into practice the theory and knowledge gained from the many open source tutorials provided by the wonderful dwyl.
This readme will include a breakdown for anyone else starting their functional programming / elixir journey in the hopes that this will be another useful resource on that quest.
If you are looking for a complete beginner look into Phoenix and Elixir, I'd strongly suggest reviewing the basics with dwyl-learn-elixir and dwyl-learn-phoenix first, before coming back and seeing these technologies in action!
We keep the HEEx and the Tailwind (besides the button styling, see next) in
the auto-generated lib\phx_calculator_web\components\core_components.ex
file.
This keeps our LiveView file lib\phx_calculator_web\live\calculator_live.ex
extremely slim by having a tiny render/1
function:
def render(assigns) do
~H"""
<.calculator><%= @calc %></.calculator>
"""
end
In assets\css\app.css
we keep the styling for the calculator buttons using
the
@layer
directive
like so:
@layer components {
.button-grey-blue {
@apply min-h-[4rem] rounded-lg bg-gray-700 font-mono text-3xl hover:bg-gray-600 text-blue-400
}
.button-grey-purple {
@apply min-h-[4rem] rounded-lg bg-gray-700 font-mono text-3xl hover:bg-gray-600 text-purple-800
}
}
This prevents the calculator component from having bloated classes and greatly reduces repeated code:
<button class="button-grey-blue" phx-click="clear">C</button>
<%!-- Row 2 --%>
<button class="button-grey-purple" phx-click="number"
phx-value-number="1">1</button>
<button class="button-grey-purple" phx-click="number"
phx-value-number="2">2</button>
The calculation logic implementation was made incredibly simple thanks to the elixir package Abacus.
Utilizing the Abacus package kept our calculation logic extremely easy and
highly effective. It provides the Abacus.eval()
function which converts
Strings to a mathematical equation and calculates the result. It also
makes error handling simple as the returned tuple can be pattern matched to
either extract the result or handle the error. More on that later.
The installation instructions on Abacus were out of date, the corrected instructions are:
- Add
abacus
to your list of dependencies in mix.exs:
def deps do
[
...
{:abacus, "~> 2.1.0"},
...
]
end
- Include
abacus
in the extra applications
def application do
[
mod: {PhxCalculator.Application, []},
extra_applications: [:logger, :runtime_tools, :abacus] # here
]
end
We can now call Abacus.eval()
in our project!
To handle the event of clicking a button, I need my project to know that and event has been triggered and the value of button that has been pressed.
In Phoenix this is very simple, we can use
phx-click
and
phx-value
The html for our calculated is in the
lib\phx_calculator_web\components\core_components.ex
file to make our LiveView file cleaner, and in it we see phx-click
on every button which triggers the corresponding event, and if a value is
needed then the corresponding phx-value
:
<button class="button-grey-purple" phx-click="number"
phx-value-number="1">1</button>
Note: not every button needs to pass a value to the handler function,
for example we can handle a "clear"
or "backspace"
event without any
data being passed
<button class="button-grey-blue" phx-click="clear">C</button>
Before we dive into talking about the event handling it is worth briefly
looking at our
mount
/3
as it details the set-up of our
socket
struct which is used in determining which inputs are permitted.
def mount(_params, _session, socket) do
socket = assign(socket, calc: "", mode: "", history: "")
{:ok, socket}
end
Ok. So in our assigns
we see we have the keys calc
, mode
and history
.
calc
will be used to store the calculation string which is auto-rendered thanks to LiveViewmode
is very useful as it allows the system to 'know' what behavior the calculator is performing. This can be utilized to prevent illegal expressions as we'll see shortlyhistory
will be used to store the calculation history of the session and render it to the history tab
All calculations start by clicking on a number (yes, or perhaps bracket..) so let's have a look at that first.
When a button is pressed with the phx-click="number"
def handle_event("number", %{"number" => number}, socket) do
case socket.assigns.mode do
"display" ->
calc = number
socket = assign(socket, calc: calc, mode: "number")
{:noreply, socket}
_ ->
calc = socket.assigns.calc <> number
socket = assign(socket, calc: calc, mode: "number")
{:noreply, socket}
end
end
Ok, first thing to notice is that we are saving the phx-value in the number
variable with %{"number" => number}
.
By implementing a case
we then either concatenate the new number to the existing calc
string saved
in the socket calc = socket.assigns.calc <> number
and then update the socket,
or if the calculator is in display mode (after clicking the equals button)
we start a new string with calc = number
and update the socket accordingly.
We'll examine the backspace event next as the helper function is also used for the
operator` event.
First let's examine the handle_event/3
:
def handle_event("backspace", _unsigned_params, socket) do
case socket.assigns.mode do
"display" ->
{:noreply, socket}
_ ->
backspace(socket)
end
end
Very simple logic thanks to our helper function. If the calculator is in display mode we tell our function to do nothing, otherwise we call the helper function to remove the last character of the calc string.
The helper function works as follows:
defp backspace(socket, operator \\ "") do
calc = String.slice(socket.assigns.calc, 0..-2//1) <> operator
socket = assign(socket, calc: calc)
{:noreply, socket}
end
Thanks to String.slice()
we can remove the last character. We specify a slice from the range of
index 0
to -2//1
. This just means the second to last index (-2
),
but we have to specify that the range is increasing with //1
for
.slice()
to be happy.
The reason we're passing an operator with a default of an empty string is so
when we call this helper function with a valid operator, we just replace
the last element of the calc string with the passed in operator
using
concatenation. We'll see why that's handy next.
Examining our handler function we see:
def handle_event("operator", %{"operator" => operator}, socket) do
case socket.assigns.mode do
"number" ->
calc = socket.assigns.calc <> operator
socket = assign(socket, calc: calc, mode: "operator")
{:noreply, socket}
"operator" ->
backspace(socket, operator)
_ ->
{:noreply, socket}
end
end
Much like the number
event we're saving the passed operator
variable
and using that to build the calculation string. But notice we are also
making use of a case which again is utilizing the socket.assigns.mode
to determine what the function should do.
If we're in mode..
-
number
, meaning the last input was a number, we simply concatenate theoperator
to thecalc
string and update the socket, like we've just seen before.- i.e if
calc = "1"
andoperator="+"
the updated socket containscalc = "1+"
- i.e if
-
operator
, meaning the last input to the calc string was another operator, we remove the previous operator using thebackspace()
helper function and then concatenate the new operator as we do not want invalid inputs.- i.e if
calc = "1+"
andoperator="-"
the updated socket containscalc = "1-"
- i.e if
-
_
, meaning if the previous input was neither a number or an operator then we tell our function to do nothing as we do not want to add an operator to the calc string unless it follows a number (brackets are handled by thenumbers
event)
In the operator
case we saw the backspace helper function being used again, this time we passed in our operator. Like we saw before, that just means we concatenate on the new operator once we've used
String.slice()`.
This ones super simple. All we need to do is set the calc string to be an empty string.
def handle_event("clear", _unsigned_params, socket) do
socket = assign(socket, calc: "")
{:noreply, socket}
end
Simples!
The actual handler function is actually very basic, thanks to another
helper function, which in turn is simple thanks to the aforementioned
Abacus.eval
.
def handle_event("equals", _unsigned_params, socket) do
case socket.assigns.mode do
"number" ->
calculate(socket)
_ ->
{:noreply, socket}
end
end
Once again we utilize a case
, which lets us only call the calculate()
function if the last input was a number which is always the case for valid inputs.
Remember that brackets are classed as a number
For example, the strings:
1+2
3-2*1
5 / (3 - 2)
would all call the calculate()
function and these strings:
1+
12/3-
would do nothing.
Let's now dive into the function that's doing all the work:
defp calculate(socket) do
case Abacus.eval(socket.assigns.calc) do
{:ok, result} ->
socket = assign(socket, calc: result, mode: "display")
{:noreply, socket}
{:error, err} ->
socket = assign(socket, calc: "ERROR", mode: "display")
{:noreply, socket}
end
end
Of course, another case.
This time, we are
pattern matching
the result of Abacus.eval(socket.assigns.socket)
with tuples:
{:ok, result}
: is returned when the.eval
is successful and extracts the result which we can pass to our socket.{:error, err}
: is returned when.eval
is unsuccessful, in cases like dividing by 0 or improper use of brackets.
In both cases we set mode: "display"
which affects certain functions
as we have seen, and we either set the calc string to the result or the
"ERROR" string.
In this section we will talk about the tests we used to obtain 100% test coverage. Instead of examining each test with a code snippet like we did with our functions, we'll examine the general test code structure, the helper function used to reduce code repetition, and then speak about the test cases in a more general manner.
The testing in this project is organized into describe blocks containing the relevant test suite, which helps separate the logic and make it more readable and easier for the developer to figure out which test is failing (along with test names).
describe "test suite name" do
# Your test suite
end
The test themselves are named, and in this project we pass a conn
instance
which we use with
live/2
to spawn a a connected LiveView process enabling us to obtain a LiveView to
test.
Note: We're using Phoenix's ConnCase
We test the view
using
render_click/3
which sends a click event to the view with value
and returns the rendered result, and then we use assert
to determine
whether the correct value has been calculated.
test "clear", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_click(view, "number", number: "1")
render_click(view, "clear")
# screen should be empty
assert render(view) =~ ~s(<div id="screen" class="mr-4"></div>)
end
So in this example we've simulated clicking the number 1
and then the clear
button.
We then check to see if our "screen" is empty on the calculator using
assert and accessing the screen via its id
.
Since this is a calculator, we'll be "pressing" a lot of buttons.
Obviously we don't want to be typing endless render_click()
's,
which is where the following helper function comes in:
defp apply_sequence(sequence, view, equals?) do
Enum.each(sequence, fn map ->
%{event: event, value: value} = map
render_click(view, event, %{event => value})
end)
if(equals?) do
render_click(view, "equals")
end
end
Let's break it down:
-
We pass in three parameters
sequence
: is a list of key-value maps containing the clickevent
and its corresponding valueview
: is the current LiveView of the process, the same one we create welive(conn, "/")
equals?
: is the boolean that determines whether we want to call theequals
event
-
We loop through the
sequence
list withEnum.each()
- We pass
(sequence, fn map ->
toEnum.each
which allows us to run a function on each entry (map
) ofsequence
list - Using pattern matching
we destruct the map into its components
event
andvalue
- Then call
render_click()
with that data
- We pass
-
If
equals?
istrue
we also render theequals
event after the sequence.
This function allows us to increase readability and reduce repeated code.
For example, look at how it's used an addition test:
# Addition block
test "with identity element", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
apply_sequence([
%{event: "number", value: "1"},
%{event: "operator", value: "+"},
%{event: "number", value: "0"}
], view, true)
assert render(view) =~ ~s(<div id="screen" class="mr-4">1</div>)
end
We can clearly read what the test is doing, and we didn't have to type four render_click() functions. Awesome!
In the addition test suite, and indeed all test suites involving a calculation, I have tried to not only ensure branch coverage but also to test all behaviours of the operator and test correct handling of every button.
To ensure this, each test suite that handles a calculation has a similar format:
- Test the operator with the 0 element
- Test the operator with the identity element
- Test the operator with numbers consisting of every digit, e.g.
1.2345 + 6.7890
Notice that for addition and subtraction the 0 element is the identity element
The subtraction
test suite is slightly different as we also test a
calculation that returns a negative result, as well as a positive one.
# Subtraction block
describe "subtraction" do
test "with identity element", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
apply_sequence([
%{event: "number", value: "1"},
%{event: "operator", value: "-"},
%{event: "number", value: "0"}
], view, true)
assert render(view) =~ ~s(<div id="screen" class="mr-4">1</div>)
end
test "with positive result", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
apply_sequence([
%{event: "number", value: "1.2345"},
%{event: "operator", value: "-"},
%{event: "number", value: "6.7890"}
], view, true)
assert render(view) =~ "5.5545"
end
test "with negative result", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
apply_sequence([
%{event: "number", value: "1.2345"},
%{event: "operator", value: "-"},
%{event: "number", value: "6.7890"}
], view, true)
assert render(view) =~ "-5.5545"
end
end
The deletion test suite is testing behavior involved in removing
data from the screen, either with the backspace
or clear
operator.
We test two behaviors (branches) of our backspace
logic, what happens after
we click backspace when the calculator is in "display" mode and when it is not
(i.e when it is in "number" or "operator" mode).
Clearly, when not in "display" mode we'd like the the backspace
event to
remove the last digit of whatever is currently on the screen. After using
our apply_sequence()
with numbers 3
, 2
, 1
we can just check that the
1
was removed with:
render_click(view, "backspace")
assert render(view) =~ ~s(<div id="screen" class="mr-4">32</div>)
And then for "display" mode the backspace event shouldn't do anything,
so we just assert that the result of the calculation is still present
after the apply_sequence
followed by the equals
event:
render_click(view, "backspace")
# nothing should happen
assert render(view) =~ "1.0"
To test the clear
event, all we need to do is trigger a number
event
followed by the clear
event and then assert whether the screen is empty:
render_click(view, "number", number: "1")
render_click(view, "clear")
# screen should be empty
assert render(view) =~ ~s(<div id="screen" class="mr-4"></div>)
This test suite is for testing the behavior ensuring legal calculations
are being typed (e.g stopping a calculation like 1+-+=
). We'll quickly
examine each test.
test "number after equals", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
apply_sequence([
%{event: "number", value: "1"},
%{event: "operator", value: "/"},
%{event: "number", value: "1"}
], view, true)
render_click(view, "number", %{number: "2"})
assert render(view) =~ ~s(<div id="screen" class="mr-4">2</div>)
end
Here we are checking that when entering a number after the calculator
is displaying a result a new calculation (calc
string) is started.
So when we use render_click(view, "number", %{number: "2"})
after
calculating the sequence the screen should only contain the 2
.
Next we test the behavior of two operators being clicked in a row:
test "operator after operator", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
apply_sequence([
%{event: "number", value: "1"},
%{event: "operator", value: "+"},
%{event: "operator", value: "-"}
], view, false)
# new operator replaces old operator
assert render(view) =~ "1-"
end
As we have seen, in this design the desired action is to replace the
first operator with the second, so we simply assert that all that is
being displayed is 1-
after the sequence 1
, +
, -
.
The last two tests are examining the behavior of an operator followed
by an equals sign (e.g. 1+=
) and the operator following a result
(e.g. `1+1=+1).
In each case nothing should happen:
test "operator with equals", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
apply_sequence([
%{event: "number", value: "1"},
%{event: "operator", value: "/"},
%{event: "number", value: "1"}
], view, true)
render_click(view, "operator", operator: "+")
# nothing should happen
assert render(view) =~ "1.0"
end
So we assert that the result is still being displayed after inputting an operator, and..
test "equals after operator", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
apply_sequence([
%{event: "number", value: "1"},
%{event: "operator", value: "+"}
], view, true)
# nothing should happen
assert render(view) =~ "1+"
end
We check that the equals
event (which we triggered by passing
true
to the helper function) does nothing.
Lastly we test the bracket
event logic. Namely, we are testing that
when used correctly they work as intended, and when used incorrectly
the screen displays the "ERROR" message:
describe "brackets" do
test "valid brackets", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
apply_sequence([
%{event: "number", value: "6"},
%{event: "operator", value: "/"},
%{event: "number", value: "("},
%{event: "number", value: "3"},
%{event: "operator", value: "-"},
%{event: "number", value: "2"},
%{event: "number", value: ")"}
], view, true)
assert render(view) =~ "6.0"
end
test "invalid brackets", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
apply_sequence([
%{event: "number", value: "("},
%{event: "number", value: "("},
%{event: "number", value: ")"}
], view, true)
assert render(view) =~ "ERROR"
end
end