Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Language Atlas (mini-courses for more languages)  Module 12 – Elm and The Elm Architecture

Project: a bill-splitter app in Elm

Build a bill splitter in Elm: whole paise, tax and tip rounded per item, shares that always add up, tests with elm-test and a minified build.

  • Intermediate
  • 25 minutes
  • Examples run with Python 3.14.8, Node.js 24.21.0, quickjs 0.32.0, Elm 0.19.3, elm-test 0.19.3-0 and UglifyJS 3.19.3
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Specify a bill split in whole paise, with tax and tip rounded half up per item and a fixed rule for odd paise
  • Implement the split as a pure Elm module whose shares always add up to the bill
  • Test the core with elm-test, including a fuzz test, and read a failure with its shrunk counterexample
  • Build a Browser.element form around the core that lists the problems it finds together, not only the first
  • Optimise and minify the app for release, and measure the file at each step

Before you start

What you’ll learn · Before you start

What you will learn

  • Specify a bill split in whole paise, with tax and tip rounded half up per item and a fixed rule for odd paise
  • Implement the split as a pure Elm module whose shares always add up to the bill
  • Test the core with elm-test, including a fuzz test, and read a failure with its shrunk counterexample
  • Build a Browser.element form around the core that lists the problems it finds together, not only the first
  • Optimise and minify the app for release, and measure the file at each step

Before you start

On this page

Three friends share a dinner of 31,625 paise with tax and tip, and two of the items do not split evenly. Round each share on its own and the shares can add up to a paisa more or less than the bill; the splitter in this lesson gives the odd paise out by a written rule, and its tests prove that the shares add up for every bill they try.

You will turn a specification into a pure Elm module, check it with eight elm-test tests, one of them a fuzz test that found a bug’s smallest case in the recorded run, wrap the module in a form that lists its problems together instead of stopping at the first, and ship it optimised and minified, from 135,770 bytes down to 9,282 on the wire.

The specification fixes every rounding before any code

Money code fails in its rounding, so the rules come first, written down so that the tests can hold the code to them:

Rule What the splitter does
Amounts whole paise everywhere; a price typed as 145.50 becomes 14550
Tax and tip each a percentage of the item’s price, rounded half up on its own: (paise * percent + 50) // 100
Line total price + tax + tip of each item; the bill is the sum of its line totals
Splitting a line equal whole parts for the item’s sharers, and the paise left over one each to the sharers in alphabetical order
Shares the sum of each diner’s parts, for every diner in alphabetical order
Limits prices from 0.01 to 100000.00 rupees, tax from 0 to 28 percent, tip from 0 to 25 percent

Two rules deserve a reason. rounding half up: Rounding in which a value exactly halfway between two whole numbers goes to the larger one: 1,252.5 becomes 1,253. For whole numbers a and b, the expression (2a + b) div (2b) rounds a / b this way without any real numbers. More in “Project: a marks report in Object Pascal” with whole numbers only means that a tax of 72.5 paise becomes 73 without a float ever appearing. The price limit keeps every quotient of the core far below 2,147,483,647, the point past which Elm’s // wraps, as the second lesson of this module measured. Follow one item of the dinner through the rules:

Interactive · stage by stage One item of the dinner, from price to shares Step through the masala dosa, priced 14500 paise and shared by Asha and Ravi, rule by rule.
One item of the dinner, from price to shares, step 6 of 6. Showing The price, Tax 5 percent, Tip 10 percent, The line total, Split between two, The paisa left over. In focus: The paisa left over.The price14500 paiseTax 5 percent725, rounded half upTip 10 percent1450, on the priceThe line total16675 paiseSplit between two8337 each, 1 leftThe paisa left overto Asha, first in order

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Whole paise. Every amount is a whole number of paise, so no step ever stores 145.00 as a float. The form turns the typed 145 into 14500 with parsePaise.
  2. Tax on the item. 5 percent of 14500 is exactly 725. For an amount such as 1450 it is 72.5, and (paise * percent + 50) // 100 rounds that half up to 73.
  3. Tip on the price. The tip is 10 percent of the price before tax, 1450, rounded the same way. Taking it on the price is a rule of this specification, written down so that the tests can hold the code to it.
  4. One line total. 14500 + 725 + 1450 is 16675 paise. The bill's total is the sum of its line totals, 31625 for the dinner.
  5. Equal whole shares. The item is shared by Asha and Ravi, so each gets 16675 // 2, which is 8337, and 16675 - 2 * 8337 = 1 paisa is left over.
  6. The odd paisa. Leftover paise go one each to the sharers in alphabetical order, so Asha gets 8338 and Ravi 8337. The two parts add up to 16675 again, and so the shares add up to the bill.

Tax rates are inputs, not advice

A restaurant meal in India is a supply that carries GST, at rates the government notifies on the recommendation of the GST Council, and the rate on a given bill depends on rules the splitter knows nothing about. So the form asks for the rate as a number from 0 to 28 percent and the code only does the arithmetic: copy the rate from the printed bill, and treat the result as a calculation, never as tax advice.

Checkpoint

Check: The rounding rules of the specification

  1. Question 1 of 2 An item costs 1450 paise and the tax is 5 percent. With tax = (paise * percent + 50) // 100, how many paise of tax does it get?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 73 paise

    1450 × 5 = 7250; 7250 + 50 = 7300; 7300 // 100 = 73. The exact tax is 72.5 paise, and rounding half up gives 73, in whole numbers only, as SplitTest's fourth test expects.

  2. Question 2 of 2 An item's line total is 1001 paise and it is shared by Ravi, Asha and Meera. What does each pay?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Asha 334, Meera 334, Ravi 333

    Equal whole parts plus a fixed rule for the leftover keeps each item's parts adding up to its line total, which is what SplitTest's third test checks.

A pure module turns a bill into shares that add up

Split.elm is the whole core: types for a bill and its items, shares, total, validate for the form, and the two money helpers, parsePaise and rupees. It imports nothing but elm/core, and every function is a pure function: A function whose result depends only on its arguments and that changes nothing outside itself: called twice with the same values it gives the same answer, so it can be tested by comparing what it returns. More in “Project: a ticket-booking core in Gleam”, so the tests, a script and the form can all use it:

Split.elm: the specification as a pure module ELM · splitter/src/Split.elm
module Split exposing (Bill, Item, parsePaise, rupees, shares, total, validate)

{-| The core of the bill splitter: amounts in whole paise, tax and tip rounded
half up per item, and leftover paise given out in alphabetical order, so that
the shares always add up to the bill.
-}


type alias Item =
    { name : String
    , paise : Int
    , sharedBy : List String
    }


type alias Bill =
    { diners : List String
    , items : List Item
    , taxPercent : Int
    , tipPercent : Int
    }


{-| A percentage of an amount, rounded half up: 5 % of 1450 paise is 73.
-}
percentOf : Int -> Int -> Int
percentOf percent paise =
    (paise * percent + 50) // 100


lineTotal : Bill -> Item -> Int
lineTotal bill item =
    item.paise + percentOf bill.taxPercent item.paise + percentOf bill.tipPercent item.paise


