diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index 13066344..c4ac5248 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -38,6 +38,7 @@ export default defineConfig({ { text: 'Sandwich Tests', link: '/sandwich-tests/' }, { text: 'Cram Tests', link: '/cram-tests/' }, { text: 'Burger Discounts', link: '/burger-discounts/' }, + { text: 'Discounts Using Lists', link: '/discounts-lists/' }, ] } ], diff --git a/docs/discounts-lists/Discount.re b/docs/discounts-lists/Discount.re new file mode 100644 index 00000000..3b30b268 --- /dev/null +++ b/docs/discounts-lists/Discount.re @@ -0,0 +1,117 @@ +// #region get-free-burger +/** Buy 2 burgers, get 1 free */ +let getFreeBurger = (items: list(Item.t)) => { + let prices = + items + |> List.filter(item => + switch (item) { + | Item.Burger(_) => true + | Sandwich(_) + | Hotdog => false + } + ) + |> List.map(Item.toPrice) + |> List.sort((x, y) => - compare(x, y)); + + switch (prices) { + | [] + | [_] => None + | [_, cheaperPrice, ..._] => Some(cheaperPrice) + }; +}; +// #endregion get-free-burger + +ignore(getFreeBurger); + +// #region get-half-off +/** Buy 1+ burger with 1+ of every topping, get half off */ +let getHalfOff = (items: list(Item.t)) => { + let meetsCondition = + items + |> List.exists( + fun + | Item.Burger({lettuce: true, tomatoes: true, onions, cheese, bacon}) + when onions > 0 && cheese > 0 && bacon > 0 => + true + | Burger(_) + | Sandwich(_) + | Hotdog => false, + ); + + switch (meetsCondition) { + | false => None + | true => + let total = + items + |> List.fold_left((total, item) => total +. Item.toPrice(item), 0.0); + Some(total /. 2.0); + }; +}; +// #endregion get-half-off + +// #region get-free-burger-improved +/** Buy 2 burgers, get 1 free */ +let getFreeBurger = (items: list(Item.t)) => { + let prices = + items + |> List.filter_map(item => + switch (item) { + | Item.Burger(burger) => Some(Item.Burger.toPrice(burger)) + | Sandwich(_) + | Hotdog => None + } + ) + |> List.sort((x, y) => - Float.compare(x, y)); + + switch (prices) { + | [] + | [_] => None + | [_, cheaperPrice, ..._] => Some(cheaperPrice) + }; +}; +// #endregion get-free-burger-improved + +ignore(getFreeBurger); + +// #region get-free-burger-nth +let getFreeBurger = (items: list(Item.t)) => { + items + |> List.filter(item => + switch (item) { + | Item.Burger(_) => true + | Sandwich(_) + | Hotdog => false + } + ) + |> List.map(Item.toPrice) + |> List.sort((x, y) => - Float.compare(x, y)) + |> ListSafe.nth(1); +}; +// #endregion get-free-burger-nth + +// #region get-free-burgers +/** Buy n burgers, get n/2 burgers free */ +let getFreeBurgers = (items: list(Item.t)) => { + let prices = + items + |> List.filter_map(item => + switch (item) { + | Item.Burger(burger) => Some(Item.Burger.toPrice(burger)) + | Sandwich(_) + | Hotdog => None + } + ); + + switch (prices) { + | [] + | [_] => None + | prices => + let result = + prices + |> List.sort((x, y) => - Float.compare(x, y)) + |> List.filteri((index, _) => index mod 2 == 1) + |> List.fold_left((+.), 0.0); + Some(result); + }; +}; +// #endregion get-free-burgers diff --git a/docs/discounts-lists/DiscountTests.re b/docs/discounts-lists/DiscountTests.re new file mode 100644 index 00000000..5f54fe71 --- /dev/null +++ b/docs/discounts-lists/DiscountTests.re @@ -0,0 +1,11 @@ +open Fest; + +// #region first-test +test("0 burgers, no discount", () => + expect + |> equal( + Discount.getFreeBurger([Hotdog, Sandwich(Ham), Sandwich(Turducken)]), + None, + ) +); +// #endregion first-test diff --git a/docs/discounts-lists/Item.re b/docs/discounts-lists/Item.re new file mode 100644 index 00000000..d0e35325 --- /dev/null +++ b/docs/discounts-lists/Item.re @@ -0,0 +1,52 @@ +module Burger = { + type t = { + lettuce: bool, + onions: int, + cheese: int, + tomatoes: bool, + bacon: int, + }; + + let toPrice = ({onions, cheese, tomatoes, bacon, lettuce: _}) => { + let toppingCost = (quantity, cost) => float_of_int(quantity) *. cost; + + 15. // base cost + +. toppingCost(onions, 0.2) + +. toppingCost(cheese, 0.1) + +. (tomatoes ? 0.05 : 0.0) + +. toppingCost(bacon, 0.5); + }; +}; + +module Sandwich = { + type t = + | Portabello + | Ham + | Unicorn + | Turducken; + + let toPrice = (~date: Js.Date.t, t) => { + let day = date |> Js.Date.getDay |> int_of_float; + + switch (t) { + | Portabello + | Ham => 10. + | Unicorn => 80. + | Turducken when day == 2 => 10. + | Turducken => 20. + }; + }; +}; + +type t = + | Sandwich(Sandwich.t) + | Burger(Burger.t) + | Hotdog; + +let toPrice = t => { + switch (t) { + | Sandwich(sandwich) => Sandwich.toPrice(sandwich, ~date=Js.Date.make()) + | Burger(burger) => Burger.toPrice(burger) + | Hotdog => 5. + }; +}; diff --git a/docs/discounts-lists/ListSafe.re b/docs/discounts-lists/ListSafe.re new file mode 100644 index 00000000..6eba66a7 --- /dev/null +++ b/docs/discounts-lists/ListSafe.re @@ -0,0 +1,2 @@ +/** Return the nth element encased in Some; if it doesn't exist, return None */ +let nth = (n, list) => n < 0 ? None : List.nth_opt(list, n); diff --git a/docs/discounts-lists/Order.re b/docs/discounts-lists/Order.re new file mode 100644 index 00000000..173bc8a9 --- /dev/null +++ b/docs/discounts-lists/Order.re @@ -0,0 +1,38 @@ +type t = list(Item.t); + +module Format = { + let currency = _ => React.null; +}; + +module OrderItem = { + [@react.component] + let make = (~item as _: Item.t) =>
; +}; + +let css = {"order": "", "total": ""}; + +// #region make +[@react.component] +let make = (~items: t) => { + let total = + items + |> ListLabels.fold_left(~init=0., ~f=(acc, order) => + acc +. Item.toPrice(order) + ); + + + + {items + |> List.mapi((index, item) => + + ) + |> Stdlib.Array.of_list + |> React.array} + + + + + +
{React.string("Total")} {total |> Format.currency}
; +}; +// #endregion make diff --git a/docs/discounts-lists/dune b/docs/discounts-lists/dune new file mode 100644 index 00000000..1f87da6c --- /dev/null +++ b/docs/discounts-lists/dune @@ -0,0 +1,6 @@ +(melange.emit + (target output) + (libraries reason-react melange-fest) + (preprocess + (pps melange.ppx reason-react-ppx)) + (module_systems es6)) diff --git a/docs/discounts-lists/function-popup.png b/docs/discounts-lists/function-popup.png new file mode 100644 index 00000000..21087931 Binary files /dev/null and b/docs/discounts-lists/function-popup.png differ diff --git a/docs/discounts-lists/index.md b/docs/discounts-lists/index.md new file mode 100644 index 00000000..e89a53c8 --- /dev/null +++ b/docs/discounts-lists/index.md @@ -0,0 +1,527 @@ +# Discounts Using Lists + +You've implemented the logic for the burger discounts that will be applied on +International Burger Day, but something feels off. The code inside the +`Discount` module is safe, in the sense that it doesn't raise exceptions, so +when it's used in the app, it won't crash. But the current logic in +`Discount.getFreeBurger` is a little fragile, since you have to remember to not +change the order of function invocations in such a way that the function will +[accidentally change the input array](/burger-discounts/#arrays-are-mutable). +You have a test in `DiscountTests` which tests for that possibility, but you +know that tests are only as reliable as the humans that maintain them. By now, +you've come to realize that The OCaml Way should be to rewrite the function so +that it **cannot** have side effects. + +## Intro to lists + +A [list](https://reasonml.github.io/docs/en/basic-structures#list) is a +sequential data structure that can often be used in place of an array. In OCaml, +lists are implemented as [singly-linked +lists](https://en.wikipedia.org/wiki/Linked_list#Singly_linked_list). The main +limitation is that they don't allow constant-time access to any element except +the first one (also known as the *head* of a list). Most operations on lists go +through the [Stdlib.List +module](https://melange.re/v3.0.0/api/re/melange/Stdlib/List/), which has many +functions that are equivalent to the ones you've already used in +[Js.Array](https://melange.re/v3.0.0/api/re/melange/Js/Array/). + +::: tip + +Since `Stdlib` is opened by default, you can access all `Stdlib.List` functions +using just `List`, e.g. `List.map`, `List.filter`, etc. Because of this, we'll +refer to this module as `List` from now on. + +::: + +## Refactor `Discount.getFreeBurger` + +Let's refactor `Discount.getFreeBurger` to accept `list(Item.t)` instead of +`array(Item.t)`: + +<<< Discount.re#get-free-burger + +There are a lot of things to talk about in this piece of code---let's go through +them one by one. + + +::: tip + +This refactor makes sense because the logic inside `Discount.getFreeBurger` +doesn't need to access random positions within the sequence of items. If that +was the case, then it would be better to keep using arrays. + +::: + +## Documentation comment + +You might have noticed that the comment at the beginning of the function has a +different format than before: + +```reason +// Buy 2 burgers, get 1 free // [!code --] +/** Buy 2 burgers, get 1 free */ // [!code ++] +``` + +This is a **documentation comment**, a special comment that is attached to the +function it appears above. Go to `DiscountTests` and hover over an invocation of +`getFreeBurger`---the editor will display a popup showing both the type +signature and documentation comment of this function: + +![Function info popup](function-popup.png) + +::: info + +By "editor", we mean an instance of Visual Studio Code that has the [OCaml +Platform +extension](https://marketplace.visualstudio.com/items?itemName=ocamllabs.ocaml-platform) +installed. Other editors that have OCaml support probably have the same (or very +similar) features, but we don't guarantee it. + +::: + +Documentation comments can also be attached to modules, types, and variables. +Besides showing up in editor popups, they are also consumed by documentation +generators like [odoc](https://ocaml.github.io/odoc/). + +## `List` functions + +We replaced these functions with their counterparts in the [List +module](https://melange.re/v3.0.0/api/re/melange/Stdlib/List/): + +- `Js.Array.filter` → + [List.filter](https://melange.re/v3.0.0/api/re/melange/Stdlib/List/#val-filter). + Note that `List.filter` doesn’t accept the labeled argument `~f`, because the + functions inside `List` don't use labeled arguments. +- `Js.Array.map` → + [List.map](https://melange.re/v3.0.0/api/re/melange/Stdlib/List/#val-map). + `List.map` also doesn’t accept the labeled argument `~f`. +- `Js.Array.sortInPlaceWith` → + [List.sort](https://melange.re/v3.0.0/api/re/melange/Stdlib/List/#val-sort). + `List.sort` returns a brand new list, because, unlike +`Js.Array.sortInPlaceWith`, it doesn’t modify its argument (and it can’t, since + lists are immutable). + +## Pattern match on lists + +The switch expression in `Discount.getFreeBurgers` accepts the entire `prices` +list. Unlike with arrays, we can pattern match on lists even if we don’t know +the length of the list. + +Inside the "failure" branch of the switch expression, we see this: + +```reason +| [] +| [_] => None +``` + +This pattern matches both empty lists and lists with one element, and returns +`None`. + +Inside the "success" branch of the switch expression, we have: + +```reason +| [_, cheaperPrice, ..._] => Some(cheaperPrice) +``` + +This pattern will match on lists that have at least two elements. The first +element is ignored via the wildcard patten `_`. The second element is bound to +the name `cheaperPrice`, which is encased in `Some` and returned. We use *list +spread syntax* (`...`) to indicate that the list can have more than the two +elements we explicitly matched on. + +## List spread syntax + +The spread operator (`...`) can also be used to create a new list by prepending +elements to an existing list: + +```reason +let list = [1, 2, 3]; +Js.log([0, ...list]); // [0, 1, 2, 3] +Js.log([-1, 0, ...list]); // [-1, 0, 1, 2, 3] +``` + +::: tip + +```reason +[1, 2, 3] +``` + +is really just a shortcut for + +```reason +[1, ...[2, ...[3, ...[]]]] +``` + +::: + +When pattern matching, the spread operator allows you to bind the *tail* of +a list to a name: + +```reason +switch ([1, 2, 3]) { +| [_, ...tail] => Js.log(tail) // [2, 3] +| _ => () +}; + +switch ([1, 2, 3]) { +| [_, _, _, ...tail] => Js.log(tail) // [] +| _ => () +}; +``` + +The tail of the list is the sublist that remains after you extract the first n +elements from the front of the list. As you can see, the tail might be the empty +list (`[]`). In practice, you don't need to bind the tail to a name unless +you're writing a [custom list function](/todo). Often, you'll just bind the tail +to the wildcard pattern `_`, effectively ignoring it: + +```reason{2} +switch (["one", "two", "three"]) { +| [a, b, ..._] => Js.log(a ++ ", " ++ b ++ ", etc") // one, two, etc +| _ => () +}; +``` + +## Runtime representation of lists + +Run this snippet in [Melange +Playground](https://melange.re/v3.0.0/playground/?language=Reason&code=SnMubG9nMyhBcnJheS5vZl9saXN0KFtdKSwgIi0%2BIiwgW10pOwpKcy5sb2czKEFycmF5Lm9mX2xpc3QoWzQyXSksICItPiIsIFs0Ml0pOwpKcy5sb2czKEFycmF5Lm9mX2xpc3QoWzQsIDUsIDZdKSwgIi0%2BIiwgWzQsIDUsIDZdKTsK&live=off): + +```reason +Js.log3(Array.of_list([]), "->", []); +Js.log3(Array.of_list([42]), "->", [42]); +Js.log3(Array.of_list([4, 5, 6]), "->", [4, 5, 6]); +``` + +You'll see this output: + +```text +[] -> 0 +[42] -> {"hd":42,"tl":0} +[4,5,6] -> {"hd":4,"tl":{"hd":5,"tl":{"hd":6,"tl":0}}} +``` + +The list `[4, 5, 6]` becomes this object in the JS runtime (pretty printed for +readability): + +```json +{ + "hd": 4, + "tl": { + "hd": 5, + "tl": { + "hd": 6, + "tl": 0 + } + } +} +``` + +An empty list in the JS runtime is represented by `0`. A non-empty list is +represented by an object with fields `hd` (for the head) and `tl` (for the +tail). + +## Fix tests for `Discount.getFreeBurger` + +You should be getting this compilation error for `DiscountTests`: + +```text +File "src/order-confirmation/DiscountTests.re", lines 15-19, characters 32-11: +15 | ................................[| +16 | Hotdog, +17 | Sandwich(Ham), +18 | Sandwich(Turducken), +19 | |].. +Error: This expression has type 'a array + but an expression was expected of type Item.t list +``` + +It's simple to fix---just change the delimiters from `[||]` to `[]`: + +<<< DiscountTests.re#first-test + +The "Input array isn't changed" test can simply be deleted, because lists are +immutable and therefore `Discount.getBurger` can't change its input list. + +## Refactor `Discount.getHalfOff` + +Let's now refactor `Discount.getHalfOff` to use lists: + +<<< Discount.re#get-half-off + +Again, we swap out array functions for list functions: + +- `Js.Array.some` → + [List.exists](https://melange.re/v3.0.0/api/re/melange/Stdlib/List/#val-exists). + Note that this is one of several functions in `List` that have different names + than their counterparts in `Js.Array`. +- `Js.Array.reduce` → + [List.fold_left](https://melange.re/v3.0.0/api/re/melange/Stdlib/List/#val-fold_left). + Despite its name, `fold_left`[^1] has the same meaning as `reduce`. + +Remember to fix the `Discount.getHalfOff` tests inside `DiscountTests`, and then +all your code should be compiling once more. + +## `ListLabels` module + +The call to `List.fold_left` is not as readable as the previous version using +`Js.Array.reduce`, but its readability can be improved by instead using +[ListLabels.fold_left](https://melange.re/v1.0.0/api/re/melange/Stdlib/ListLabels/#val-fold_left): + +```reason +let total = + items + |> List.fold_left((total, item) => total +. Item.toPrice(item), 0.0); // [!code --] + |> ListLabels.fold_left(~init=0.0, ~f=(total, item) => // [!code ++] + total +. Item.toPrice(item) // [!code ++] + ); // [!code ++] +Some(total /. 2.0); +``` + +The [ListLabels](https://melange.re/v1.0.0/api/re/melange/Stdlib/ListLabels/) +module has all the functions found in `List`, but many of them take labeled +arguments instead of positional arguments. + +## Refactor `Order` component + +Because we've gone all in on lists, we have to migrate the component modules as +well. Next up is `Order`. Start off by changing the type of `Order.t` from +`array(Item.t)` to `list(Item.t)`, then refactor `Order.make` accordingly: + +<<< Order.re#make + +Again, we’re mostly just replacing array functions with list functions: + +- `Js.Array.reduce` → `ListLabels.fold_left` +- `Js.Array.mapi` → + [List.mapi](https://melange.re/v3.0.0/api/re/melange/Stdlib/List/#val-mapi). + Note that the order of the callback arguments has been reversed. For + `Js.Array.mapi` it’s `(item, index)`, but for `List.mapi` it’s `(index, + item)`. +- We have to add a call to + [Stdlib.Array.of_list](https://melange.re/v2.2.0/api/re/melange/Stdlib/Array/#val-of_list) + in between the calls to `List.mapi` and `React.array` to convert the list to + an array. Whenever we want to render a list of `React.element`s, we must first + convert it to an array of `React.element`s. To understand why, recall that a + list is just an object in the JS runtime, and React cannot directly render + objects. + +Because `Stdlib` is automatically opened, normally we can just call +`Array.of_list`, but we have to use the full name `Stdlib.Array.of_list` because +our custom `Array` module takes precedence[^2]. + +To get all your code compiling again, you have to also refactor `Index`. But +there all you have to do is change the array delimiters (`[||]`) to list +delimiters (`[]`). + +## `List.nth_opt` + +If we peruse the `List` module a bit, we'll find a function that can make +`Discount.getFreeBurger` shorter: +[List.nth](https://melange.re/v1.0.0/api/re/melange/Stdlib/List/#val-nth). It +takes an index `n` that returns the `n`-th element of a list. However, from +previous experience, we don't want to use unsafe functions like this. +Fortunately, there's a similar +[List.nth_opt](https://melange.re/v1.0.0/api/re/melange/Stdlib/List/#val-nth_opt) +function that does the same thing but is safer because it returns `option` +instead of raising an exception. Let's refactor `Discount.getFreeBurger` to use +it: + +```reason +let getFreeBurger = (items: list(Item.t)) => { + items + |> List.filter(item => + switch (item) { + | Item.Burger(_) => true + | Sandwich(_) + | Hotdog => false + } + ) + |> List.map(Item.toPrice) + |> List.sort((x, y) => - compare(x, y)) + |> List.nth_opt(1); +}; +``` + +By using `List.nth_opt`, we can simplify the function to a single expression. +However, we get a compilation error: + +```text +File "docs/order-confirmation/Discount.re", line 61, characters 18-19: +61 | |> List.nth_opt(1); + ^ +Error: This expression has type int but an expression was expected of type + 'a list +``` + +## Placeholder operator + +This is because the type signature of `List.nth_opt` is + +```text +list('a) => int => option('a) +``` + +That is, it accepts the list as the first argument, not the last. Recall that +the pipe last operator (`|>`) pipes values into the last argument of a function. +However, there's a way to override the placement of the argument: + +```reason +|> List.nth_opt(_, 1) +``` + +Do not confuse the `_` here for wildcard, here it's a placeholder for where the +argument should go. When we put `_` in the first argument position, it overrides +the default behavior of the pipe last operator. Run `npm run test` to confirm +that `Discount.getFreeBurger` works the same as before. + +# `List.nth_opt` is unsafe + +The problem with using `List.nth_opt` is that it can still raise an +`Invalid_argument` exception if the value of `n` is negative. You can confirm +this by hovering over `nth_opt` and reading the popup or [read its +documentation](https://melange.re/v1.0.0/api/re/melange/Stdlib/List/#val-nth_opt). +While this is unlikely to cause a problem inside `Discount.getFreeBurger`, it's +best to avoid unsafe functions except in special circumstances, for example if a +function needs to be as fast as possible. Of course, it's possible to implement +a completely safe version of `nth_opt`, which is an exercise at the end of this +chapter. + +--- + +Mazel tov! You've implemented the burger discounts in a way that is more +maintainable, and you've also learned a lot about lists along the way. In the +next chapter, we'll finally use the discount logic to reduce the final price of +an order. + +## Overview + +- Lists are immutable +- You can pattern match on a whole list, even if you don't know its length +- The `List` module contains most of the functions you'll need for + dealing with lists +- The runtime representation of lists: + - Empty list → `0` + - Nonempty list → a JavaScript object with the fields `hd` (for head) and `tl` + (for tail) +- The delimiters for list literals are `[]` +- You can use list spread syntax (`...`) to add elements to the front of a list + or pattern match on the tail of a list + - The names of equivalent functions might not match the names in `Js.Array` +- Documentation comments show up in editor hover popups and generated + documentation pages + - They can be attached to functions, modules, types, and + variables +- The `ListLabels` module contains the same functions as in `List`, but they + have labeled arguments instead of positional arguments +- The placeholder operator (`_`) can be used to override the position of the + piped argument when using the pipe last operator + +## Exercises + +1. There are a couple ways to improve `Discount.getFreeBurger`: + +- Instead of `StdLib.compare`, use a type-specific compare function to make the + code less brittle. Examples of type-specific compare functions are + `Bool.compare` and `String.compare`. +- Use + [List.filter_map](https://melange.re/v2.2.0/api/re/melange/Stdlib/List/#val-filter_map) + +::: details Solution + +<<< Discount.re#get-free-burger-improved + +Prefer type-specific compare functions like +[Float.compare](https://melange.re/v1.0.0/api/re/melange/Stdlib/Float/#val-compare) +over polymorphic `Stdlib.compare`. While `Stdlib.compare` can handle any type, +its flexibility comes with drawbacks. It can be slower due to its polymorphic +nature, and might not always offer meaningful comparisons for complex types. +Additionally, it can raise exceptions if used on non-comparable types like +functions. + +::: + +2. Add a `ListSafe.nth` function which safely returns the nth element of +a list encased in `Some`. If the nth element doesn’t exist, return `None`. Make +sure the function can be used with the pipe last operator without the use of the +placeholder operator. Refactor `Discount.getFreeBurger` to use your new +function. + +::: details Solution + +Add a new file `ListSafe.re`: + +<<< ListSafe.re + +`Discount.getFreeBurger` could be refactored to: + +<<< Discount.re#get-free-burger-nth{12} + +::: + +3. Update the logic of `Discount.getFreeBurger` so that for every pair of +burgers purchased, one of them is free. You can order the burgers by price +(descending), and then choose every other burger (starting from the second +burger) to be free. + +::: details Hint 1 + +Use +[List.filteri](https://melange.re/v3.0.0/api/re/melange/Stdlib/List/#val-filteri) +and [mod operator](https://ocaml.org/manual/5.1/expr.html#ss:expr-operators). + +::: + +::: details Hint 2 + +Use +[ListLabels.fold_left](https://melange.re/v3.0.0/api/re/melange/Stdlib/ListLabels/#val-fold_left) +or [List.fold_left](https://melange.re/v3.0.0/api/re/melange/Stdlib/List/#val-fold_left) + +::: + +::: details Solution + +<<< Discount.re#get-free-burgers + +A few points about this solution: +- `getFreeBurger` is renamed to `getFreeBurgers` to reflect the fact that +multiple burgers can be free. +- The callback passed to `List.fold_left` is just the float addition operator + (`+.`). +- The switch expression reappears because it's needed to detect the cases when + the discount can't be applied (when there are less than two burgers in the + order). + +::: + +4. Update the tests in `DiscountTests` to work with the new function you +added in the last exercise, and then add a new test to make sure that the case +of 4 or more burgers works as expected. + +::: details Solution + +The updated tests can be found in https://github.com/melange-re/melange-for-react-devs/blob/main/src/discounts-lists/DiscountTests.re + +::: + +----- + +View [source +code](https://github.com/melange-re/melange-for-react-devs/blob/main/src/discounts-lists/) +and [demo](https://react-book.melange.re/demo/src/discounts-lists/) for this chapter. + +----- + +[^1]: Inside `List`, there are `fold_left` and `fold_right` functions. "Fold + left" means to apply the given fold function starting from the first element + and work towards the end of the list, while "fold right" starts from the + last element and works backwards towards the front of the list. + +[^2]: A quick fix to allow you to write `Array.of_list` would be to add a + function alias in `Array`: + + ```reason + /** Convert list to array */ + let of_list = Stdlib.Array.of_list; + ``` diff --git a/index.html b/index.html index 05274c93..21bb05bb 100644 --- a/index.html +++ b/index.html @@ -41,6 +41,9 @@

