Yesterday I started to implement the logic for a simple calculator web app that I'm recreating.
One of my goals in building it is to use the features of Elm to cleanly separate the application logic from the UI.
I believe I achieved what I was going for and that's why I'm so excited to share my progress with you.
So what did I do?
I made a data structure in a Calculator
module with the following public API:
module Calculator exposing
( Calculator, Key(..), Operator(..)
, new, process
, Display
, toDisplay
)
N.B. The implementation details aren't important right now.
Calculator
is an opaque type.
The new
function creates a Calculator
. process
takes a Key
and a Calculator
and returns an updated Calculator
. And, toDisplay
allows you to observe the results produced by a given Calculator
.
For e.g.
Calculator.new
|> Calculator.process (Digit 1)
|> Calculator.process (Digit 2)
|> Calculator.process (Operator Plus)
|> Calculator.process (Digit 3)
|> Calculator.toDisplay
{ expr = "12+3", output = "3" }
I experienced two major benefits. The first was that I was able to unit test (yes, testing is important even in a typed functional language) my logic and gain confidence in my implementation.
Here are the tests:
module Test.Calculator exposing (suite)
import Expect
import Test exposing (..)
import Calculator exposing (Key(..), Operator(..))
suite : Test
suite =
describe "Calculator"
[ processSuite ]
processSuite : Test
processSuite =
describe "process"
[ describe "when nothing has been entered" <|
let
calculator =
Calculator.new
in
[ test "pressing AC" <|
\_ ->
calculator
|> Calculator.process AC
|> Calculator.toDisplay
|> Expect.equal { expr = "", output = "0" }
, test "pressing a digit" <|
\_ ->
calculator
|> Calculator.process (Digit 1)
|> Calculator.toDisplay
|> Expect.equal { expr = "1", output = "1" }
, test "pressing an operator" <|
\_ ->
calculator
|> Calculator.process (Operator Plus)
|> Calculator.toDisplay
|> Expect.equal { expr = "", output = "0" }
, test "pressing =" <|
\_ ->
calculator
|> Calculator.process Equal
|> Calculator.toDisplay
|> Expect.equal { expr = "", output = "0" }
]
, describe "when a number has been entered" <|
let
calculator =
Calculator.new
|> Calculator.process (Digit 1)
|> Calculator.process (Digit 2)
in
[ test "pressing AC" <|
\_ ->
calculator
|> Calculator.process AC
|> Calculator.toDisplay
|> Expect.equal { expr = "", output = "0" }
, test "pressing a digit" <|
\_ ->
calculator
|> Calculator.process (Digit 3)
|> Calculator.toDisplay
|> Expect.equal { expr = "123", output = "123" }
, test "pressing an operator" <|
\_ ->
calculator
|> Calculator.process (Operator Plus)
|> Calculator.toDisplay
|> Expect.equal { expr = "12+", output = "+" }
, test "pressing =" <|
\_ ->
calculator
|> Calculator.process Equal
|> Calculator.toDisplay
|> Expect.equal { expr = "12=12", output = "12" }
]
, describe "when a number and operator has been entered" <|
let
calculator =
Calculator.new
|> Calculator.process (Digit 1)
|> Calculator.process (Digit 2)
|> Calculator.process (Operator Plus)
in
[ test "pressing AC" <|
\_ ->
calculator
|> Calculator.process AC
|> Calculator.toDisplay
|> Expect.equal { expr = "", output = "0" }
, test "pressing a digit" <|
\_ ->
calculator
|> Calculator.process (Digit 3)
|> Calculator.toDisplay
|> Expect.equal { expr = "12+3", output = "3" }
, test "pressing an operator" <|
\_ ->
calculator
|> Calculator.process (Operator Minus)
|> Calculator.toDisplay
|> Expect.equal { expr = "12-", output = "-" }
, test "pressing =" <|
\_ ->
calculator
|> Calculator.process Equal
|> Calculator.toDisplay
|> Expect.equal { expr = "12=12", output = "12" }
]
, describe "when a complete expression has been entered" <|
let
calculator =
Calculator.new
|> Calculator.process (Digit 1)
|> Calculator.process (Digit 2)
|> Calculator.process (Operator Plus)
|> Calculator.process (Digit 3)
|> Calculator.process (Operator Minus)
|> Calculator.process (Digit 4)
in
[ test "pressing AC" <|
\_ ->
calculator
|> Calculator.process AC
|> Calculator.toDisplay
|> Expect.equal { expr = "", output = "0" }
, test "pressing a digit" <|
\_ ->
calculator
|> Calculator.process (Digit 5)
|> Calculator.toDisplay
|> Expect.equal { expr = "12+3-45", output = "45" }
, test "pressing an operator" <|
\_ ->
calculator
|> Calculator.process (Operator Plus)
|> Calculator.toDisplay
|> Expect.equal { expr = "12+3-4+", output = "+" }
, test "pressing =" <|
\_ ->
calculator
|> Calculator.process Equal
|> Calculator.toDisplay
|> Expect.equal { expr = "12+3-4=11", output = "11" }
]
, describe "when an answer is given" <|
let
calculator =
Calculator.new
|> Calculator.process (Digit 1)
|> Calculator.process (Digit 2)
|> Calculator.process (Operator Plus)
|> Calculator.process (Digit 3)
|> Calculator.process (Operator Minus)
|> Calculator.process (Digit 4)
|> Calculator.process Equal
in
[ test "pressing AC" <|
\_ ->
calculator
|> Calculator.process AC
|> Calculator.toDisplay
|> Expect.equal { expr = "", output = "0" }
, test "pressing a digit" <|
\_ ->
calculator
|> Calculator.process (Digit 9)
|> Calculator.toDisplay
|> Expect.equal { expr = "9", output = "9" }
, test "pressing an operator" <|
\_ ->
calculator
|> Calculator.process (Operator Plus)
|> Calculator.toDisplay
|> Expect.equal { expr = "11+", output = "+" }
, test "pressing =" <|
\_ ->
calculator
|> Calculator.process Equal
|> Calculator.toDisplay
|> Expect.equal { expr = "12+3-4=11", output = "11" }
]
]
The second benefit was how trivial it became to implement the UI. I see many web apps that write extensive integration tests for UI code but that's because they couple the UI with the application logic. With proper separation of concerns you'd end up with more unit tests and much less integration tests (if any).
Here's the code that makes the UI work:
-- MODEL
type alias Model =
{ calculator : Calculator
}
init : Model
init =
{ calculator = Calculator.new
}
-- UPDATE
type Msg
= Clicked Key
update : Msg -> Model -> Model
update msg model =
case msg of
Clicked key ->
{ model | calculator = Calculator.process key model.calculator }
-- VIEW
viewCalculator : Calculator -> Html Msg
viewCalculator calculator =
let
display =
Calculator.toDisplay calculator
in
div [ class "calculator" ]
[ div [ class "calculator__expr" ]
[ if String.isEmpty display.expr then
text (String.fromChar nonBreakingSpace)
else
text display.expr
]
, div [ class "calculator__output" ] [ text display.output ]
, div [ class "calculator__buttons" ]
[ button [ class "r0 c0 colspan2 bg-red", onClick (Clicked AC) ]
[ text "AC" ]
, button [ class "r0 c2", disabled True ]
[ text "÷" ]
, button [ class "r0 c3", onClick (Clicked (Operator Times)) ]
[ text "×" ]
, button [ class "r1 c0", onClick (Clicked (Digit 7)) ]
[ text "7" ]
, button [ class "r1 c1", onClick (Clicked (Digit 8)) ]
[ text "8" ]
, button [ class "r1 c2", onClick (Clicked (Digit 9)) ]
[ text "9" ]
, button [ class "r1 c3", onClick (Clicked (Operator Minus)) ]
[ text "-" ]
, button [ class "r2 c0", onClick (Clicked (Digit 4)) ]
[ text "4" ]
, button [ class "r2 c1", onClick (Clicked (Digit 5)) ]
[ text "5" ]
, button [ class "r2 c2", onClick (Clicked (Digit 6)) ]
[ text "6" ]
, button [ class "r2 c3", onClick (Clicked (Operator Plus)) ]
[ text "+" ]
, button [ class "r3 c0", onClick (Clicked (Digit 1)) ]
[ text "1" ]
, button [ class "r3 c1", onClick (Clicked (Digit 2)) ]
[ text "2" ]
, button [ class "r3 c2", onClick (Clicked (Digit 3)) ]
[ text "3" ]
, button [ class "r3 c3 rowspan2 bg-blue", onClick (Clicked Equal) ]
[ text "=" ]
, button [ class "r4 c0 colspan2", onClick (Clicked (Digit 0)) ]
[ text "0" ]
, button [ class "r4 c2", disabled True ]
[ text "." ]
]
]
Key takeaway: Let the UI drive a data structure and unit test the living daylights out of that data structure.
What do you think about this approach to implementing more complicated UIs?
Top comments (0)