total : Bill -> Int
total bill =
    List.sum (List.map (lineTotal bill) bill.items)


{-| One amount split among names: everyone gets the same whole number of paise,
and the paise left over go one each to the first names in alphabetical order.
-}
splitAmong : List String -> Int -> List ( String, Int )
splitAmong names amount =
    let
        sorted =
            List.sort names

        count =
            List.length sorted

        base =
            amount // count

        leftover =
            amount - base * count
    in
    List.indexedMap
        (\index name ->
            if index < leftover then
                ( name, base + 1 )

            else
                ( name, base )
        )
        sorted


{-| Each diner's share in paise, in alphabetical order, every diner listed.
-}
shares : Bill -> List ( String, Int )
shares bill =
    let
        parts =
            List.concatMap (\item -> splitAmong item.sharedBy (lineTotal bill item)) bill.items

        shareOf diner =
            List.sum (List.map Tuple.second (List.filter (\( name, _ ) -> name == diner) parts))
    in
    List.map (\diner -> ( diner, shareOf diner )) (List.sort bill.diners)


{-| Every problem of a bill, in the order of the form, or the bill itself.
-}
validate : Bill -> Result (List String) Bill
validate bill =
    let
        diners =
            if List.isEmpty bill.diners then
                [ "add at least one diner" ]

            else
                List.filterMap identity
                    [ if List.any (\d -> String.trim d == "") bill.diners then
                        Just "every diner needs a name"

                      else
                        Nothing
                    , if List.length (unique bill.diners) < List.length bill.diners then
                        Just "two diners have the same name"

                      else
                        Nothing
                    ]

        items =
            if List.isEmpty bill.items then
                [ "add at least one item" ]

            else
                List.concat (List.indexedMap (itemProblems bill.diners) bill.items)

        rates =
            List.filterMap identity
                [ if bill.taxPercent < 0 || bill.taxPercent > 28 then
                    Just "tax must be from 0 to 28 percent"

                  else
                    Nothing
                , if bill.tipPercent < 0 || bill.tipPercent > 25 then
                    Just "tip must be from 0 to 25 percent"

                  else
                    Nothing
                ]

        problems =
            diners ++ items ++ rates
    in
    if List.isEmpty problems then
        Ok bill

    else
        Err problems


itemProblems : List String -> Int -> Item -> List String
itemProblems diners index item =
    let
        label =
            "item " ++ String.fromInt (index + 1) ++ " (" ++ item.name ++ ")"
    in
    List.filterMap identity
        [ if item.paise <= 0 || item.paise > 10000000 then
            Just (label ++ ": the price must be from 0.01 to 100000.00")

          else
            Nothing
        , if List.isEmpty item.sharedBy then
            Just (label ++ ": shared by nobody")

          else
            Nothing
        , case List.filter (\name -> not (List.member name diners)) item.sharedBy of
            [] ->
                Nothing

            strangers ->
                Just (label ++ ": not a diner: " ++ String.join ", " strangers)
        , if List.length (unique item.sharedBy) < List.length item.sharedBy then
            Just (label ++ ": a diner is listed twice")

          else
            Nothing
        ]


unique : List String -> List String
unique names =
    List.foldl
        (\name kept ->
            if List.member name kept then
                kept

            else
                kept ++ [ name ]
        )
        []
        names


{-| A price typed in rupees, such as "145" or "145.50", in paise; Nothing for
anything else, such as "145.505", "-3" or "1e3".
-}
parsePaise : String -> Maybe Int
parsePaise text =
    case String.split "." (String.trim text) of
        [ whole ] ->
            Maybe.map (\r -> r * 100) (digits whole)

        [ whole, fraction ] ->
            if String.length fraction >= 1 && String.length fraction <= 2 then
                Maybe.map2 (\r p -> r * 100 + p) (digits whole) (digits (String.padRight 2 '0' fraction))

            else
                Nothing

        _ ->
            Nothing


digits : String -> Maybe Int
digits text =
    if text /= "" && String.all Char.isDigit text then
        String.toInt text

    else
        Nothing