Melange for React Developers

  • Burger Discounts
  • +
  • + Discounts Using Lists +
  • diff --git a/src/discounts-lists/Array.re b/src/discounts-lists/Array.re new file mode 100644 index 00000000..7825f788 --- /dev/null +++ b/src/discounts-lists/Array.re @@ -0,0 +1,7 @@ +/** Safe array access function */ +let get: (array('a), int) => option('a) = + (array, index) => + switch (index) { + | index when index < 0 || index >= Js.Array.length(array) => None + | index => Some(Stdlib.Array.get(array, index)) + }; diff --git a/src/discounts-lists/BurgerTests.re b/src/discounts-lists/BurgerTests.re new file mode 100644 index 00000000..e720b427 --- /dev/null +++ b/src/discounts-lists/BurgerTests.re @@ -0,0 +1,85 @@ +open Fest; + +test("A fully-loaded burger", () => + expect + |> equal( + Item.Burger.toEmoji({ + lettuce: true, + onions: 2, + cheese: 3, + tomatoes: true, + bacon: 4, + }), + {js|🍔{🥬,🍅,🧅×2,🧀×3,🥓×4}|js}, + ) +); + +test("Burger with 0 of onions, cheese, or bacon doesn't show those emoji", () => + expect + |> equal( + Item.Burger.toEmoji({ + lettuce: true, + tomatoes: true, + onions: 0, + cheese: 0, + bacon: 0, + }), + {js|🍔{🥬,🍅}|js}, + ) +); + +test( + "Burger with 1 of onions, cheese, or bacon should show just the emoji without ×", + () => + expect + |> equal( + Item.Burger.toEmoji({ + lettuce: true, + tomatoes: true, + onions: 1, + cheese: 1, + bacon: 1, + }), + {js|🍔{🥬,🍅,🧅,🧀,🥓}|js}, + ) +); + +test("Burger with 2 or more of onions, cheese, or bacon should show ×", () => + expect + |> equal( + Item.Burger.toEmoji({ + lettuce: true, + tomatoes: true, + onions: 2, + cheese: 2, + bacon: 2, + }), + {js|🍔{🥬,🍅,🧅×2,🧀×2,🥓×2}|js}, + ) +); + +test("Burger with more than 12 toppings should also show bowl emoji", () => { + expect + |> equal( + Item.Burger.toEmoji({ + lettuce: true, + tomatoes: true, + onions: 4, + cheese: 2, + bacon: 5, + }), + {js|🍔🥣{🥬,🍅,🧅×4,🧀×2,🥓×5}|js}, + ); + + expect + |> equal( + Item.Burger.toEmoji({ + lettuce: true, + tomatoes: true, + onions: 4, + cheese: 2, + bacon: 4, + }), + {js|🍔{🥬,🍅,🧅×4,🧀×2,🥓×4}|js}, + ); +}); diff --git a/src/discounts-lists/Discount.re b/src/discounts-lists/Discount.re new file mode 100644 index 00000000..cd6efb18 --- /dev/null +++ b/src/discounts-lists/Discount.re @@ -0,0 +1,50 @@ +/** Buy n burgers, get n/2 burgers free */ +let getFreeBurgers = (items: list(Item.t)) => { + let prices = + items + |> List.filter_map(item => + switch (item) { + | Item.Burger(burger) => Some(Item.Burger.toPrice(burger)) + | Sandwich(_) + | Hotdog => None + } + ); + + switch (prices) { + | [] + | [_] => None + | prices => + let result = + prices + |> List.sort((x, y) => - Float.compare(x, y)) + |> List.filteri((index, _) => index mod 2 == 1) + |> List.fold_left((+.), 0.0); + Some(result); + }; +}; + +// Buy 1+ burger with 1+ of every topping, get half off +let getHalfOff = (items: list(Item.t)) => { + let meetsCondition = + items + |> List.exists( + fun + | Item.Burger({lettuce: true, tomatoes: true, onions, cheese, bacon}) + when onions > 0 && cheese > 0 && bacon > 0 => + true + | Burger(_) + | Sandwich(_) + | Hotdog => false, + ); + + switch (meetsCondition) { + | false => None + | true => + let total = + items + |> ListLabels.fold_left(~init=0.0, ~f=(total, item) => + total +. Item.toPrice(item) + ); + Some(total /. 2.0); + }; +}; diff --git a/src/discounts-lists/DiscountTests.re b/src/discounts-lists/DiscountTests.re new file mode 100644 index 00000000..f1e85994 --- /dev/null +++ b/src/discounts-lists/DiscountTests.re @@ -0,0 +1,129 @@ +open Fest; + +module FreeBurger = { + let burger: Item.Burger.t = { + lettuce: false, + onions: 0, + cheese: 0, + tomatoes: false, + bacon: 0, + }; + + test("0 burgers, no discount", () => + expect + |> equal( + Discount.getFreeBurgers([ + Hotdog, + Sandwich(Ham), + Sandwich(Turducken), + ]), + None, + ) + ); + + test("1 burger, no discount", () => + expect + |> equal( + Discount.getFreeBurgers([Hotdog, Sandwich(Ham), Burger(burger)]), + None, + ) + ); + + test("2 burgers of same price, discount", () => + expect + |> equal( + Discount.getFreeBurgers([ + Hotdog, + Burger(burger), + Sandwich(Ham), + Burger(burger), + ]), + Some(15.), + ) + ); + + test("2 burgers of different price, discount of cheaper one", () => + expect + |> equal( + Discount.getFreeBurgers([ + Hotdog, + Burger({...burger, tomatoes: true}), // 15.05 + Sandwich(Ham), + Burger({...burger, bacon: 2}) // 16.00 + ]), + Some(15.05), + ) + ); + + test("3 burgers of different price, return Some(15.15)", () => + expect + |> equal( + Discount.getFreeBurgers([ + Burger(burger), // 15 + Hotdog, + Burger({...burger, tomatoes: true, cheese: 1}), // 15.15 + Sandwich(Ham), + Burger({...burger, bacon: 2}) // 16.00 + ]), + Some(15.15), + ) + ); + + test("7 burgers, return Some(46.75)", () => + expect + |> equal( + Discount.getFreeBurgers([ + Burger(burger), // 15 + Hotdog, + Burger({...burger, cheese: 5}), // 15.50 + Sandwich(Unicorn), + Burger({...burger, bacon: 4}), // 17.00 + Burger({...burger, tomatoes: true, cheese: 1}), // 15.15 + Sandwich(Ham), + Burger({...burger, bacon: 2}), // 16.00 + Burger({...burger, onions: 6}), // 16.20 + Sandwich(Portabello), + Burger({...burger, tomatoes: true}) // 15.05 + ]), + Some(46.75), + ) + ); +}; + +module HalfOff = { + test("No burger has 1+ of every topping, return None", () => + expect + |> equal( + Discount.getHalfOff([ + Hotdog, + Sandwich(Portabello), + Burger({ + lettuce: true, + tomatoes: true, + cheese: 1, + onions: 1, + bacon: 0, + }), + ]), + None, + ) + ); + + test("One burger has 1+ of every topping, return Some(15.675)", () => + expect + |> equal( + Discount.getHalfOff([ + Hotdog, + Sandwich(Portabello), + Burger({ + lettuce: true, + tomatoes: true, + cheese: 1, + onions: 1, + bacon: 2, + }), + ]), + Some(15.675), + ) + ); +}; diff --git a/src/discounts-lists/Format.re b/src/discounts-lists/Format.re new file mode 100644 index 00000000..3dfd5ce6 --- /dev/null +++ b/src/discounts-lists/Format.re @@ -0,0 +1 @@ +let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string; diff --git a/src/discounts-lists/Index.re b/src/discounts-lists/Index.re new file mode 100644 index 00000000..a90bdef5 --- /dev/null +++ b/src/discounts-lists/Index.re @@ -0,0 +1,30 @@ +module App = { + let items: Order.t = [ + Sandwich(Portabello), + Sandwich(Unicorn), + Sandwich(Ham), + Sandwich(Turducken), + Hotdog, + Burger({lettuce: true, tomatoes: true, onions: 3, cheese: 2, bacon: 6}), + Burger({lettuce: false, tomatoes: false, onions: 0, cheese: 0, bacon: 0}), + Burger({lettuce: true, tomatoes: false, onions: 1, cheese: 1, bacon: 1}), + Burger({lettuce: false, tomatoes: false, onions: 1, cheese: 0, bacon: 0}), + Burger({lettuce: false, tomatoes: false, onions: 0, cheese: 1, bacon: 0}), + ]; + + [@react.component] + let make = () => +
    +

    {React.string("Order confirmation")}

    + +
    ; +}; + +let node = ReactDOM.querySelector("#root"); +switch (node) { +| None => + Js.Console.error("Failed to start React: couldn't find the #root element") +| Some(root) => + let root = ReactDOM.Client.createRoot(root); + ReactDOM.Client.render(root, ); +}; diff --git a/src/discounts-lists/Item.re b/src/discounts-lists/Item.re new file mode 100644 index 00000000..64f381c8 --- /dev/null +++ b/src/discounts-lists/Item.re @@ -0,0 +1,99 @@ +module Burger = { + type t = { + lettuce: bool, + onions: int, + cheese: int, + tomatoes: bool, + bacon: int, + }; + + let toEmoji = t => { + let multiple = (emoji, count) => + switch (count) { + | 0 => "" + | 1 => emoji + | count => Printf.sprintf({js|%s×%d|js}, emoji, count) + }; + + switch (t) { + | {lettuce: false, onions: 0, cheese: 0, tomatoes: false, bacon: 0} => {js|🍔|js} + | {lettuce, onions, cheese, tomatoes, bacon} => + let toppingsCount = + (lettuce ? 1 : 0) + (tomatoes ? 1 : 0) + onions + cheese + bacon; + + Printf.sprintf( + {js|🍔%s{%s}|js}, + toppingsCount > 12 ? {js|🥣|js} : "", + [| + lettuce ? {js|🥬|js} : "", + tomatoes ? {js|🍅|js} : "", + multiple({js|🧅|js}, onions), + multiple({js|🧀|js}, cheese), + multiple({js|🥓|js}, bacon), + |] + |> Js.Array.filter(~f=str => str != "") + |> Js.Array.join(~sep=","), + ); + }; + }; + + let toPrice = ({onions, cheese, tomatoes, bacon, lettuce: _}) => { + let toppingCost = (quantity, cost) => float_of_int(quantity) *. cost; + + 15. // base cost + +. toppingCost(onions, 0.2) + +. toppingCost(cheese, 0.1) + +. (tomatoes ? 0.05 : 0.0) + +. toppingCost(bacon, 0.5); + }; +}; + +module Sandwich = { + type t = + | Portabello + | Ham + | Unicorn + | Turducken; + + let toPrice = (~date: Js.Date.t, t) => { + let day = date |> Js.Date.getDay |> int_of_float; + + switch (t) { + | Portabello + | Ham => 10. + | Unicorn => 80. + | Turducken when day == 2 => 10. + | Turducken => 20. + }; + }; + + let toEmoji = t => + Printf.sprintf( + {js|🥪(%s)|js}, + switch (t) { + | Portabello => {js|🍄|js} + | Ham => {js|🐷|js} + | Unicorn => {js|🦄|js} + | Turducken => {js|🦃🦆🐓|js} + }, + ); +}; + +type t = + | Sandwich(Sandwich.t) + | Burger(Burger.t) + | Hotdog; + +let toPrice = t => { + switch (t) { + | Sandwich(sandwich) => Sandwich.toPrice(sandwich, ~date=Js.Date.make()) + | Burger(burger) => Burger.toPrice(burger) + | Hotdog => 5. + }; +}; + +let toEmoji = + fun + | Hotdog => {js|🌭|js} + | Burger(burger) => Burger.toEmoji(burger) + | Sandwich(sandwich) => Sandwich.toEmoji(sandwich); diff --git a/src/discounts-lists/ListSafe.re b/src/discounts-lists/ListSafe.re new file mode 100644 index 00000000..6eba66a7 --- /dev/null +++ b/src/discounts-lists/ListSafe.re @@ -0,0 +1,2 @@ +/** Return the nth element encased in Some; if it doesn't exist, return None */ +let nth = (n, list) => n < 0 ? None : List.nth_opt(list, n); diff --git a/src/discounts-lists/Order.re b/src/discounts-lists/Order.re new file mode 100644 index 00000000..40627477 --- /dev/null +++ b/src/discounts-lists/Order.re @@ -0,0 +1,39 @@ +type t = list(Item.t); + +module OrderItem = { + [@mel.module "./order-item.module.css"] + external css: Js.t({..}) = "default"; + + [@react.component] + let make = (~item: Item.t) => + + {item |> Item.toEmoji |> React.string} + {item |> Item.toPrice |> Format.currency} + ; +}; + +[@mel.module "./order.module.css"] external css: Js.t({..}) = "default"; + +[@react.component] +let make = (~items: t) => { + let total = + items + |> ListLabels.fold_left(~init=0., ~f=(acc, order) => + acc +. Item.toPrice(order) + ); + + + + {items + |> List.mapi((index, item) => + + ) + |> Stdlib.Array.of_list + |> React.array} + + + + + +
    {React.string("Total")} {total |> Format.currency}
    ; +}; diff --git a/src/discounts-lists/SandwichTests.re b/src/discounts-lists/SandwichTests.re new file mode 100644 index 00000000..abaac03c --- /dev/null +++ b/src/discounts-lists/SandwichTests.re @@ -0,0 +1,43 @@ +open Fest; + +test("Item.Sandwich.toEmoji", () => { + expect + |> deepEqual( + [|Portabello, Ham, Unicorn, Turducken|] + |> Js.Array.map(~f=Item.Sandwich.toEmoji), + [| + {js|🥪(🍄)|js}, + {js|🥪(🐷)|js}, + {js|🥪(🦄)|js}, + {js|🥪(🦃🦆🐓)|js}, + |], + ) +}); + +test("Item.Sandwich.toPrice", () => { + // 14 Feb 2024 is a Wednesday + let date = Js.Date.makeWithYMD(~year=2024., ~month=1., ~date=14.); + + expect + |> deepEqual( + [|Portabello, Ham, Unicorn, Turducken|] + |> Js.Array.map(~f=Item.Sandwich.toPrice(~date)), + [|10., 10., 80., 20.|], + ); +}); + +test("Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays", () => { + // Make an array of all dates in a single week; 1 Jan 2024 is a Monday + let dates = + [|1., 2., 3., 4., 5., 6., 7.|] + |> Js.Array.map(~f=date => + Js.Date.makeWithYMD(~year=2024., ~month=0., ~date) + ); + + expect + |> deepEqual( + dates + |> Js.Array.map(~f=date => Item.Sandwich.toPrice(Turducken, ~date)), + [|20., 10., 20., 20., 20., 20., 20.|], + ); +}); diff --git a/src/discounts-lists/dune b/src/discounts-lists/dune new file mode 100644 index 00000000..936ded2d --- /dev/null +++ b/src/discounts-lists/dune @@ -0,0 +1,13 @@ +(melange.emit + (target output) + (libraries reason-react melange-fest) + (preprocess + (pps melange.ppx reason-react-ppx)) + (module_systems + (es6 mjs)) + (runtime_deps + (glob_files *.css))) + +(cram + (deps + (alias melange))) diff --git a/src/discounts-lists/index.html b/src/discounts-lists/index.html new file mode 100644 index 00000000..7b38ec39 --- /dev/null +++ b/src/discounts-lists/index.html @@ -0,0 +1,12 @@ + + + + + + Melange for React Devs + + + +
    + + diff --git a/src/discounts-lists/order-item.module.css b/src/discounts-lists/order-item.module.css new file mode 100644 index 00000000..c90296f2 --- /dev/null +++ b/src/discounts-lists/order-item.module.css @@ -0,0 +1,11 @@ +.item { + border-top: 1px solid lightgray; +} + +.emoji { + font-size: 2em; +} + +.price { + text-align: right; +} diff --git a/src/discounts-lists/order.module.css b/src/discounts-lists/order.module.css new file mode 100644 index 00000000..0d6b4d9c --- /dev/null +++ b/src/discounts-lists/order.module.css @@ -0,0 +1,13 @@ +table.order { + border-collapse: collapse; +} + +table.order td { + padding: 0.5em; +} + +.total { + border-top: 1px solid gray; + font-weight: bold; + text-align: right; +} diff --git a/src/discounts-lists/tests.t b/src/discounts-lists/tests.t new file mode 100644 index 00000000..c6f0b064 --- /dev/null +++ b/src/discounts-lists/tests.t @@ -0,0 +1,99 @@ +Sandwich tests + $ node ./output/src/discounts-lists/SandwichTests.mjs | sed '/duration_ms/d' + TAP version 13 + # Subtest: Item.Sandwich.toEmoji + ok 1 - Item.Sandwich.toEmoji + --- + ... + # Subtest: Item.Sandwich.toPrice + ok 2 - Item.Sandwich.toPrice + --- + ... + # Subtest: Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays + ok 3 - Item.Sandwich.toPrice returns lower price for Turducken on Tuesdays + --- + ... + 1..3 + # tests 3 + # suites 0 + # pass 3 + # fail 0 + # cancelled 0 + # skipped 0 + # todo 0 + +Burger tests + $ node ./output/src/discounts-lists/BurgerTests.mjs | sed '/duration_ms/d' + TAP version 13 + # Subtest: A fully-loaded burger + ok 1 - A fully-loaded burger + --- + ... + # Subtest: Burger with 0 of onions, cheese, or bacon doesn't show those emoji + ok 2 - Burger with 0 of onions, cheese, or bacon doesn't show those emoji + --- + ... + # Subtest: Burger with 1 of onions, cheese, or bacon should show just the emoji without × + ok 3 - Burger with 1 of onions, cheese, or bacon should show just the emoji without × + --- + ... + # Subtest: Burger with 2 or more of onions, cheese, or bacon should show × + ok 4 - Burger with 2 or more of onions, cheese, or bacon should show × + --- + ... + # Subtest: Burger with more than 12 toppings should also show bowl emoji + ok 5 - Burger with more than 12 toppings should also show bowl emoji + --- + ... + 1..5 + # tests 5 + # suites 0 + # pass 5 + # fail 0 + # cancelled 0 + # skipped 0 + # todo 0 + +Discount tests + $ node ./output/src/discounts-lists/DiscountTests.mjs | sed '/duration_ms/d' + TAP version 13 + # Subtest: 0 burgers, no discount + ok 1 - 0 burgers, no discount + --- + ... + # Subtest: 1 burger, no discount + ok 2 - 1 burger, no discount + --- + ... + # Subtest: 2 burgers of same price, discount + ok 3 - 2 burgers of same price, discount + --- + ... + # Subtest: 2 burgers of different price, discount of cheaper one + ok 4 - 2 burgers of different price, discount of cheaper one + --- + ... + # Subtest: 3 burgers of different price, return Some(15.15) + ok 5 - 3 burgers of different price, return Some(15.15) + --- + ... + # Subtest: 7 burgers, return Some(46.75) + ok 6 - 7 burgers, return Some(46.75) + --- + ... + # Subtest: No burger has 1+ of every topping, return None + ok 7 - No burger has 1+ of every topping, return None + --- + ... + # Subtest: One burger has 1+ of every topping, return Some(15.675) + ok 8 - One burger has 1+ of every topping, return Some(15.675) + --- + ... + 1..8 + # tests 8 + # suites 0 + # pass 8 + # fail 0 + # cancelled 0 + # skipped 0 + # todo 0 diff --git a/vite.config.mjs b/vite.config.mjs index f8b304a6..a864e04d 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -28,6 +28,7 @@ export default defineConfig({ 'sandwich-tests': resolve(__dirname, 'src/sandwich-tests/index.html'), 'cram-tests': resolve(__dirname, 'src/cram-tests/index.html'), 'burger-discounts': resolve(__dirname, 'src/burger-discounts/index.html'), + 'discounts-lists': resolve(__dirname, 'src/discounts-lists/index.html'), }, }, },