From 2d8d075580042e3a595d3fb2a37483dbdb7151d5 Mon Sep 17 00:00:00 2001 From: Jie Date: Mon, 25 Sep 2023 21:23:32 +0900 Subject: [PATCH] Add practice exercise `ledger` (#1367) * Add practice exercise `ledger` * Format config file * Fix bad solution * type for locale --- config.json | 19 +++ .../practice/ledger/.docs/instructions.md | 14 ++ exercises/practice/ledger/.formatter.exs | 4 + exercises/practice/ledger/.gitignore | 26 +++ exercises/practice/ledger/.meta/config.json | 17 ++ exercises/practice/ledger/.meta/example.ex | 127 +++++++++++++++ exercises/practice/ledger/.meta/tests.toml | 43 +++++ exercises/practice/ledger/lib/ledger.ex | 104 ++++++++++++ exercises/practice/ledger/mix.exs | 28 ++++ .../practice/ledger/test/ledger_test.exs | 153 ++++++++++++++++++ .../practice/ledger/test/test_helper.exs | 2 + 11 files changed, 537 insertions(+) create mode 100644 exercises/practice/ledger/.docs/instructions.md create mode 100644 exercises/practice/ledger/.formatter.exs create mode 100644 exercises/practice/ledger/.gitignore create mode 100644 exercises/practice/ledger/.meta/config.json create mode 100644 exercises/practice/ledger/.meta/example.ex create mode 100644 exercises/practice/ledger/.meta/tests.toml create mode 100644 exercises/practice/ledger/lib/ledger.ex create mode 100644 exercises/practice/ledger/mix.exs create mode 100644 exercises/practice/ledger/test/ledger_test.exs create mode 100644 exercises/practice/ledger/test/test_helper.exs diff --git a/config.json b/config.json index 67bdc67cf0..1200999e2f 100644 --- a/config.json +++ b/config.json @@ -2314,6 +2314,25 @@ "practices": [], "difficulty": 6 }, + { + "slug": "ledger", + "name": "Ledger", + "uuid": "a3134be3-ac2f-4612-9cd2-4f2a6a4de48f", + "prerequisites": [ + "if", + "lists", + "atoms", + "strings", + "maps", + "dates-and-time", + "multiple-clause-functions", + "pattern-matching" + ], + "practices": [ + "multiple-clause-functions" + ], + "difficulty": 6 + }, { "slug": "list-ops", "name": "List Ops", diff --git a/exercises/practice/ledger/.docs/instructions.md b/exercises/practice/ledger/.docs/instructions.md new file mode 100644 index 0000000000..a53e5c15e3 --- /dev/null +++ b/exercises/practice/ledger/.docs/instructions.md @@ -0,0 +1,14 @@ +# Instructions + +Refactor a ledger printer. + +The ledger exercise is a refactoring exercise. +There is code that prints a nicely formatted ledger, given a locale (American or Dutch) and a currency (US dollar or euro). +The code however is rather badly written, though (somewhat surprisingly) it consistently passes the test suite. + +Rewrite this code. +Remember that in refactoring the trick is to make small steps that keep the tests passing. +That way you can always quickly go back to a working version. +Version control tools like git can help here as well. + +Please keep a log of what changes you've made and make a comment on the exercise containing that log, this will help reviewers. diff --git a/exercises/practice/ledger/.formatter.exs b/exercises/practice/ledger/.formatter.exs new file mode 100644 index 0000000000..d2cda26edd --- /dev/null +++ b/exercises/practice/ledger/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/exercises/practice/ledger/.gitignore b/exercises/practice/ledger/.gitignore new file mode 100644 index 0000000000..1d98798971 --- /dev/null +++ b/exercises/practice/ledger/.gitignore @@ -0,0 +1,26 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +ledger-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/exercises/practice/ledger/.meta/config.json b/exercises/practice/ledger/.meta/config.json new file mode 100644 index 0000000000..aa18f73d66 --- /dev/null +++ b/exercises/practice/ledger/.meta/config.json @@ -0,0 +1,17 @@ +{ + "authors": [ + "jiegillet" + ], + "files": { + "solution": [ + "lib/ledger.ex" + ], + "test": [ + "test/ledger_test.exs" + ], + "example": [ + ".meta/example.ex" + ] + }, + "blurb": "Refactor a ledger printer." +} diff --git a/exercises/practice/ledger/.meta/example.ex b/exercises/practice/ledger/.meta/example.ex new file mode 100644 index 0000000000..4b4578f23b --- /dev/null +++ b/exercises/practice/ledger/.meta/example.ex @@ -0,0 +1,127 @@ +defmodule Ledger do + @doc """ + Format the given entries given a currency and locale + """ + @type currency :: :usd | :eur + @type locale :: :en_US | :nl_NL + @type entry :: %{amount_in_cents: integer(), date: Date.t(), description: String.t()} + + @spec format_entries(currency(), locale(), list(entry())) :: String.t() + def format_entries(currency, locale, entries) do + header = header(locale) + + entries = + entries + |> Enum.sort(&compare_entries/2) + |> Enum.map(fn entry -> format_entry(currency, locale, entry) end) + + Enum.join([header | entries], "\n") <> "\n" + end + + defp header(:en_US), do: "Date | Description | Change " + defp header(:nl_NL), do: "Datum | Omschrijving | Verandering " + + defp compare_entries(a, b) do + case Date.compare(a.date, b.date) do + :lt -> + true + + :gt -> + false + + :eq -> + cond do + a.description < b.description -> true + a.description > b.description -> false + true -> a.amount_in_cents <= b.amount_in_cents + end + end + end + + @description_width 25 + @amount_width 13 + defp format_entry(currency, locale, %{ + amount_in_cents: amount, + date: date, + description: description + }) do + date = format_date(date, locale) + + amount = + amount + |> format_amount(currency, locale) + |> String.pad_leading(@amount_width, " ") + + description = + if String.length(description) > @description_width do + String.slice(description, 0, @description_width - 3) <> "..." + else + String.pad_trailing(description, @description_width, " ") + end + + Enum.join([date, description, amount], " | ") + end + + defp format_date(date, :en_US) do + year = date.year + month = date.month |> to_string() |> String.pad_leading(2, "0") + day = date.day |> to_string() |> String.pad_leading(2, "0") + Enum.join([month, day, year], "/") + end + + defp format_date(date, :nl_NL) do + year = date.year + month = date.month |> to_string() |> String.pad_leading(2, "0") + day = date.day |> to_string() |> String.pad_leading(2, "0") + Enum.join([day, month, year], "-") + end + + defp format_amount(amount, currency, :en_US) do + currency = format_currency(currency) + number = format_number(abs(amount), ".", ",") + + if amount >= 0 do + " #{currency}#{number} " + else + "(#{currency}#{number})" + end + end + + defp format_amount(amount, currency, :nl_NL) do + currency = format_currency(currency) + number = format_number(abs(amount), ",", ".") + + if amount >= 0 do + "#{currency} #{number} " + else + "#{currency} -#{number} " + end + end + + defp format_currency(:usd), do: "$" + defp format_currency(:eur), do: "€" + + defp format_number(number, decimal_separator, thousand_separator) do + decimal = number |> rem(100) |> to_string() |> String.pad_leading(2, "0") + whole = number |> div(100) |> to_string() |> chunk() |> Enum.join(thousand_separator) + whole <> decimal_separator <> decimal + end + + defp chunk(number) do + case String.length(number) do + 0 -> + [] + + n when n < 3 -> + [number] + + n when rem(n, 3) == 0 -> + {chunk, rest} = String.split_at(number, 3) + [chunk | chunk(rest)] + + n -> + {chunk, rest} = String.split_at(number, rem(n, 3)) + [chunk | chunk(rest)] + end + end +end diff --git a/exercises/practice/ledger/.meta/tests.toml b/exercises/practice/ledger/.meta/tests.toml new file mode 100644 index 0000000000..e71dfbfcaf --- /dev/null +++ b/exercises/practice/ledger/.meta/tests.toml @@ -0,0 +1,43 @@ +# This is an auto-generated file. +# +# Regenerating this file via `configlet sync` will: +# - Recreate every `description` key/value pair +# - Recreate every `reimplements` key/value pair, where they exist in problem-specifications +# - Remove any `include = true` key/value pair (an omitted `include` key implies inclusion) +# - Preserve any other key/value pair +# +# As user-added comments (using the # character) will be removed when this file +# is regenerated, comments can be added via a `comment` key. + +[d131ecae-a30e-436c-b8f3-858039a27234] +description = "empty ledger" + +[ce4618d2-9379-4eca-b207-9df1c4ec8aaa] +description = "one entry" + +[8d02e9cb-e6ee-4b77-9ce4-e5aec8eb5ccb] +description = "credit and debit" + +[502c4106-0371-4e7c-a7d8-9ce33f16ccb1] +description = "multiple entries on same date ordered by description" + +[29dd3659-6c2d-4380-94a8-6d96086e28e1] +description = "final order tie breaker is change" + +[9b9712a6-f779-4f5c-a759-af65615fcbb9] +description = "overlong description is truncated" + +[67318aad-af53-4f3d-aa19-1293b4d4c924] +description = "euros" + +[bdc499b6-51f5-4117-95f2-43cb6737208e] +description = "Dutch locale" + +[86591cd4-1379-4208-ae54-0ee2652b4670] +description = "Dutch locale and euros" + +[876bcec8-d7d7-4ba4-82bd-b836ac87c5d2] +description = "Dutch negative number with 3 digits before decimal point" + +[29670d1c-56be-492a-9c5e-427e4b766309] +description = "American negative number with 3 digits before decimal point" diff --git a/exercises/practice/ledger/lib/ledger.ex b/exercises/practice/ledger/lib/ledger.ex new file mode 100644 index 0000000000..5ab995b852 --- /dev/null +++ b/exercises/practice/ledger/lib/ledger.ex @@ -0,0 +1,104 @@ +defmodule Ledger do + @doc """ + Format the given entries given a currency and locale + """ + @type currency :: :usd | :eur + @type locale :: :en_US | :nl_NL + @type entry :: %{amount_in_cents: integer(), date: Date.t(), description: String.t()} + + @spec format_entries(currency(), locale(), list(entry())) :: String.t() + def format_entries(currency, locale, entries) do + header = + if locale == :en_US do + "Date | Description | Change \n" + else + "Datum | Omschrijving | Verandering \n" + end + + if entries == [] do + header + else + entries = + Enum.sort(entries, fn a, b -> + cond do + a.date.day < b.date.day -> true + a.date.day > b.date.day -> false + a.description < b.description -> true + a.description > b.description -> false + true -> a.amount_in_cents <= b.amount_in_cents + end + end) + |> Enum.map(fn entry -> format_entry(currency, locale, entry) end) + |> Enum.join("\n") + + header <> entries <> "\n" + end + end + + defp format_entry(currency, locale, entry) do + year = entry.date.year |> to_string() + month = entry.date.month |> to_string() |> String.pad_leading(2, "0") + day = entry.date.day |> to_string() |> String.pad_leading(2, "0") + + date = + if locale == :en_US do + month <> "/" <> day <> "/" <> year <> " " + else + day <> "-" <> month <> "-" <> year <> " " + end + + number = + if locale == :en_US do + decimal = + entry.amount_in_cents |> abs |> rem(100) |> to_string() |> String.pad_leading(2, "0") + + whole = + if abs(div(entry.amount_in_cents, 100)) < 1000 do + abs(div(entry.amount_in_cents, 100)) |> to_string() + else + to_string(div(abs(div(entry.amount_in_cents, 100)), 1000)) <> + "," <> to_string(rem(abs(div(entry.amount_in_cents, 100)), 1000)) + end + + whole <> "." <> decimal + else + decimal = + entry.amount_in_cents |> abs |> rem(100) |> to_string() |> String.pad_leading(2, "0") + + whole = + if abs(div(entry.amount_in_cents, 100)) < 1000 do + abs(div(entry.amount_in_cents, 100)) |> to_string() + else + to_string(div(abs(div(entry.amount_in_cents, 100)), 1000)) <> + "." <> to_string(rem(abs(div(entry.amount_in_cents, 100)), 1000)) + end + + whole <> "," <> decimal + end + + amount = + if entry.amount_in_cents >= 0 do + if locale == :en_US do + " #{if(currency == :eur, do: "€", else: "$")}#{number} " + else + " #{if(currency == :eur, do: "€", else: "$")} #{number} " + end + else + if locale == :en_US do + " (#{if(currency == :eur, do: "€", else: "$")}#{number})" + else + " #{if(currency == :eur, do: "€", else: "$")} -#{number} " + end + end + |> String.pad_leading(14, " ") + + description = + if entry.description |> String.length() > 26 do + " " <> String.slice(entry.description, 0, 22) <> "..." + else + " " <> String.pad_trailing(entry.description, 25, " ") + end + + date <> "|" <> description <> " |" <> amount + end +end diff --git a/exercises/practice/ledger/mix.exs b/exercises/practice/ledger/mix.exs new file mode 100644 index 0000000000..ddbc1da0a9 --- /dev/null +++ b/exercises/practice/ledger/mix.exs @@ -0,0 +1,28 @@ +defmodule Ledger.MixProject do + use Mix.Project + + def project do + [ + app: :ledger, + version: "0.1.0", + # elixir: "~> 1.10", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + # {:dep_from_hexpm, "~> 0.3.0"}, + # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + ] + end +end diff --git a/exercises/practice/ledger/test/ledger_test.exs b/exercises/practice/ledger/test/ledger_test.exs new file mode 100644 index 0000000000..d4138d5828 --- /dev/null +++ b/exercises/practice/ledger/test/ledger_test.exs @@ -0,0 +1,153 @@ +defmodule LedgerTest do + use ExUnit.Case + + # @tag :pending + test "empty ledger" do + assert Ledger.format_entries(:usd, :en_US, []) == + """ + Date | Description | Change\s\s\s\s\s\s\s + """ + end + + @tag :pending + test "one entry" do + entries = [ + %{amount_in_cents: -1000, date: ~D[2015-01-01], description: "Buy present"} + ] + + assert Ledger.format_entries(:usd, :en_US, entries) == + """ + Date | Description | Change\s\s\s\s\s\s\s + 01/01/2015 | Buy present | ($10.00) + """ + end + + @tag :pending + test "credit and debit" do + entries = [ + %{amount_in_cents: 1000, date: ~D[2015-01-02], description: "Get present"}, + %{amount_in_cents: -1000, date: ~D[2015-01-01], description: "Buy present"} + ] + + assert Ledger.format_entries(:usd, :en_US, entries) == + """ + Date | Description | Change\s\s\s\s\s\s\s + 01/01/2015 | Buy present | ($10.00) + 01/02/2015 | Get present | $10.00\s + """ + end + + @tag :pending + test "multiple entries on same date ordered by description" do + entries = [ + %{amount_in_cents: 1000, date: ~D[2015-01-02], description: "Get present"}, + %{amount_in_cents: -1000, date: ~D[2015-01-01], description: "Buy present"} + ] + + assert Ledger.format_entries(:usd, :en_US, entries) == + """ + Date | Description | Change\s\s\s\s\s\s\s + 01/01/2015 | Buy present | ($10.00) + 01/02/2015 | Get present | $10.00\s + """ + end + + @tag :pending + test "final order tie breaker is change" do + entries = [ + %{amount_in_cents: 0, date: ~D[2015-01-01], description: "Something"}, + %{amount_in_cents: -1, date: ~D[2015-01-01], description: "Something"}, + %{amount_in_cents: 1, date: ~D[2015-01-01], description: "Something"} + ] + + assert Ledger.format_entries(:usd, :en_US, entries) == + """ + Date | Description | Change\s\s\s\s\s\s\s + 01/01/2015 | Something | ($0.01) + 01/01/2015 | Something | $0.00\s + 01/01/2015 | Something | $0.01\s + """ + end + + @tag :pending + test "overlong description is truncated" do + entries = [ + %{ + amount_in_cents: -123_456, + date: ~D[2015-01-01], + description: "Freude schoner Gotterfunken" + } + ] + + assert Ledger.format_entries(:usd, :en_US, entries) == + """ + Date | Description | Change\s\s\s\s\s\s\s + 01/01/2015 | Freude schoner Gotterf... | ($1,234.56) + """ + end + + @tag :pending + test "euros" do + entries = [ + %{amount_in_cents: -1000, date: ~D[2015-01-01], description: "Buy present"} + ] + + assert Ledger.format_entries(:eur, :en_US, entries) == + """ + Date | Description | Change\s\s\s\s\s\s\s + 01/01/2015 | Buy present | (€10.00) + """ + end + + @tag :pending + test "Dutch locale" do + entries = [ + %{amount_in_cents: 123_456, date: ~D[2015-03-12], description: "Buy present"} + ] + + assert Ledger.format_entries(:usd, :nl_NL, entries) == + """ + Datum | Omschrijving | Verandering\s\s + 12-03-2015 | Buy present | $ 1.234,56\s + """ + end + + @tag :pending + test "Dutch locale and euros" do + entries = [ + %{amount_in_cents: 123_456, date: ~D[2015-03-12], description: "Buy present"} + ] + + assert Ledger.format_entries(:eur, :nl_NL, entries) == + """ + Datum | Omschrijving | Verandering\s\s + 12-03-2015 | Buy present | € 1.234,56\s + """ + end + + @tag :pending + test "Dutch negative number with 3 digits before decimal point" do + entries = [ + %{amount_in_cents: -12345, date: ~D[2015-03-12], description: "Buy present"} + ] + + assert Ledger.format_entries(:usd, :nl_NL, entries) == + """ + Datum | Omschrijving | Verandering\s\s + 12-03-2015 | Buy present | $ -123,45\s + """ + end + + @tag :pending + test "American negative number with 3 digits before decimal point" do + entries = [ + %{amount_in_cents: -12345, date: ~D[2015-03-12], description: "Buy present"} + ] + + assert Ledger.format_entries(:usd, :en_US, entries) == + """ + Date | Description | Change\s\s\s\s\s\s\s + 03/12/2015 | Buy present | ($123.45) + """ + end +end diff --git a/exercises/practice/ledger/test/test_helper.exs b/exercises/practice/ledger/test/test_helper.exs new file mode 100644 index 0000000000..35fc5bff82 --- /dev/null +++ b/exercises/practice/ledger/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +ExUnit.configure(exclude: :pending, trace: true)