{-| Whole paise of 0 or more as rupees with two digits after the dot.
-}
rupees : Int -> String
rupees paise =
    "₹" ++ String.fromInt (paise // 100) ++ "." ++ String.padLeft 2 '0' (String.fromInt (modBy 100 paise))

splitAmong is where the shares are made to add up. It sorts the sharers, gives each amount // count, works out the leftover as amount - base * count, which is always from 0 to count - 1, and adds one paisa to the first leftover names. The parts of one item therefore add up to its line total exactly, and so the shares add up to the bill. Dinner.elm is a worker that splits one fixed dinner and prints a summary:

Dinner.elm: one dinner for three, as a summary ELM · splitter/src/Dinner.elm
port module Dinner exposing (main)

{-| One dinner for three, split with the Split module and printed as a summary
that could be copied into a chat.
-}

import Split exposing (Bill)


port out : String -> Cmd msg


dinner : Bill
dinner =
    { diners = [ "Ravi", "Asha", "Meera" ]
    , items =
        [ { name = "masala dosa", paise = 14500, sharedBy = [ "Asha", "Ravi" ] }
        , { name = "filter coffee", paise = 4000, sharedBy = [ "Asha", "Meera", "Ravi" ] }
        , { name = "idli", paise = 9000, sharedBy = [ "Meera" ] }
        ]
    , taxPercent = 5
    , tipPercent = 10
    }


summary : Bill -> String
summary bill =
    case Split.validate bill of
        Err problems ->
            String.join "\n" ("The bill has problems:" :: problems)

        Ok good ->
            String.join "\n"
                (List.map (\( diner, paise ) -> String.padRight 8 ' ' diner ++ String.padLeft 9 ' ' (Split.rupees paise)) (Split.shares good)
                    ++ [ String.padRight 8 ' ' "total" ++ String.padLeft 9 ' ' (Split.rupees (Split.total good))
                       , "tax " ++ String.fromInt good.taxPercent ++ " %, tip " ++ String.fromInt good.tipPercent ++ " %, both on each item's price"
                       ]
                )


main : Program () () Never
main =
    Platform.worker
        { init = \_ -> ( (), out (summary dinner) )
        , update = \_ model -> ( model, Cmd.none )
        , subscriptions = \_ -> Sub.none
        }

The session below printed it: Asha 98.72, Meera 118.83 and Ravi 98.70 rupees, a total of 316.25 that is the sum of the three. Asha paid both odd paise, one from the masala dosa and one from the filter coffee, because she comes first in alphabetical order. The rule is simple and the same every time, but over many bills it always favours the same names. Weigh it against two other rules, with your own use of the splitter in mind:

Interactive · answer and compare Who should pay the odd paise? Answer two questions about how the splitter is used. The table marks the rules that fit and says why the others do not.
Will the same group split many bills, so small biases add up?
Must anyone at the table be able to check the split by hand?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three rules for the paise left over after an equal split, against four things a bill splitter needs
Rule Shares add up Same answer each run Fair over many items Easy to check by hand
Alphabetical order Gives each odd paisa to: the first names, A to Z Good every paisa goes to someone Good the order of names never changes Poor Asha got both odd paise of the dinner, and would get every one Good sort the names, count down the leftover
Turns, item by item Gives each odd paisa to: the next sharer after the last Good every paisa goes to someone Good the same items in the same order give the same answer Good over many items each sharer pays about the same Mixed you must track where the last item stopped
The one who pays Gives each odd paisa to: the diner who settles the bill Good every paisa goes to the payer Good one name, chosen in the form Mixed the payer covers up to n - 1 paise on each item shared by n Good all odd paise on one line
How the answers decide
  • “Will the same group split many bills, so small biases add up?” Yes, every week: rules out Alphabetical order and adds a caveat to The one who pays. Alphabetical order gives every odd paisa to the same first names; over many bills the same person always pays more.
  • “Must anyone at the table be able to check the split by hand?” Yes: adds a caveat to Turns, item by item. Turns depend on where the previous item stopped, which is harder to follow at the table than a sorted list.

In short: alphabetical order is easy to check and fine for one dinner; a group that splits every week should take turns, item by item; and giving the odd paise to whoever pays keeps the rest of the shares round.

Checkpoint

Check: A pure core whose shares add up

  1. Question 1 of 2 Why do the shares from Split.shares always add up to Split.total?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: splitAmong gives out exactly the line total of each item, base parts plus the leftover paise, and the shares only add those parts up

    The property holds item by item, so it holds for the whole bill. The fuzz test checks it on generated bills, and breaking the leftover rule made it fail at once.

  2. Question 2 of 2 A group of flatmates splits every grocery bill with the app, and Asha notices she always pays the odd paise. Which rule from the table fits better, and why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Taking turns item by item, so over many items each sharer pays about the same odd paise

    Every rule must give out every paisa; they differ in who gets them. For one dinner alphabetical order is fine; for many bills between the same people, take turns.

Tests pin the rules, and a fuzz test finds the smallest failure

SplitTest.elm holds eight tests. Seven state one rule each with a small bill, such as 1001 paise shared by three giving 334, 334 and 333. The fifth is a fuzz test: A test that elm-test runs many times with generated inputs, such as random lists of prices, and that checks a rule which must hold for every input, such as shares adding up to the bill. The seed makes a run repeatable.: elm-test generates lists of prices and numbers of sharers, and for every one checks that the shares add up to Split.total:

SplitTest.elm: eight tests, one of them a fuzz test ELM · splitter/tests/SplitTest.elm
module SplitTest exposing (suite)

import Expect
import Fuzz
import Split exposing (Bill)
import Test exposing (Test, describe, fuzz2, test)


bill : List String -> List ( String, Int, List String ) -> Bill
bill diners items =
    { diners = diners
    , items = List.map (\( name, paise, sharedBy ) -> { name = name, paise = paise, sharedBy = sharedBy }) items
    , taxPercent = 0
    , tipPercent = 0
    }


suite : Test
suite =
    describe "Split"
        [ test "one diner pays the whole bill" <|
            \_ ->
                Split.shares { diners = [ "Asha" ], items = [ { name = "chai", paise = 1200, sharedBy = [ "Asha" ] } ], taxPercent = 5, tipPercent = 0 }
                    |> Expect.equal [ ( "Asha", 1260 ) ]
        , test "an even split leaves nothing over" <|
            \_ ->
                Split.shares (bill [ "Asha", "Meera", "Ravi" ] [ ( "thali", 3000, [ "Asha", "Meera", "Ravi" ] ) ])
                    |> Expect.equal [ ( "Asha", 1000 ), ( "Meera", 1000 ), ( "Ravi", 1000 ) ]
        , test "leftover paise go to the first names in alphabetical order" <|
            \_ ->
                Split.shares (bill [ "Ravi", "Asha", "Meera" ] [ ( "thali", 1001, [ "Ravi", "Meera", "Asha" ] ) ])
                    |> Expect.equal [ ( "Asha", 334 ), ( "Meera", 334 ), ( "Ravi", 333 ) ]
        , test "tax and tip are each rounded half up, per item, on the price" <|
            \_ ->
                Split.total { diners = [ "Asha" ], items = [ { name = "samosa", paise = 1450, sharedBy = [ "Asha" ] } ], taxPercent = 5, tipPercent = 10 }
                    |> Expect.equal (1450 + 73 + 145)
        , fuzz2 (Fuzz.list (Fuzz.intRange 1 100000)) (Fuzz.intRange 1 4) "the shares always add up to the bill" <|
            \prices sharers ->
                let
                    diners =
                        List.take sharers [ "Asha", "Meera", "Ravi", "Zoya" ]

                    fuzzed =
                        { diners = diners
                        , items = List.map (\paise -> { name = "item", paise = paise, sharedBy = diners }) prices
                        , taxPercent = 5
                        , tipPercent = 10
                        }
                in
                Split.shares fuzzed
                    |> List.map Tuple.second
                    |> List.sum
                    |> Expect.equal (Split.total fuzzed)
        , test "an item nobody shares is refused" <|
            \_ ->
                Split.validate (bill [ "Asha" ] [ ( "lassi", 6000, [] ) ])
                    |> Expect.equal (Err [ "item 1 (lassi): shared by nobody" ])
        , test "every problem is reported at once" <|
            \_ ->
                Split.validate { diners = [ "Asha", "Asha" ], items = [ { name = "dosa", paise = 0, sharedBy = [ "Kabir" ] } ], taxPercent = 40, tipPercent = 0 }
                    |> Expect.equal
                        (Err
                            [ "two diners have the same name"
                            , "item 1 (dosa): the price must be from 0.01 to 100000.00"
                            , "item 1 (dosa): not a diner: Kabir"
                            , "tax must be from 0 to 28 percent"
                            ]
                        )
        , test "prices typed in rupees become paise" <|
            \_ ->
                List.map Split.parsePaise [ "145", "145.5", "145.50", "145.505", "-3", "1e3", "" ]
                    |> Expect.equal [ Just 14500, Just 14550, Just 14550, Nothing, Nothing, Nothing, Nothing ]
        ]

The project session runs them with --seed 42, so that the generated inputs are the same in every run, then builds and runs the dinner, then builds the app; the build steps come up again in the last section:

elm-test, the dinner, then the app optimised and minified Python · splitter_session.py
"""Tests the split core with elm-test, prints the scripted dinner, then builds the app with --optimize and minifies it
the way the compiler's notes on optimising describe, measuring each file, and last writes a page to open in a browser
(session.py plays it)."""

from session import cache, run

cache()
run("""
cd splitter
elm-test --seed 42
elm make src/Dinner.elm --optimize --output=build/dinner.js
node run.cjs
elm make src/Main.elm --optimize --output=build/app.js
wc -c < build/app.js
uglifyjs build/app.js --compress "pure_funcs=[F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9],pure_getters,keep_fargs=false,unsafe_comps,unsafe" | uglifyjs --mangle --output build/app.min.js
wc -c < build/app.min.js
gzip -9 < build/app.min.js | wc -c
elm make src/Main.elm --output=build/index.html
""")

Output

$ cd splitter
$ elm-test --seed 42
Solving dependencies > Compiling > Starting tests

elm-test 0.19.3-0
-----------------

Running 8 tests. To reproduce these results, run: elm-test --fuzz 100 --seed 42


TEST RUN PASSED

Duration: … ms
Passed:   8
Failed:   0
$ elm make src/Dinner.elm --optimize --output=build/dinner.js
Dependencies ready!
Success! Compiled 2 modules.

    Dinner ───> build/dinner.js
$ node run.cjs
Asha       ₹98.72
Meera     ₹118.83
Ravi       ₹98.70
total     ₹316.25
tax 5 %, tip 10 %, both on each item's price
$ elm make src/Main.elm --optimize --output=build/app.js
Success! Compiled 1 module.

    Main ───> build/app.js
$ wc -c < build/app.js
  135770
$ uglifyjs build/app.js --compress "pure_funcs=[F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9],pure_getters,keep_fargs=false,unsafe_comps,unsafe" | uglifyjs --mangle --output build/app.min.js
$ wc -c < build/app.min.js
   23942
$ gzip -9 < build/app.min.js | wc -c
    9282
$ elm make src/Main.elm --output=build/index.html
Success!

    Main ───> build/index.html

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 splitter_session.py

TEST RUN PASSED, eight of eight. A passing test proves little until you have seen it fail, so the next session breaks one line of the core: sed turns if index < leftover then into if False then, so the leftover paise are dropped instead of given out, and the same tests run again:

One line broken on purpose, and the same tests Python · bug_session.py
"""Changes one line of the split core, so that the leftover paise are dropped instead of given out, and runs the same
tests on it (session.py plays it). The change goes through sed into a new file, which then replaces Split.elm, so the
command works the same with the sed of macOS and of Linux."""

from session import cache, run

cache()
run("""
cd splitter
sed 's/if index < leftover then/if False then/' src/Split.elm > Split.changed
mv Split.changed src/Split.elm
grep -n "if False then" src/Split.elm
! elm-test --seed 42
""")

Output

$ cd splitter
$ sed 's/if index < leftover then/if False then/' src/Split.elm > Split.changed
$ mv Split.changed src/Split.elm
$ grep -n "if False then" src/Split.elm
61:            if False then
$ elm-test --seed 42
Solving dependencies > Compiling > Starting tests

elm-test 0.19.3-0
-----------------

Running 8 tests. To reproduce these results, run: elm-test --fuzz 100 --seed 42

↓ SplitTest
↓ Split
✗ leftover paise go to the first names in alphabetical order

               ▼             ▼
    [("Asha",333),("Meera",333),("Ravi",333)]
    ╷
    │ Expect.equal
    ╵
    [("Asha",334),("Meera",334),("Ravi",333)]
               ▲             ▲


↓ SplitTest
↓ Split
✗ the shares always add up to the bill

Given ([1],2)

    0
    ╷
    │ Expect.equal
    ╵
    1



TEST RUN FAILED

Duration: … ms
Passed:   6
Failed:   2
[exit status 2]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bug_session.py

Two tests failed, and both say what is wrong. The rule test shows the expected and the actual list with markers under the two parts that differ, 333 against 334. The fuzz test reports Given ([1],2): after its first failure elm-test kept trying smaller inputs, which is called shrinking: What elm-test does after a fuzz test fails: it tries smaller and simpler inputs until it finds a small one that still fails, such as one item of 1 paisa shared by 2, and reports that one., and the smallest case that still fails is one item of 1 paisa shared by two, where the shares add up to 0 and the bill is 1. The run ended with exit status 2, which is what a CI step checks.

Key idea

Pin each rule with a small test, and the one property that must always hold, here that the shares add up, with a fuzz test and a fixed seed; break the code once on purpose to see both kinds fail.

Checkpoint

Check: elm-test, fuzz tests and shrinking

  1. Question 1 of 2 With the leftover rule broken, the fuzz test reported Given ([1],2). What is that?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The smallest failing input elm-test found: one item of 1 paisa shared by two diners

    Shrinking turns a random failure into a small case you can work through by hand, which is what makes fuzz tests practical.

  2. Question 2 of 2 Why does the session run elm-test --seed 42 instead of plain elm-test?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A fixed seed makes the fuzz test try the same inputs every run, so a result can be repeated

    Random inputs find cases nobody wrote down; a printed or fixed seed makes every such case repeatable on another computer.

The form is a Browser.element wrapped around the core

Main.elm is the app. Its model keeps what was typed, as text, because a half-typed price such as 145. is not yet a mistake; its update only stores what changes; and its view turns the form into a Bill on every change with toBill, which lists every price and rate that does not parse and, once all of them parse, every problem that Split.validate finds:

Main.elm: the form, checked on every change ELM · splitter/src/Main.elm
module Main exposing (main)

{-| The bill splitter as a Browser.element: a form for diners, items, tax and
tip, checked on every change, and the shares, or every problem, below it.
-}

import Browser
import Html exposing (Html, button, div, fieldset, h2, input, label, legend, li, p, text, ul)
import Html.Attributes exposing (checked, for, id, type_, value)
import Html.Events exposing (onClick, onInput)
import Split exposing (Bill)


type alias Row =
    { name : String
    , price : String
    , sharedBy : List String
    }


type alias Model =
    { diners : String
    , rows : List Row
    , tax : String
    , tip : String
    }


type Msg
    = DinersChanged String
    | NameChanged Int String
    | PriceChanged Int String
    | Toggled Int String
    | AddRow
    | RemoveRow Int
    | TaxChanged String
    | TipChanged String


init : () -> ( Model, Cmd Msg )
init _ =
    ( { diners = "Asha, Meera, Ravi"
      , rows = [ { name = "masala dosa", price = "145", sharedBy = [ "Asha", "Ravi" ] } ]
      , tax = "5"
      , tip = "10"
      }
    , Cmd.none
    )


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    ( case msg of
        DinersChanged text ->
            { model | diners = text }

        NameChanged index text ->
            { model | rows = updateRow index (\row -> { row | name = text }) model.rows }

        PriceChanged index text ->
            { model | rows = updateRow index (\row -> { row | price = text }) model.rows }

        Toggled index diner ->
            { model | rows = updateRow index (toggle diner) model.rows }

        AddRow ->
            { model | rows = model.rows ++ [ { name = "", price = "", sharedBy = [] } ] }

        RemoveRow index ->
            { model | rows = List.take index model.rows ++ List.drop (index + 1) model.rows }

        TaxChanged text ->
            { model | tax = text }

        TipChanged text ->
            { model | tip = text }
    , Cmd.none
    )


updateRow : Int -> (Row -> Row) -> List Row -> List Row
updateRow index change rows =
    List.indexedMap
        (\i row ->
            if i == index then
                change row

            else
                row
        )
        rows


toggle : String -> Row -> Row
toggle diner row =
    if List.member diner row.sharedBy then
        { row | sharedBy = List.filter (\d -> d /= diner) row.sharedBy }

    else
        { row | sharedBy = row.sharedBy ++ [ diner ] }


dinersOf : Model -> List String
dinersOf model =
    List.filter (\d -> d /= "") (List.map String.trim (String.split "," model.diners))


{-| The form as a Bill, or every problem with it: first what does not parse, then
what Split.validate finds.
-}
toBill : Model -> Result (List String) Bill
toBill model =
    let
        prices =
            List.map (\row -> Split.parsePaise row.price) model.rows

        priceProblems =
            List.filterMap identity
                (List.indexedMap
                    (\i ( row, parsed ) ->
                        case parsed of
                            Just _ ->
                                Nothing

                            Nothing ->
                                Just ("item " ++ String.fromInt (i + 1) ++ " (" ++ row.name ++ "): write the price in rupees, such as 145.50")
                    )
                    (List.map2 Tuple.pair model.rows prices)
                )

        rate label raw =
            case String.toInt (String.trim raw) of
                Just n ->
                    Ok n

                Nothing ->
                    Err (label ++ " must be a whole number of percent")
    in
    case ( priceProblems, rate "tax" model.tax, rate "tip" model.tip ) of
        ( [], Ok tax, Ok tip ) ->
            Split.validate
                { diners = dinersOf model
                , items = List.map2 (\row paise -> { name = row.name, paise = Maybe.withDefault 0 paise, sharedBy = row.sharedBy }) model.rows prices
                , taxPercent = tax
                , tipPercent = tip
                }

        ( problems, taxResult, tipResult ) ->
            Err (problems ++ List.filterMap errorOf [ taxResult, tipResult ])


errorOf : Result String Int -> Maybe String
errorOf result =
    case result of
        Err problem ->
            Just problem

        Ok _ ->
            Nothing


view : Model -> Html Msg
view model =
    div []
        [ label [ for "diners" ] [ text "Diners, separated by commas" ]
        , input [ id "diners", value model.diners, onInput DinersChanged ] []
        , div [] (List.indexedMap (viewRow (dinersOf model)) model.rows)
        , button [ onClick AddRow ] [ text "Add an item" ]
        , label [ for "tax" ] [ text "Tax, percent" ]
        , input [ id "tax", value model.tax, onInput TaxChanged ] []
        , label [ for "tip" ] [ text "Tip, percent" ]
        , input [ id "tip", value model.tip, onInput TipChanged ] []
        , h2 [] [ text "Shares" ]
        , viewResult (toBill model)
        ]


viewRow : List String -> Int -> Row -> Html Msg
viewRow diners index row =
    let
        key =
            "item-" ++ String.fromInt (index + 1)
    in
    fieldset []
        [ legend [] [ text ("Item " ++ String.fromInt (index + 1)) ]
        , label [ for (key ++ "-name") ] [ text "Name" ]
        , input [ id (key ++ "-name"), value row.name, onInput (NameChanged index) ] []
        , label [ for (key ++ "-price") ] [ text "Price in rupees" ]
        , input [ id (key ++ "-price"), value row.price, onInput (PriceChanged index) ] []
        , div []
            (List.map
                (\diner ->
                    label []
                        [ input [ type_ "checkbox", checked (List.member diner row.sharedBy), onClick (Toggled index diner) ] []
                        , text diner
                        ]
                )
                diners
            )
        , button [ onClick (RemoveRow index) ] [ text ("Remove item " ++ String.fromInt (index + 1)) ]
        ]


viewResult : Result (List String) Bill -> Html Msg
viewResult result =
    case result of
        Ok bill ->
            ul []
                (List.map (\( diner, paise ) -> li [] [ text (diner ++ ": " ++ Split.rupees paise) ]) (Split.shares bill)
                    ++ [ li [] [ text ("Total: " ++ Split.rupees (Split.total bill)) ] ]
                )

        Err problems ->
            div []
                [ p [] [ text "Fix these first:" ]
                , ul [] (List.map (\problem -> li [] [ text problem ]) problems)
                ]


main : Program () Model Msg
main =
    Browser.element
        { init = init
        , view = view
        , update = update
        , subscriptions = \_ -> Sub.none
        }

Step through the project, from the core to the page:

Interactive · step by step The four modules of the splitter, and what runs each Step through the project, from the pure core in the middle to the file a browser loads.
The four modules of the splitter, and what runs each, step 6 of 6. Showing src/Split.elm, tests/SplitTest.elm, src/Main.elm, src/Dinner.elm, build/app.min.js, A browser. In focus: A browser.src/Split.elmthe pure coretests/SplitTest.elm8 tests, elm-testsrc/Main.elmthe form, an elementsrc/Dinner.elma worker for Node.jsbuild/app.min.js23,942 bytesA browserruns the formElm.Main.init

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The core. Split.elm holds every rule: shares, total, validate, parsePaise and rupees. It imports nothing but elm/core and knows nothing about pages or ports.
  2. The tests use the core. SplitTest.elm calls Split's functions with bills and compares what they return, so elm-test runs all 8 tests under Node.js, with no page at all.
  3. A script uses it. Dinner.elm, a Platform.worker, splits one fixed dinner and prints the summary through a port, so the rules can be read as a receipt.
  4. The form uses it. Main.elm is the Browser.element. Its update keeps what was typed, and its view turns the form into a Bill, asks Split for every problem or the shares, and draws the answer.
  5. One file to ship. elm make --optimize writes build/app.js, 135,770 bytes; uglifyjs, with the flags of the compiler's notes, writes build/app.min.js, 23,942 bytes, 9,282 after gzip.
  6. The page. A page loads app.min.js and calls Elm.Main.init with the node the form should take over. Everything a test checked is the same code the page runs.

This project checks the view with a rubric instead of tests: build build/index.html with the session’s last command, elm make src/Main.elm --output=build/index.html, open it in a browser, and check the form against each line.

Check What a good form does
Labels every input has a label, the text fields through a for that matches their id and each checkbox inside its own label, as WCAG 2.2’s labels criterion asks
Problems every message of toBill shows at once, not only the first
Amounts shares in rupees with two digits, from whole paise, never a float on screen
Buttons each says what it does, such as Remove item 2
Keyboard every control is reached with Tab, and each checkbox works with Space
Text names and items appear through Html.text only, so typed markup stays text

Parsing on every keystroke into the model

Storing parsePaise of each keystroke in the model, instead of the text, turns 145. into an error before the person has typed 145.50, and loses what they typed when it does not parse. Keep the text, and parse it in toBill when the view needs the numbers.

Checkpoint

Check: The form around the core

  1. Question 1 of 2 Why does Main's model keep each price as the text typed, not as paise?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A half-typed price such as 145. is not yet a mistake, and the text must survive until it parses

    Keep what the person typed in the model and parse it where the numbers are needed. The mistake callout shows what goes wrong otherwise.

  2. Question 2 of 2 A form has two diners called Asha, one item priced 0 and shared by Asha, a tax of 40 and a tip of 10. What does the splitter show?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: All three problems at once, in the order of the form

    Showing every problem at once saves round trips in a form, and in Elm it is just a list in an Err.

Optimise, minify, measure: the build you ship

The end of the project session follows the compiler’s notes on optimising. elm make --optimize wrote build/app.js, 135,770 bytes. uglifyjs then compressed it with the flags those notes give and mangled the names, in two passes as they ask, and wrote build/app.min.js, 23,942 bytes, 82 % smaller; gzip -9 brought that to 9,282 bytes, about what a server that compresses its files would send.

The flags are safe only because of what Elm guarantees. pure_funcs=[F2,…,A9] tells the minifier that Elm’s helpers for calling functions have no side effects, so a call whose result is unused may be dropped, and unsafe_comps and unsafe allow rewrites that ordinary JavaScript could break. Compiled Elm has no side effects outside the runtime, which is what makes these flags safe for it and dangerous for hand-written code. The first lesson’s worker saved under 1 % with --optimize alone; here the minifier: A tool that rewrites JavaScript into a smaller file that does the same thing, by shortening names and removing unused code and spaces. Elm's notes on optimising pass --optimize output through uglifyjs. saved most of the file.

Itemised Bill Split Calculator Compare your splitter's answer for a bill with an independent split in your browser.

Measure in the build script

Print the size of the optimised, the minified and the gzipped file in every build, as the session does with wc -c, and fail the build if the minified file grows past a budget you set, so that a new package cannot double the download unnoticed.

Checkpoint

Check: Optimising and minifying the build

  1. Question 1 of 2 The optimised app.js was 135,770 bytes and the minified app.min.js 23,942 bytes. By what percentage, to the nearest whole number, did the minifier shrink it?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 82 % (anything from 81.5 to 82.5 counts)

    (135,770 − 23,942) ÷ 135,770 ≈ 0.824, about 82 %. gzip then brought the minified file to 9,282 bytes, the size a compressing server would send.

  2. Question 2 of 2 A teammate wants to reuse the uglifyjs flags of Elm's notes, pure_funcs and unsafe included, for the site's hand-written JavaScript. What do you say?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No: those flags are safe for compiled Elm because it has no side effects, and may break ordinary code

    pure_funcs tells the minifier that calls to Elm's F2 to A9 helpers can be dropped when their result is unused. In code with side effects, dropping such a call changes what the program does.

Practise: build the core in JavaScript too

Three steps in JavaScript, which the page runs in your browser, each the twin of a function of Split.elm and tested with the same cases as SplitTest.elm: read a typed price as paise, split a bill so that the shares add up, and report every problem of a bill at once. Writing the core twice, in two languages, is also how you would check a port: run both on the same bills and compare. The dinner of Dinner.elm is among the tests of the second step:

Code lab · 3 steps

The splitter's core, in JavaScript

Parse prices in rupees into whole paise, split a bill whose shares always add up, and list every problem of a bill in the order of the form.

  1. Read a price typed in rupees as paise To do
  2. Split a bill so that the shares add up To do
  3. Report every problem of a bill at once To do

Exercise · Easy · JavaScript

Read a price typed in rupees as paise

The splitter's form takes prices as text in rupees, and Split.parsePaise turns them into whole paise before any arithmetic. Write the same function in JavaScript: parsePaise(text) in price.mjs returns the price in paise, or null for text that is not a price.

  • Spaces around the text are ignored.
  • A price is digits, then optionally a dot and one or two more digits: "145" is 14500, "145.5" is 14550 and "145.50" is 14550.
  • Anything else is null: three digits after the dot ("145.505"), a sign ("-3", "+3"), an exponent ("1e3"), a dot with nothing on one side (".5", "5."), commas ("1,450") and the empty string.

The starter code uses parseFloat, which reads "145.505", "1e3" and "12abc" without complaint and multiplies in floating point.

Starter code · price.mjs

/** A price typed in rupees, such as "145.50", in whole paise; null for text that is not a price. */
export function parsePaise(text) {
  // Replace this: parseFloat accepts "1e3" and "12abc", and multiplying a float by 100 can give 14549.999999999998.
  const value = parseFloat(text);
  return Number.isNaN(value) ? null : Math.round(value * 100);
}
The sample tests · price.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { parsePaise } from './price.mjs';

test('the prices of the elm-test test, as Split.parsePaise reads them', () => {
  assert.equal(parsePaise('145'), 14500);
  assert.equal(parsePaise('145.5'), 14550);
  assert.equal(parsePaise('145.50'), 14550);
  assert.equal(parsePaise('145.505'), null);
  assert.equal(parsePaise('-3'), null);
  assert.equal(parsePaise('1e3'), null);
  assert.equal(parsePaise(''), null);
});

test('spaces around the text are ignored', () => {
  assert.equal(parsePaise('  40 '), 4000);
  assert.equal(parsePaise(' 0.05'), 5);
});

test('text that only looks like a price', () => {
  for (const text of ['+3', '.5', '5.', '1,450', '12abc', '12.3.4', 'abc', '  ']) {
    assert.equal(parsePaise(text), null, `"${text}" is not a price`);
  }
});

test('whole paise, never a float', () => {
  assert.equal(parsePaise('0.29'), 29);
  assert.equal(parsePaise('1.15'), 115);
  assert.equal(parsePaise('100000.00'), 10000000);
});
A hint

Check the whole trimmed text against one regular expression, such as /^(\d+)(?:\.(\d{1,2}))?$/, before you convert anything. Then build the paise from the two groups as whole numbers: the rupees times 100, plus the paise part padded to two digits with padEnd(2, '0').

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Medium · JavaScript

Split a bill so that the shares add up

Write shares(bill) in shares.mjs with the rules of Split.elm, so that it gives the same answer as the Elm core for every bill. bill is { diners, items: [{ name, paise, sharedBy }], taxPercent, tipPercent }, already checked; return [[diner, paise], …] for every diner in alphabetical order, including diners who shared nothing.

  • Tax and tip are each a percentage of an item's price, rounded half up on their own: Math.floor((paise * percent + 50) / 100). An item's line total is its price plus both.
  • A line total is split among the item's sharers: each gets the line total divided by their number, without the fraction, and the paise left over go one each to the sharers in alphabetical order.
  • A diner's share is the sum of their parts of every item.

The sample tests use the cases of SplitTest.elm and the dinner, and check that the shares add up to the bill.

Starter code · shares.mjs

/** Every diner's share of the bill in paise, as [diner, paise] pairs in alphabetical order. */
export function shares(bill) {
  // Replace this: it splits each price evenly with a fraction, forgets tax and tip, and rounds each part on its own.
  const totals = new Map(bill.diners.map((d) => [d, 0]));
  for (const item of bill.items) {
    for (const diner of item.sharedBy) totals.set(diner, totals.get(diner) + Math.round(item.paise / item.sharedBy.length));
  }
  return [...totals].sort((a, b) => (a[0] < b[0] ? -1 : 1));
}
The sample tests · shares.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { shares } from './shares.mjs';

const bill = (diners, items, taxPercent = 0, tipPercent = 0) => ({
  diners,
  items: items.map(([name, paise, sharedBy]) => ({ name, paise, sharedBy })),
  taxPercent,
  tipPercent,
});

const sum = (pairs) => pairs.reduce((total, [, paise]) => total + paise, 0);

test('one diner pays the whole bill, tax included', () => {
  assert.deepEqual(shares(bill(['Asha'], [['chai', 1200, ['Asha']]], 5)), [['Asha', 1260]]);
});

test('an even split, and leftover paise in alphabetical order', () => {
  assert.deepEqual(shares(bill(['Asha', 'Meera', 'Ravi'], [['thali', 3000, ['Asha', 'Meera', 'Ravi']]])), [['Asha', 1000], ['Meera', 1000], ['Ravi', 1000]]);
  assert.deepEqual(shares(bill(['Ravi', 'Asha', 'Meera'], [['thali', 1001, ['Ravi', 'Meera', 'Asha']]])), [['Asha', 334], ['Meera', 334], ['Ravi', 333]]);
});

test('tax and tip are rounded half up, each on the price', () => {
  assert.deepEqual(shares(bill(['Asha'], [['samosa', 1450, ['Asha']]], 5, 10)), [['Asha', 1450 + 73 + 145]]);
});

test('the dinner of Dinner.elm', () => {
  const dinner = bill(
    ['Ravi', 'Asha', 'Meera'],
    [
      ['masala dosa', 14500, ['Asha', 'Ravi']],
      ['filter coffee', 4000, ['Asha', 'Meera', 'Ravi']],
      ['idli', 9000, ['Meera']],
    ],
    5,
    10,
  );
  assert.deepEqual(shares(dinner), [['Asha', 9872], ['Meera', 11883], ['Ravi', 9870]]);
  assert.equal(sum(shares(dinner)), 31625);
});

test('a diner who shared nothing is listed with 0', () => {
  assert.deepEqual(shares(bill(['Zoya', 'Asha'], [['vada', 3000, ['Asha']]])), [['Asha', 3000], ['Zoya', 0]]);
});

test('the shares add up to the bill on many small bills', () => {
  const names = ['Asha', 'Meera', 'Ravi', 'Zoya'];
  for (let sharers = 1; sharers <= 4; sharers++) {
    for (let paise = 1; paise <= 60; paise += 7) {
      const diners = names.slice(0, sharers);
      const b = bill(diners, [['item', paise, diners], ['other', paise * 3 + 1, diners]], 5, 10);
      const lineTotal = (p) => p + Math.floor((p * 5 + 50) / 100) + Math.floor((p * 10 + 50) / 100);
      assert.equal(sum(shares(b)), lineTotal(paise) + lineTotal(paise * 3 + 1), `${sharers} sharers, ${paise} paise`);
    }
  }
});
A hint

Write the split of one amount first: sort the sharers, base = Math.floor(amount / n), leftover = amount - base * n, and give base + 1 to the first leftover names. Then keep a Map from diner to paise, add each item's parts to it, and turn it into pairs in the order of the sorted diners.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Medium · JavaScript

Report every problem of a bill at once

Split.validate returns every problem of a bill, in the order of the form, so that the page can show them all at once. Write validate(bill) in validate.mjs with the same checks and the same messages; it returns an array of messages, empty when the bill is fine. bill is { diners, items: [{ name, paise, sharedBy }], taxPercent, tipPercent }.

  1. Diners: none at all gives add at least one diner (and no other diner message); otherwise a name that is empty after trimming gives every diner needs a name, and two equal names give two diners have the same name.
  2. Items: none at all gives add at least one item; otherwise, for each item n (from 1) with name name, in this order: item n (name): the price must be from 0.01 to 100000.00 when paise is not from 1 to 10000000, item n (name): shared by nobody, item n (name): not a diner: Kabir, Zoya for sharers who are not diners (in the order listed), and item n (name): a diner is listed twice.
  3. Rates: tax must be from 0 to 28 percent and tip must be from 0 to 25 percent.

Each message appears at most once. The starter code stops at the first problem, the habit the form should not have.

Starter code · validate.mjs

/** Every problem of a bill, in the order of the form; [] when there is none. */
export function validate(bill) {
  // Replace this: it returns only the first problem it finds.
  if (bill.diners.length === 0) return ['add at least one diner'];
  for (const [i, item] of bill.items.entries()) {
    if (item.sharedBy.length === 0) return [`item ${i + 1} (${item.name}): shared by nobody`];
  }
  return [];
}
The sample tests · validate.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { validate } from './validate.mjs';

const item = (name, paise, sharedBy) => ({ name, paise, sharedBy });
const good = { diners: ['Asha', 'Ravi'], items: [item('dosa', 14500, ['Asha', 'Ravi'])], taxPercent: 5, tipPercent: 10 };

test('a good bill has no problems', () => {
  assert.deepEqual(validate(good), []);
});

test('the two cases of SplitTest.elm', () => {
  assert.deepEqual(validate({ diners: ['Asha'], items: [item('lassi', 6000, [])], taxPercent: 0, tipPercent: 0 }), ['item 1 (lassi): shared by nobody']);
  assert.deepEqual(validate({ diners: ['Asha', 'Asha'], items: [item('dosa', 0, ['Kabir'])], taxPercent: 40, tipPercent: 0 }), [
    'two diners have the same name',
    'item 1 (dosa): the price must be from 0.01 to 100000.00',
    'item 1 (dosa): not a diner: Kabir',
    'tax must be from 0 to 28 percent',
  ]);
});

test('no diners and no items', () => {
  assert.deepEqual(validate({ diners: [], items: [], taxPercent: 0, tipPercent: 0 }), ['add at least one diner', 'add at least one item']);
});

test('names that are empty, and sharers listed twice', () => {
  assert.deepEqual(validate({ ...good, diners: ['Asha', '  ', 'Ravi'] }), ['every diner needs a name']);
  assert.deepEqual(validate({ ...good, items: [item('dosa', 14500, ['Asha', 'Asha'])] }), ['item 1 (dosa): a diner is listed twice']);
});

test('prices at and past the limits, and the item numbers', () => {
  assert.deepEqual(validate({ ...good, items: [item('a', 1, ['Asha']), item('b', 10000000, ['Ravi'])] }), []);
  assert.deepEqual(validate({ ...good, items: [item('a', 1, ['Asha']), item('b', 10000001, ['Ravi'])] }), ['item 2 (b): the price must be from 0.01 to 100000.00']);
  assert.deepEqual(validate({ ...good, items: [item('c', -5, ['Zoya', 'Asha', 'Kabir'])] }), [
    'item 1 (c): the price must be from 0.01 to 100000.00',
    'item 1 (c): not a diner: Zoya, Kabir',
  ]);
});

test('the limits of the rates', () => {
  assert.deepEqual(validate({ ...good, taxPercent: 28, tipPercent: 25 }), []);
  assert.deepEqual(validate({ ...good, taxPercent: -1, tipPercent: 26 }), ['tax must be from 0 to 28 percent', 'tip must be from 0 to 25 percent']);
  assert.deepEqual(validate({ ...good, taxPercent: 0, tipPercent: 0 }), []);
});
A hint

Collect messages into one array with push instead of returning early: one block for the diners, a loop over the items with forEach((item, i) => …) that checks the four item rules in order, and two checks for the rates. new Set(names).size < names.length tells you that a name appears twice.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then the self-check on the five parts of the project, starting with the one you know least:

Practice that adapts to you

Practise this lesson

10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 10 Why does the specification keep every amount in whole paise?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: Whole numbers add and divide exactly, while floats such as 0.1 + 0.2 do not

    Money stays in whole paise inside the program; text in rupees is parsed at the edge and formatted again only for display.

  2. Question 2 of 10 Why does the specification limit a price to 100000.00 rupees, when an Elm Int can hold far larger numbers?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: It keeps every quotient of the core far below 2,147,483,647, past which Elm's // wraps round

    A limit in the specification is cheaper than a wrong share. Here it keeps every division safe and gives validate a clear message for anything past it.

  3. Question 3 of 10 In splitAmong, what values can leftover = amount - base * count take, for count sharers?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: From 0 to count - 1

    Because the leftover is less than the number of sharers, giving one paisa each to the first leftover names never runs out of names.

  4. Question 4 of 10 In the dinner, Asha shares the masala dosa (line total 16675) with Ravi and the filter coffee (line total 4600) with Meera and Ravi. What is her share in paise?

    Type a number.

    Show the answer: Question 4 of 10

    Answer: 9872 paise

    16675 // 2 = 8337 with 1 left over, which goes to Asha: 8338. 4600 // 3 = 1533 with 1 left over, again to Asha: 1534. 8338 + 1534 = 9872, the 98.72 rupees of the summary.

  5. Question 5 of 10 What is the difference between SplitTest's rule tests and its fuzz test?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: A rule test checks one chosen bill against one expected answer; the fuzz test checks a property on many generated bills

    Rule tests document the specification with examples; a property test guards the one thing that must hold for every input.

  6. Question 6 of 10 Your tests pass on the first try. What does the lesson do before trusting them, and why?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: Breaks one line of the code on purpose and checks that the tests fail, to prove they can catch that bug

    A test that has never failed may test nothing. Breaking the code once on purpose, as the bug session did, shows which tests guard which rule.

  7. Question 7 of 10 What does toBill do before it calls Split.validate?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: It reports prices that do not parse and tax or tip that are not whole numbers

    Parse at the edge, then validate the typed value: the form's text becomes a Bill only when every field parses, and each step shows all of its problems together, the parsing ones first.

  8. Question 8 of 10 The rubric asks that every input has a label tied to it, through for and id or by wrapping the input. What does that give a person using a screen reader?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: The input is announced with its label, such as "Price in rupees", and clicking the label moves to the input

    Labels are part of the view's rubric because tests of the core cannot see them; checking them by hand, and with the keyboard, completes the project.

  9. Question 9 of 10 Which file size is closest to what a browser downloads when the server compresses its files?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: 9,282 bytes, the minified file after gzip

    Measure what is sent: optimised, then minified, then compressed. The last number is the one a slow connection feels.

  10. Question 10 of 10 In this module, --optimize alone saved under 1 % of the first lesson's worker, while uglifyjs saved 82 % of the app. Why the difference?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: --optimize renames record fields and unboxes values, while a minifier also shortens every other name and removes unused code and spaces

    --optimize prepares the code, and the minifier does most of the shrinking; the compiler's notes run both, in that order.

Interview questions

Warm-up (fresher to mid-level): Why does the splitter work in whole paise instead of rupees with decimals?

Because binary floating point cannot hold most decimal fractions exactly: 0.1 + 0.2 printed 0.30000000000000004 in the second lesson, and a sum of many such prices drifts. Whole paise are exact integers, so addition, the half-up rounding of tax with (paise * percent + 50) // 100 and the split into equal parts are all exact, and the shares can be proved to add up to the bill. Text in rupees is turned into paise once, at the edge, by parsePaise, which refuses anything that is not a price, and paise become rupees again only for display, with rupees. The limit on prices keeps every quotient far inside the range where Elm’s // is exact.

Two longer questions, one about fairness and one about a bill that does not match the restaurant’s:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to staff

    Users of your Elm bill splitter notice that the diner whose name comes first always pays the odd paise, a few paise every week. Change the rule so that it is fair over time without breaking the guarantee that the shares add up, and say how you would test it.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to senior

    A diner says the splitter's total is 2 paise more than the restaurant's printed bill. The restaurant applies tax once to the subtotal, while the splitter rounds tax on each item. Which is right, and how do you make the app match the receipt without breaking the shares?

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. How does the splitter round tax and tip without any floats?

    Each is a percentage of an item's price in whole paise, rounded half up on its own with (paise * percent + 50) // 100, so 72.5 paise of tax becomes 73.

  2. How do one item's parts always add up to its line total?

    splitAmong gives each sorted sharer amount // count, then one more paisa to the first leftover names, where leftover = amount - base * count lies from 0 to count - 1.

  3. Why did Asha pay both odd paise of the dinner, and what is the catch?

    Leftover paise go to sharers in alphabetical order, and Asha comes first. The rule is easy to check, but over many bills it always favours the same names.

  4. Why does the splitter cap prices at 100000.00 rupees?

    To keep every quotient in the core far below 2,147,483,647, past which Elm's // wraps.

  5. What does the splitter's fuzz test check, and why does it run with --seed 42?

    That the shares add up to the bill for generated lists of prices and sharers. The fixed seed makes elm-test generate the same inputs in every run.

  6. What is shrinking in elm-test?

    After a fuzz failure, elm-test tries smaller inputs that still fail. The broken splitter came down to one item of 1 paisa shared by two: Given ([1],2).

  7. Why does the form keep what was typed as text in its model?

    A half-typed price such as 145. is not a mistake yet, and parsing early would lose what was typed. toBill parses when the view needs numbers and lists every problem at once.

  8. Why are uglifyjs's pure_funcs and unsafe flags safe for compiled Elm, and what did they save?

    Compiled Elm has no side effects outside the runtime, so dropping unused calls cannot change it. The app went from 135,770 bytes to 23,942 minified, 9,282 gzipped.

Key takeaways

  • Write the rounding rules down before the code: whole paise, tax and tip each rounded half up on an item’s price, and a fixed rule for the paise left over after an equal split.
  • Keep the rules in a pure module: Split.elm gives every sharer amount // count and one more paisa to the first leftover names, so the parts of each item, and so the shares, add up to the bill.
  • Pin each rule with a small test and the sum with a fuzz test on a fixed seed; breaking one line made two tests fail, and shrinking reduced the fuzz failure to one item of 1 paisa shared by two.
  • Keep the form’s text in the model and parse it in the view’s toBill, so that the problems show together as a list and nothing typed is lost; check the view with the rubric.
  • Ship --optimize output through a minifier with Elm’s flags: 135,770 bytes became 23,942, and 9,282 gzipped.

Terms in this lesson

fuzz test
A test that elm-test runs many times with generated inputs, such as random lists of prices, and that checks a rule which must hold for every input, such as shares adding up to the bill. The seed makes a run repeatable.
minifier
A tool that rewrites JavaScript into a smaller file that does the same thing, by shortening names and removing unused code and spaces. Elm's notes on optimising pass --optimize output through uglifyjs.
shrinking
What elm-test does after a fuzz test fails: it tries smaller and simpler inputs until it finds a small one that still fails, such as one item of 1 paisa shared by 2, and reports that one.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.