diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index e5cc8cac..13066344 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -37,6 +37,7 @@ export default defineConfig({ { text: 'Better Burgers', link: '/better-burgers/' }, { text: 'Sandwich Tests', link: '/sandwich-tests/' }, { text: 'Cram Tests', link: '/cram-tests/' }, + { text: 'Burger Discounts', link: '/burger-discounts/' }, ] } ], diff --git a/docs/burger-discounts/Discount.re b/docs/burger-discounts/Discount.re new file mode 100644 index 00000000..1ef57bc0 --- /dev/null +++ b/docs/burger-discounts/Discount.re @@ -0,0 +1,252 @@ +let _ = + (items: array(Item.t)) => { + let burgers = + items + // #region type-annotate-argument + |> Js.Array.filter(~f=(item: Item.t) => + switch (item) { + | Burger(_) => true + | Sandwich(_) + | Hotdog => false + } + ); + // #endregion type-annotate-argument + ignore(burgers); + }; + +let _ = + (items: array(Item.t)) => { + let burgers = + items + // #region full-name-constructors + |> Js.Array.filter(~f=item => + switch (item) { + | Item.Burger(_) => true + | Item.Sandwich(_) + | Item.Hotdog => false + } + ); + // #endregion full-name-constructors + ignore(burgers); + }; + +let _ = + (items: array(Item.t)) => { + let burgers = + items + // #region full-name-constructor + |> Js.Array.filter(~f=item => + switch (item) { + | Item.Burger(_) => true + | Sandwich(_) + | Hotdog => false + } + ); + // #endregion full-name-constructor + ignore(burgers); + }; + +let _ = + (items: array(Item.t)) => { + let burgers = + items + // #region full-name-fun + |> Js.Array.filter( + ~f= + fun + | Item.Burger(_) => true + | Sandwich(_) + | Hotdog => false, + ); + // #endregion full-name-fun + ignore(burgers); + }; + +let _ = + (items: array(Item.t)) => { + // #region swap-function-order + items + |> Js.Array.filter(~f=item => + switch (item) { + | Item.Burger(_) => true + | Sandwich(_) + | Hotdog => false + } + ) + |> Js.Array.sortInPlaceWith(~f=(item1, item2) => + - compare(Item.toPrice(item1), Item.toPrice(item2)) + ) + // #endregion swap-function-order + |> ignore; + }; + +let _ = { + let burgers: array(Item.t) = [||]; + + // #region match-on-tuple + switch (burgers[0], burgers[1]) { + | (Burger(_), Burger(cheaperBurger)) => + Some(Item.Burger.toPrice(cheaperBurger)) + | _ => None + }; + // #endregion match-on-tuple +}; + +let _ = { + let burgers: array(Item.t) = [||]; + + // #region check-array-length + Js.Array.length(burgers) < 2 + ? None + : ( + switch (burgers[0], burgers[1]) { + | (Burger(_), Burger(cheaperBurger)) => + Some(Item.Burger.toPrice(cheaperBurger)) + | _ => None + } + ); + // #endregion check-array-length +}; + +let _ = { + let burgers: array(Item.t) = [||]; + + // #region catch-exception + switch (burgers[0], burgers[1]) { + | exception _ => None + | (Burger(_), Burger(cheaperBurger)) => + Some(Item.Burger.toPrice(cheaperBurger)) + | _ => None + }; + // #endregion catch-exception +}; + +module Array = { + // #region module-array + // 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)) + }; + // #endregion module-array +}; + +let _ = { + let burgers: array(Item.t) = [||]; + + // #region custom-array-get + switch (burgers[0], burgers[1]) { + | (Some(Burger(_)), Some(Burger(cheaperBurger))) => + Some(Item.Burger.toPrice(cheaperBurger)) + | _ => None + }; + // #endregion custom-array-get +}; + +// #region improved-get-free-burger +// Buy 2 burgers, get 1 free +let getFreeBurger = (items: array(Item.t)) => { + let burgers = + items + |> Js.Array.filter(~f=item => + switch (item) { + | Item.Burger(_) => true + | Sandwich(_) + | Hotdog => false + } + ) + |> Js.Array.map(~f=Item.toPrice) + |> Js.Array.sortInPlaceWith(~f=(x, y) => - compare(x, y)); + + switch (burgers[0], burgers[1]) { + | (Some(_), Some(cheaperPrice)) => Some(cheaperPrice) + | (None | Some(_), None | Some(_)) => None + }; +}; +// #endregion improved-get-free-burger + +// #region get-half-off-one +// Buy 1+ burger with 1 of every topping, get half off +let getHalfOff = (items: array(Item.t)) => { + let meetsCondition = + items + |> Js.Array.some( + ~f= + fun + | Item.Burger({ + lettuce: true, + tomatoes: true, + onions: 1, + cheese: 1, + bacon: 1, + }) => + true + | Burger(_) + | Sandwich(_) + | Hotdog => false, + ); + + switch (meetsCondition) { + | false => None + | true => + let total = + items + |> Js.Array.reduce(~init=0.0, ~f=(total, item) => + total +. Item.toPrice(item) + ); + Some(total /. 2.0); + }; +}; +// #endregion get-half-off-one +ignore(getHalfOff); + +// #region get-half-off +// Buy 1+ burger with 1+ of every topping, get half off +let getHalfOff = (items: array(Item.t)) => { + let meetsCondition = + items + |> Js.Array.some( + ~f= + 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 + |> Js.Array.reduce(~init=0.0, ~f=(total, item) => + total +. Item.toPrice(item) + ); + Some(total /. 2.0); + }; +}; +// #endregion get-half-off + +let _ = + name => { + // #region return-variant-at-end + let result = + name + |> String.split_on_char(' ') + |> List.map(String.map(c => c |> Char.code |> (+)(1) |> Char.chr)) + |> String.concat(" ") + |> String.cat("Hello, "); + + Some(result); + // #endregion return-variant-at-end + }; diff --git a/docs/burger-discounts/DiscountTests.re b/docs/burger-discounts/DiscountTests.re new file mode 100644 index 00000000..19bd2f1d --- /dev/null +++ b/docs/burger-discounts/DiscountTests.re @@ -0,0 +1,122 @@ +// #region first-three +open Fest; + +test("0 burgers, no discount", () => + expect + |> equal( + Discount.getFreeBurger([| + Hotdog, + Sandwich(Ham), + Sandwich(Turducken), + |]), + None, + ) +); + +test("1 burger, no discount", () => + expect + |> equal( + Discount.getFreeBurger([| + Hotdog, + Sandwich(Ham), + Burger({ + lettuce: false, + onions: 0, + cheese: 0, + tomatoes: false, + bacon: 0, + }), + |]), + None, + ) +); + +test("2 burgers of same price, discount", () => + expect + |> equal( + Discount.getFreeBurger([| + Hotdog, + Burger({ + lettuce: false, + onions: 0, + cheese: 0, + tomatoes: false, + bacon: 0, + }), + Sandwich(Ham), + Burger({ + lettuce: false, + onions: 0, + cheese: 0, + tomatoes: false, + bacon: 0, + }), + |]), + Some(15.), + ) +); +// #endregion first-three + +// #region burger-record +let burger: Item.Burger.t = { + lettuce: false, + onions: 0, + cheese: 0, + tomatoes: false, + bacon: 0, +}; +// #endregion burger-record + +// #region refactor-use-burger-record +test("1 burger, no discount", () => + expect + |> equal( + Discount.getFreeBurger([|Hotdog, Sandwich(Ham), Burger(burger)|]), + None, + ) +); + +test("2 burgers of same price, discount", () => + expect + |> equal( + Discount.getFreeBurger([| + Hotdog, + Burger(burger), + Sandwich(Ham), + Burger(burger), + |]), + Some(15.), + ) +); +// #endregion refactor-use-burger-record + +// #region different-price-test +test("2 burgers of different price, discount of cheaper one", () => + expect + |> equal( + Discount.getFreeBurger([| + Hotdog, + Burger({...burger, tomatoes: true}), // 15.05 + Sandwich(Ham), + Burger({...burger, bacon: 2}) // 16.00 + |]), + Some(15.05), + ) +); +// #endregion different-price-test + +// #region three-burgers +test("3 burgers of different price, return Some(15.15)", () => + expect + |> equal( + Discount.getFreeBurger([| + Burger(burger), // 15 + Hotdog, + Burger({...burger, tomatoes: true, cheese: 1}), // 15.15 + Sandwich(Ham), + Burger({...burger, bacon: 2}) // 16.00 + |]), + Some(15.15), + ) +); +// #endregion three-burgers diff --git a/docs/burger-discounts/Item.re b/docs/burger-discounts/Item.re new file mode 100644 index 00000000..d0e35325 --- /dev/null +++ b/docs/burger-discounts/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/burger-discounts/dune b/docs/burger-discounts/dune new file mode 100644 index 00000000..1f87da6c --- /dev/null +++ b/docs/burger-discounts/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/burger-discounts/index.md b/docs/burger-discounts/index.md new file mode 100644 index 00000000..77194b41 --- /dev/null +++ b/docs/burger-discounts/index.md @@ -0,0 +1,545 @@ +# Burger Discounts + +International Burger Day falls on Tuesday of next week, so Madame Jellobutter +decides to celebrate it by running the classic "buy 2 burgers get 1 free" +promotion. She clearly lays out the rules of this promotion: + +> Given an order comprising multiple items, find the price of the second most +> expensive burger. Subtract this number from the pre-tax total of the order. + +Add a new file `src/order-confirmation/Discount.re`: + +```reason +// Buy 2 burgers, get 1 free +let getFreeBurger = (items: array(Item.t)) => { + let burgers = + items + |> Js.Array.sortInPlaceWith(~f=(item1, item2) => + - compare(Item.toPrice(item1), Item.toPrice(item2)) + ) + |> Js.Array.filter(~f=item => + switch (item) { + | Burger(_) => true + | Sandwich(_) + | Hotdog => false + } + ); + + switch (burgers) { + | [|Burger(_), Burger(cheaperBurger)|] => + Some(Item.Burger.toPrice(cheaperBurger)) + | _ => None + }; +}; +``` + +## `Discount.getFreeBurger` function + +The new function `Discount.getFreeBurger` takes an array of items, finds the +second-most-expensive burger, and returns its price encased in `Some`. If there +is no second burger, it returns `None`. + +The functions used in `Discount.getFreeBurger` are: + +- [Js.Array.sortInPlaceWith](https://melange.re/v3.0.0/api/re/melange/Js/Array/index.html#val-sortInPlaceWith) + takes a callback function `~f` with type signature `('a, 'a) => int` (accept + two arguments of the same type and return `int`). It's used to sort the items + by price (highest to lowest). +- [Stdlib.compare](https://melange.re/v3.0.0/api/re/melange/Stdlib/#val-compare) + has type signature `('a, 'a) => int`. It's a polymorphic function capable of + comparing many types, including `bool`, `int`, `string`, etc. Note that you + can always just write `compare` instead of `Stdlib.compare`, because the + [`Stdlib` module is always opened by + default](https://melange.re/v3.0.0/api/re/melange/Stdlib/). +- [Js.Array.filter](https://melange.re/v3.0.0/api/re/melange/Js/Array/#val-filter) + takes a callback function `~f` with type signature `'a => bool`. It's used to + make sure all items in the `burgers` array are all burgers. + +At the moment, this code doesn't compile, and that's not the only thing wrong +with it, but we'll address each issue in due course. + +## Limitation of type inference + +You should see this compilation error: + +``` +File "src/order-confirmation/Discount.re", line 9, characters 11-17: +9 | | Burger(_) => true + ^^^^^^ +Error: Unbound constructor Burger +``` + +OCaml's type inference isn't able to figure out that the callback function +passed to `Js.Array.filter` receives an argument of `Item.t`, so it doesn't know +where the `Burger` constructor is coming from. But why does type inference work +in the callback to `Js.Array.sortInPlaceWith`? + +```reason +|> Js.Array.sortInPlaceWith(~f=(item1, item2) => + - compare(Item.toPrice(item1), Item.toPrice(item2)) + ) +``` + +The reason is that `Item.toPrice` is invoked inside this callback, and its type +signature is already known to be `Item.t => float`. So type inference can figure +out that `item1` and `item2` must both be of type `Item.t`, because +`Item.toPrice` can only accept an argument of type `Item.t`. + +## Type annotate callback argument + +There aren't any function invocations inside the callback to `Js.Array.filter`, +so we can help the compiler out by type annotating the `item` argument: + +<<< Discount.re#type-annotate-argument{1} + +## Use full name + +Explicit type annotation always works, but sometimes it's enough to just give +the compiler a hint. For example, we can use full names[^1] for the constructors +inside the switch expression: + +<<< Discount.re#full-name-constructors{3-5} + +Because `Item.Burger` is a constructor of the `Item.t` variant type, `item` must +have type `Item.t`. For the sake of convenience, you don't need to use full +names in the second branch of the switch expression---OCaml is smart enough to +infer which module the `Sandwich` and `Hotdog` constructors belong to. + +<<< Discount.re#full-name-constructor{4-5} + +By using the full name of the `Burger` constructor, we can now easily refactor +the callback function to use the `fun` syntax: + +<<< Discount.re#full-name-fun + +## Add new tests + +Add some tests in new file `src/order-confirmation/DiscountTests.re`: + +<<< DiscountTests.re#first-three + +To run these tests, add a new cram test to `src/order-confirmation/tests.t`: + +```cram +Discount tests + $ node ./output/src/order-confirmation/DiscountTests.mjs | sed '/duration_ms/d' +``` + +Run `npm run test:watch` to see that the unit tests pass, then run `npm promote` +to make the cram test pass. + +## Records are immutable + +It's unnecessary to fully write out every burger record in the tests. Define a +`burger` record at the very top of `DiscountTests`: + +<<< DiscountTests.re#burger-record + +Then refactor the second and third tests to use this record: + +<<< DiscountTests.re#refactor-use-burger-record{4,14,16} + +It's safe to reuse a single record this way because records are immutable. You +can pass a record to any function and never worry that its fields might be +changed by that function. + +## Record copy syntax + +Add a new test to `DiscountTests`: + +<<< DiscountTests.re#different-price-test + +Again, we're reusing the `burger` record, but this time, we use [record copy +syntax](https://reasonml.github.io/docs/en/record#updating-records-spreading) to +make copies of `burger` record that have slightly different field values. For +example, + +```reason +{...burger, tomatoes: true} +``` + +means to make a copy of `burger` but with `tomatoes` set to `true`. It's just a +shorter and more convenient way to write this: + +```reason +{ + lettuce: burger.lettuce, + onions: burger.onions, + cheese: burger.cheese, + bacon: burger.bacon, + tomatoes: true, +} +``` + +## Ignoring function return values + +Add another test to `DiscountTests` that checks whether `Discount.getFreeBurger` +modifies the array passed to it: + +```reason +test("Input array isn't changed", () => { + let items = [| + Item.Hotdog, + Burger({...burger, tomatoes: true}), + Sandwich(Ham), + Burger({...burger, bacon: 2}), + |]; + + Discount.getFreeBurger(items); + + expect + |> deepEqual( + items, + [| + Item.Hotdog, + Burger({...burger, tomatoes: true}), + Sandwich(Ham), + Burger({...burger, bacon: 2}), + |], + ); +}); +``` + +You'll get this compilation error: + +``` +File "src/order-confirmation/DiscountTests.re", line 65, characters 2-31: +65 | Discount.getFreeBurger(items); + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Error: This expression has type float option + but an expression was expected of type unit + because it is in the left-hand side of a sequence +``` + +When you call a function in OCaml, you have use its return value, unless the +return value is `()` (the [unit +value](https://reasonml.github.io/docs/en/overview#unit)). However, inside this +test, we are calling `Discount.getFreeBurger` to test its side effects, so the +return value isn't needed; as such, we can explicitly discard it by using +[Stdlib.ignore](https://melange.re/v3.0.0/api/re/melange/Stdlib/#val-ignore)[^2]: + +```reason +Discount.getFreeBurger(items) |> ignore; +``` + +## Runtime representation of variant + +After compilation succeeds, we find that the "Input array isn't changed" unit +test fails. Part of the output (cleaned up for readability) looks like this: + +```json +[ + 0, + { + TAG: 1, + _0: { + bacon: 0, + cheese: 0, + lettuce: false, + onions: 0, + tomatoes: true + } + }, + { + TAG: 0, + _0: 1 + }, + { + TAG: 1, + _0: { + bacon: 2, + cheese: 0, + lettuce: false, + onions: 0, + tomatoes: false + } + } +] +``` + +This is how Melange maps the original OCaml values to their JavaScript runtime +values shown by Node test runner: + +| OCaml source | JavaScript runtime | +|--------------|--------------------| +| `Item.Hotdog` | `0` | +| `Sandwich(Ham)` | `{TAG: 0, _0: 1}` | +| `Burger({...burger, tomatoes: true})` | `{TAG: 1, _0: {bacon: 0, cheese: 0, lettuce: false, onions: 0, tomatoes: true}}` | + +A variant constructor without arguments, like `Hotdog`, gets turned into an +integer. If the constructor has an argument, like `Sandwich(Ham)`, then it's +turned into a record where the `TAG` field is an integer and the `_0` field +contains the argument. Records, like the one encased in the `Burger` +constructor, are turned into JS objects. OCaml arrays, like the one that +contains all the items, are turned into JS arrays. + +::: warning + +Variant constructors in the runtime don't always have the `TAG` field. That +field only appears when there's more than one variant constructor with an +argument. See [Data types and runtime +representation](https://melange.re/v3.0.0/communicate-with-javascript.html#data-types-and-runtime-representation) +for more details. + +::: + +## Arrays are mutable + +The "Input array isn't changed" unit test fails because arrays in OCaml are +mutable (just as in JavaScript) and the `Discount.getFreeBurger` function +mutates its array argument. The easiest way to fix this is to swap the order of +`Js.Array.sortInPlaceWith` and `Js.Array.filter` invocations: + +<<< Discount.re#swap-function-order + +Although sorting still happens in-place, the array being sorted is a new one +created by `Js.Array.filter` (the array containing only burgers), not the +original input array. + +## Runtime representation of `option` + +We need to add one more test to check that `Discount.getFreeBurger` works when +there are more than two burgers: + +<<< DiscountTests.re#three-burgers + +This test fails, with the key part of the output being: + +```diff ++ Expected values to be strictly equal: ++ + actual - expected ++ ++ + undefined ++ - 15.15 +``` + +Recall that `Discount.getFreeBurger` has the return type `option(float)`. +This is how Melange maps `option(float)` values to the JavaScript runtime[^3]: + +| OCaml source | JavaScript runtime | +|--------------|--------------------| +| `None` | `undefined` | +| `Some(15.15)` | `15.15` | + +So Node test runner is basically telling you that `None` was returned, but +`Some(15.15)` was expected. + +## Pattern matching on arrays + +The test is failing because the current "success" pattern match only accounts +for a two-element array: + +```reason{2} +switch (burgers) { +| [|Burger(_), Burger(cheaperBurger)|] => + Some(Item.Burger.toPrice(cheaperBurger)) +| _ => None +``` + +OCaml [only allows you to pattern match on arrays of fixed +length](https://v2.ocaml.org/manual/patterns.html#sss:pat-array), so to fix +this, we must instead match on a tuple of the first and second elements of the +array: + +<<< Discount.re#match-on-tuple + +## Array access is unsafe + +The first and second tests now fail due to `index out of bounds` errors (since +they work on arrays of length 0 and 1, respectively). Array access in OCaml is +unsafe by default, so the simplest fix is to check the length of the array +before using the switch expression: + +<<< Discount.re#check-array-length{1-2} + +An alternative approach is to catch the exception that gets raised using an +`exception` branch inside the switch expression: + +<<< Discount.re#catch-exception{2} + +## `Array.get` array access function + +In OCaml, the array access operator `[]` is just a function call. That is, +`burger[0]` is completely equivalent to `Array.get(burger, 0)`. + +Since the [`Stdlib` module is opened by +default](https://melange.re/v3.0.0/api/re/melange/Stdlib/), the +[Stdlib.Array.get](https://melange.re/v3.0.0/api/re/melange/Stdlib/Array/#val-get) +function is used for `Array.get`, but it's possible to override this by defining +our own `Array` module. Add a new file `src/order-confirmation/Array.re`: + +<<< Discount.re#module-array + +This function returns `None` if the `index` is out of bounds; otherwise it +returns `Some(Stdlib.Array.get(array, index))`, i.e. the element at `index` +encased by `Some`. + +Introducing our own `Array` module triggers a new compilation error: + +``` +File "src/order-confirmation/Discount.re", line 18, characters 5-11: +18 | | (Burger(_), Burger(cheaperBurger)) => + ^^^^^^ +Error: This variant pattern is expected to have type Item.t option + There is no constructor Burger within type option +``` + +The "success" branch must now include `Some` in the pattern match: + +<<< Discount.re#custom-array-get{2} + +Note that we no longer need to check the array length or catch an exception. Our +new `Array.get` function is a safe function that returns `None` instead of +raising an exception. + +Your code should now compile and all unit tests should pass. If you haven't done +so already, run `npm run promote` to promote the latest test output to become +the expected test output inside `tests.t`. + +--- + +Nice, you've implemented the burger discount, and you also understand more about +arrays in OCaml. In the next chapter, you'll implement the same discount logic +using lists, which are a better fit for this problem. + +## Overview + +- Type inference is less effective inside functions that don't call other + functions. In those cases, you can give the compiler more information: + - Type annotate the function arguments + - Use the full name for value +- The `Stdlib` module is opened by default +- Records are immutable +- Use record copy syntax to make copies of records that have different values + for some fields +- OCaml doesn't allow you to ignore the return value of functions (unless the + value is `()`), so you can use `Stdlib.ignore` to explicitly discard return + values +- Runtime representations of common data types: + - Variant constructor without argument -> integer + - Variant constructor with argument -> JavaScript object + - Record -> JavaScript object + - Array -> JavaScript array + - `None` -> `undefined` + - `Some(value)` -> `value` +- Array facts: + - Arrays are mutable, just like in JavaScript + - You can pattern match on arrays of fixed length + - Array access is unsafe by default + - What looks like operator usage in `array[index]` is actually just a call to + `Array.get(array, index)` + - You can create your own `Array` module to override the behavior of + `Array.get` + +## Exercises + +1. `Discount.getFreeBurger` can be improved a bit. In particular, there's +no need to match on the `Burger` constructor when non-burger items have already +been filtered out. Refactor the function so that the "success" pattern match +looks like this: + +```reason +| (Some(_), Some(cheaperPrice)) => Some(cheaperPrice) +``` + +Also refactor the "failure" pattern match so there's no wildcard. + +::: details Hint + +Use [Js.Array.map](https://melange.re/v3.0.0/api/re/melange/Js/Array/#val-map) + +::: + + +::: details Solution + +<<< Discount.re#improved-get-free-burger + +::: + +2. Add new function `Discount.getHalfOff` that gives you a discount of +half off the entire meal if there’s at least one burger that has one of every +topping. + +::: details Hint + +Use [Js.Array.some](https://melange.re/v3.0.0/api/re/melange/Js/Array/#val-some) + +::: + +::: details Solution + +<<< Discount.re#get-half-off-one + +::: + +3. Update `Discount.getHalfOff` so that it returns a discount of one half +off the entire meal if there’s at least one burger that has **at least** one of +every topping. Also add a couple of tests for this function in `DiscountTests`. + +::: details Hint + +Use [when](https://reasonml.github.io/docs/en/pattern-matching#when) guard + +::: + +::: details Solution + +<<< Discount.re#get-half-off + +See +[DiscountTests.re](https://github.com/melange-re/melange-for-react-devs/blob/main/src/burger-discounts/DiscountTests.re) +to see how the tests are implemented. Note the use of a submodule to group the +`Discount.getHalfOff` tests together. + +::: + +4. What happens if you to try to rewrite `Some(String.length("foobar"))` +to `"foobar" |> String.length |> Some`? + +::: details Solution + +You'll get a compilation error: + +``` +Error This expression should not be a constructor, the expected type is int -> 'a +``` + +Variant constructors like `Some` are not functions, so they can't be used with +the pipe last (`|>`) operator. If you have have a long string of function +invocations but you need to return a variant at the end, consider using an extra +variable, e.g. + +<<< Discount.re#return-variant-at-end + +See [full example on Melange +Playground](https://melange.re/v3.0.0/playground/?language=Reason&code=bGV0IGNpcGhlckdyZWV0aW5nID0gbmFtZSA9PiB7CiAgc3dpdGNoIChTdHJpbmcudHJpbShuYW1lKSkgewogIHwgIiIgPT4gTm9uZQogIHwgbmFtZSA9PgogICAgbGV0IHJlc3VsdCA9CiAgICAgIG5hbWUKICAgICAgfD4gU3RyaW5nLnNwbGl0X29uX2NoYXIoJyAnKQogICAgICB8PiBMaXN0Lm1hcChTdHJpbmcubWFwKGMgPT4gYyB8PiBDaGFyLmNvZGUgfD4gKCspKDEpIHw%2BIENoYXIuY2hyKSkKICAgICAgfD4gU3RyaW5nLmNvbmNhdCgiICIpCiAgICAgIHw%2BIFN0cmluZy5jYXQoIkhlbGxvLCAiKTsKCiAgICBTb21lKHJlc3VsdCk7CiAgfTsKfTsKCkpzLmxvZyhjaXBoZXJHcmVldGluZygiIikpOwpKcy5sb2coY2lwaGVyR3JlZXRpbmcoIlhhdmllciBMZXJveSIpKTsK&live=off). + +::: + +----- + +View [source +code](https://github.com/melange-re/melange-for-react-devs/blob/main/src/burger-discounts/) +and [demo](https://react-book.melange.re/demo/src/burger-discounts/) for this chapter. + +----- + +[^1]: The official term for something like `Item.Burger` (module name followed + by value name) is [access + path](https://v2.ocaml.org/manual/names.html#sss:refer-named), but this term + isn't widely used. + +[^2]: Another valid way to discard the return value of a function is: + + ```reason + let _: option(float) = Discount.getFreeBurger(items); + ``` + + This works, but `ignore` is more explicit and therefore the recommended + approach. + +[^3]: Technically [`option` is a + variant](https://melange.re/v3.0.0/api/re/melange/Stdlib/Option/#type-t), + but Melange treats them as a special case---`option` values are never + represented as JS objects in the runtime. diff --git a/docs/cram-tests/Item.re b/docs/cram-tests/Item.re index d78735f9..d23d32a9 100644 --- a/docs/cram-tests/Item.re +++ b/docs/cram-tests/Item.re @@ -38,14 +38,4 @@ module Burger = { }; // #endregion to-emoji }; - - 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); - }; }; diff --git a/index.html b/index.html index 394a9482..05274c93 100644 --- a/index.html +++ b/index.html @@ -38,6 +38,9 @@

Melange for React Developers

  • Cram Tests
  • +
  • + Burger Discounts +
  • diff --git a/src/burger-discounts/Array.re b/src/burger-discounts/Array.re new file mode 100644 index 00000000..9582e9a9 --- /dev/null +++ b/src/burger-discounts/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/burger-discounts/BurgerTests.re b/src/burger-discounts/BurgerTests.re new file mode 100644 index 00000000..e720b427 --- /dev/null +++ b/src/burger-discounts/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/burger-discounts/Discount.re b/src/burger-discounts/Discount.re new file mode 100644 index 00000000..7962d592 --- /dev/null +++ b/src/burger-discounts/Discount.re @@ -0,0 +1,52 @@ +// Buy 2 burgers, get 1 free +let getFreeBurger = (items: array(Item.t)) => { + let burgers = + items + |> Js.Array.filter(~f=item => + switch (item) { + | Item.Burger(_) => true + | Sandwich(_) + | Hotdog => false + } + ) + |> Js.Array.map(~f=Item.toPrice) + |> Js.Array.sortInPlaceWith(~f=(x, y) => - compare(x, y)); + + switch (burgers[0], burgers[1]) { + | (Some(_), Some(cheaperPrice)) => Some(cheaperPrice) + | (None | Some(_), None | Some(_)) => None + }; +}; + +// Buy 1+ burger with 1+ of every topping, get half off +let getHalfOff = (items: array(Item.t)) => { + let meetsCondition = + items + |> Js.Array.some( + ~f= + 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 + |> Js.Array.reduce(~init=0.0, ~f=(total, item) => + total +. Item.toPrice(item) + ); + Some(total /. 2.0); + }; +}; diff --git a/src/burger-discounts/DiscountTests.re b/src/burger-discounts/DiscountTests.re new file mode 100644 index 00000000..01b46f05 --- /dev/null +++ b/src/burger-discounts/DiscountTests.re @@ -0,0 +1,131 @@ +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.getFreeBurger([| + Hotdog, + Sandwich(Ham), + Sandwich(Turducken), + |]), + None, + ) + ); + + test("1 burger, no discount", () => + expect + |> equal( + Discount.getFreeBurger([|Hotdog, Sandwich(Ham), Burger(burger)|]), + None, + ) + ); + + test("2 burgers of same price, discount", () => + expect + |> equal( + Discount.getFreeBurger([| + Hotdog, + Burger(burger), + Sandwich(Ham), + Burger(burger), + |]), + Some(15.), + ) + ); + + test("2 burgers of different price, discount of cheaper one", () => + expect + |> equal( + Discount.getFreeBurger([| + Hotdog, + Burger({...burger, tomatoes: true}), // 15.05 + Sandwich(Ham), + Burger({...burger, bacon: 2}) // 16.00 + |]), + Some(15.05), + ) + ); + + test("Input array isn't changed", () => { + let items = [| + Item.Hotdog, + Burger({...burger, tomatoes: true}), + Sandwich(Ham), + Burger({...burger, bacon: 2}), + |]; + + Discount.getFreeBurger(items) |> ignore; + + expect + |> deepEqual( + items, + [| + Item.Hotdog, + Burger({...burger, tomatoes: true}), + Sandwich(Ham), + Burger({...burger, bacon: 2}), + |], + ); + }); + + test("3 burgers of different price, return Some(15.15)", () => + expect + |> equal( + Discount.getFreeBurger([| + Burger(burger), // 15 + Hotdog, + Burger({...burger, tomatoes: true, cheese: 1}), // 15.15 + Sandwich(Ham), + Burger({...burger, bacon: 2}) // 16.00 + |]), + Some(15.15), + ) + ); +}; + +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/burger-discounts/Format.re b/src/burger-discounts/Format.re new file mode 100644 index 00000000..3dfd5ce6 --- /dev/null +++ b/src/burger-discounts/Format.re @@ -0,0 +1 @@ +let currency = value => value |> Js.Float.toFixed(~digits=2) |> React.string; diff --git a/src/burger-discounts/Index.re b/src/burger-discounts/Index.re new file mode 100644 index 00000000..ba375c92 --- /dev/null +++ b/src/burger-discounts/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/burger-discounts/Item.re b/src/burger-discounts/Item.re new file mode 100644 index 00000000..64f381c8 --- /dev/null +++ b/src/burger-discounts/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/burger-discounts/Order.re b/src/burger-discounts/Order.re new file mode 100644 index 00000000..92cf2d0b --- /dev/null +++ b/src/burger-discounts/Order.re @@ -0,0 +1,38 @@ +type t = array(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 + |> Js.Array.reduce(~init=0., ~f=(acc, order) => + acc +. Item.toPrice(order) + ); + + + + {items + |> Js.Array.mapi(~f=(item, index) => + + ) + |> React.array} + + + + + +
    {React.string("Total")} {total |> Format.currency}
    ; +}; diff --git a/src/burger-discounts/SandwichTests.re b/src/burger-discounts/SandwichTests.re new file mode 100644 index 00000000..abaac03c --- /dev/null +++ b/src/burger-discounts/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/burger-discounts/dune b/src/burger-discounts/dune new file mode 100644 index 00000000..936ded2d --- /dev/null +++ b/src/burger-discounts/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/burger-discounts/index.html b/src/burger-discounts/index.html new file mode 100644 index 00000000..9b5aaa9c --- /dev/null +++ b/src/burger-discounts/index.html @@ -0,0 +1,12 @@ + + + + + + Melange for React Devs + + + +
    + + diff --git a/src/burger-discounts/order-item.module.css b/src/burger-discounts/order-item.module.css new file mode 100644 index 00000000..c90296f2 --- /dev/null +++ b/src/burger-discounts/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/burger-discounts/order.module.css b/src/burger-discounts/order.module.css new file mode 100644 index 00000000..0d6b4d9c --- /dev/null +++ b/src/burger-discounts/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/burger-discounts/tests.t b/src/burger-discounts/tests.t new file mode 100644 index 00000000..16e340c6 --- /dev/null +++ b/src/burger-discounts/tests.t @@ -0,0 +1,99 @@ +Sandwich tests + $ node ./output/src/burger-discounts/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/burger-discounts/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/burger-discounts/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: Input array isn't changed + ok 5 - Input array isn't changed + --- + ... + # Subtest: 3 burgers of different price, return Some(15.15) + ok 6 - 3 burgers of different price, return Some(15.15) + --- + ... + # 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 f2845983..f8b304a6 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -27,6 +27,7 @@ export default defineConfig({ 'better-burgers': resolve(__dirname, 'src/better-burgers/index.html'), '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'), }, }, },