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>
"""
endIn 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
abacusto your list of dependencies in mix.exs:
def deps do
[
...
{:abacus, "~> 2.1.0"},
...
]
end- Include
abacusin the extra applications
def application do
[
mod: {PhxCalculator.Application, []},
extra_applications: [:logger, :runtime_tools, :abacus] # here
]
endWe 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}
endOk. So in our assigns
we see we have the keys calc, mode and history.
calcwill be used to store the calculation string which is auto-rendered thanks to LiveViewmodeis 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 shortlyhistorywill 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
endOk, 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
endVery 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}
endThanks 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
endMuch 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 theoperatorto thecalcstring 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 thenumbersevent)
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}
endSimples!
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
endOnce 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+23-2*15 / (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
endOf 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.evalis successful and extracts the result which we can pass to our socket.{:error, err}: is returned when.evalis 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
endThe 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>)
endSo 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
endLet's break it down:
-
We pass in three parameters
sequence: is a list of key-value maps containing the clickeventand 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 theequalsevent
-
We loop through the
sequencelist withEnum.each()- We pass
(sequence, fn map ->toEnum.eachwhich allows us to run a function on each entry (map) ofsequencelist - Using pattern matching
we destruct the map into its components
eventandvalue - Then call
render_click()with that data
- We pass
-
If
equals?istruewe also render theequalsevent 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>)
endWe 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
endThe 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>)
endHere 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-"
endAs 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"
endSo 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+"
endWe 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