Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle invalid options #8

Merged
merged 11 commits into from
Jan 20, 2020
50 changes: 34 additions & 16 deletions lib/chopperbot/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ defmodule Chopperbot.Router do

post "/split" do
input = conn.body_params["text"]
response = Character.happy_talk() <> "\n\n" <> Split.run(input)
response = build_response(input)

body = %{
"response_type" => "in_channel",
Expand All @@ -34,21 +34,15 @@ defmodule Chopperbot.Router do
] = conn.params["events"]

response =
if text |> String.downcase() |> String.starts_with?("split") do
["", input] = text |> String.downcase() |> String.split("split ")
Character.happy_talk() <> "\n\n" <> Split.run(input)
else
[
"Now I can help you split the bill 💸! Just type `split` following by orders. For example...",
"",
"1️⃣",
"split alice 100 alice 250 bob 200 +vat +service",
"2️⃣",
"split alice 100 bob 200 +v",
"3️⃣",
"split alice 100 bob 200 share 100",
]
|> Enum.join("\n")
text
|> String.trim()
|> String.downcase()
|> case do
"split " <> input ->
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this way of matching 👍

build_response(input)

_ ->
build_line_suggestion_response()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice for returning help here.

end

Linex.Message.reply(response, reply_token)
Expand All @@ -61,4 +55,28 @@ defmodule Chopperbot.Router do
match _ do
send_resp(conn, 404, "not found")
end

defp build_response(input_text) do
case Split.run(input_text) do
{:ok, ok_msg} ->
Character.happy_talk() <> "\n\n" <> ok_msg

{:error, error_msg} ->
Character.confused_talk() <> "\n\n" <> error_msg
end
end

defp build_line_suggestion_response do
[
"Now I can help you split the bill 💸! Just type `split` following by orders. For example...",
"",
"1️⃣",
"split alice 100 alice 250 bob 200 +vat +service",
"2️⃣",
"split alice 100 bob 200 +v",
"3️⃣",
"split alice 100 bob 200 share 100"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, out of the scope, but maybe we can add the discount use case as well. (maybe later)

]
|> Enum.join("\n")
end
end
191 changes: 48 additions & 143 deletions lib/chopperbot/split.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,50 @@ defmodule Chopperbot.Split do

TODO:
[ ] add support for LINE format
[ ] add flexible discount ex. -10%
[ ] [Bug] String.split wrong on copy & paste the command in Slack
"""

@type orders :: list({String.t(), float() | integer()})
@type options :: list(String.t())
alias Chopperbot.Split.{Order, Parser}

@type orders :: list(Order.t())

@doc """
Process text input for /split to result

## Examples
iex> run("a 100 a 200 b 300 +v +s")
"a: 353.10 THB\\nb: 353.10 THB\\n---\\n*total: 706.20 THB*"
{:ok, "a: 353.10 THB\\nb: 353.10 THB\\n---\\n*total: 706.20 THB*"}

iex> run("a 1100 b 300 share 200 +s")
"a: 1,320.00 THB\\nb: 440.00 THB\\n---\\n*total: 1,760.00 THB*"
{:ok, "a: 1,320.00 THB\\nb: 440.00 THB\\n---\\n*total: 1,760.00 THB*"}

iex> run("a 1100 b 300 share 200 +invalid -haha")
{:error, "invalid options: +invalid, -haha"}

iex> run("a 1100 b 300 share five dollars")
{:error, "invalid inputs: five, dollars"}
"""
@spec run(String.t()) :: String.t()
@spec run(String.t()) :: {:ok, String.t()} | {:error, String.t()}
def run(text) do
%{orders: orders, options: options} = process_input(text)

orders
|> sum_orders_by_name()
|> split_share()
|> apply_options(options)
|> add_total()
|> format_slack_string()
case Parser.parse(text) do
{:ok, %{orders: orders, multiplier: multiplier}} ->
result =
orders
|> sum_orders_by_name()
|> split_share()
|> apply_multiplier(multiplier)
|> add_total()
|> format_slack_string()

{:ok, result}

{:error, :invalid_option, invalid_options} ->
error_msg = "invalid options: " <> Enum.join(invalid_options, ", ")
{:error, error_msg}

{:error, :invalid_input, invalid_inputs} ->
error_msg = "invalid inputs: " <> Enum.join(invalid_inputs, ", ")
{:error, error_msg}
end
end

@doc """
Expand Down Expand Up @@ -88,6 +106,21 @@ defmodule Chopperbot.Split do
end
end

@doc """
Multiply each order amount with the given multiplier.

## Examples
iex> apply_multiplier([{"a", 100}, {"b", 300}], 1.07)
[{"a", 107.0}, {"b", 321.0}]
"""
@spec apply_multiplier(orders(), float()) :: orders()
def apply_multiplier(orders, multiplier) do
Enum.map(orders, fn {name, amount} ->
new_amount = Float.round(amount * multiplier, 15)
{name, new_amount}
end)
end

@doc """
add _total amount to order

Expand All @@ -105,132 +138,4 @@ defmodule Chopperbot.Split do
|> Enum.map(fn {_name, amount} -> amount end)
|> Enum.sum()
end

@doc """
Apply each options to all orders

## Examples
iex> apply_options([{"a", 300}, {"b", 400}], ["+s"])
[{"a", 330.0}, {"b", 440.0}]
iex> apply_options([{"a", 100}, {"b", 200}], ["-20.5%"])
[{"a", 79.5}, {"b", 159.0}]
"""
@spec apply_options(orders(), options()) :: orders()
def apply_options(orders, [option | rest_options]) do
orders
|> Enum.map(fn {name, amount} ->
new_amount =
option
|> get_multiplier_from_option()
|> Kernel.*(amount)
|> rounding_floating_problem()

{name, new_amount}
end)
|> apply_options(rest_options)
end

def apply_options(orders, []), do: orders

defp get_multiplier_from_option(option) when option in ["+service", "+s"] do
get_multiplier_from_option("+10%")
end

defp get_multiplier_from_option(option) when option in ["+vat", "+v"] do
get_multiplier_from_option("+7%")
end

defp get_multiplier_from_option(option) do
regex = ~r/^(\+|-)(\d+|\d+[.]\d+)(%)$/
[^option, operator, number, "%"] = Regex.run(regex, option)
{float_number, ""} = Float.parse(number)

Kernel
|> apply(String.to_existing_atom(operator), [100, float_number])
|> Kernel./(100)
end

# FIXME: use the proper way to handle the float precision
defp rounding_floating_problem(float), do: round(float * 100) / 100

@doc """
process text into the correct orders & options.

## Examples
iex> process_input("a 100 a 200 b 300 +v +s")
%{orders: [{"a", 100.0}, {"a", 200.0}, {"b", 300.0}], options: ["+v", "+s"]}
"""
@spec process_input(String.t()) :: %{orders: orders(), options: options()}
def process_input(text) do
%{
orders: parse_orders(text),
options: parse_options(text)
}
end

@doc """
Extract options out of the text into the list.
will make all name lower case for the sake of comparison.

## Example
iex> parse_orders("turbo 10 kendo 200 +v +s")
[{"turbo", 10.0}, {"kendo", 200.0}]
iex> parse_orders("ant 200 pipe 100 share -30 +v +s")
[{"ant", 200.0}, {"pipe", 100.0}, {"share", -30.0}]
iex> parse_orders("Neo 310 neo 19 -5%")
[{"neo", 310.0}, {"neo", 19.0}]
iex> parse_orders("satoshi 10.9 takeshi 390.13")
[{"satoshi", 10.9}, {"takeshi", 390.13}]
iex> parse_orders("+vat +service")
[]
iex> parse_orders("")
[]
"""
@spec parse_orders(String.t()) :: orders()
def parse_orders(text) do
text
|> String.split(" ")
|> Enum.filter(fn s -> not option?(s) and s != "" end)
|> Enum.chunk_every(2)
|> Enum.map(fn
[name, amount] ->
{float_amount, ""} = Float.parse(amount)
{String.downcase(name), float_amount}
end)
end

@doc """
Extract options (anything beginning with +/-) out of
the input text into the list.
will make all name lower case for the sake of comparison.

## Example
iex> parse_options("d 10 a 200 +vat +service")
["+vat", "+service"]
iex> parse_options("a 500 +V +s b 200 +T")
["+v", "+s", "+t"]
iex> parse_options("d 10 a 200 +7% +10% -5%")
["+7%", "+10%", "-5%"]
iex> parse_options("d 10 a 200 z 200")
[]
iex> parse_options("")
[]
"""
@spec parse_options(String.t()) :: options()
def parse_options(text) do
text
|> String.split(" ")
|> Enum.filter(fn s -> option?(s) end)
|> Enum.map(&String.downcase/1)
end

defp option?(string), do: not number?(string) and String.match?(string, ~r/^[+-]/)

defp number?(string) do
with {_float_amount, remaining} <- Float.parse(string) do
remaining == ""
else
_ -> false
end
end
end
81 changes: 81 additions & 0 deletions lib/chopperbot/split/input_transformer.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
defmodule Chopperbot.Split.InputTransformer do
alias Chopperbot.Split.Order

@doc """
Transform inputs to a list of orders.

## Examples
iex> transform(["turbo", "10", "kendo", "200"])
{:ok, [{"turbo", 10.0}, {"kendo", 200.0}]}

iex> transform(["ant", "200", "pipe", "100", "share", "-30"])
{:ok, [{"ant", 200.0}, {"pipe", 100.0}, {"share", -30.0}]}

iex> transform(["Satoshi", "10.9", "Takeshi", "390.13", "satoshi", "112.50"])
{:ok, [{"satoshi", 10.9}, {"takeshi", 390.13}, {"satoshi", 112.5}]}

iex> transform([])
{:ok, []}

iex> transform(["turbo", "ten", "kendo", "twenty"])
{:error, :invalid_input, ["ten", "twenty"]}

iex> transform(["turbo", "100", "kendo", "200", "chopper"])
{:error, :invalid_input, ["chopper"]}

iex> transform(["turbo", "ten", "kendo", "200", "chopper"])
{:error, :invalid_input, ["ten", "chopper"]}
"""
@spec transform([String.t()]) :: {:ok, [Order.t()]} | {:error, :invalid_input, [String.t()]}
def transform(inputs) do
input_pairs = Enum.chunk_every(inputs, 2)

case transform_to_orders(input_pairs) do
{orders, []} ->
{:ok, orders}

{_, invalid_inputs} ->
{:error, :invalid_input, invalid_inputs}
end
end

defp transform_to_orders(input_pairs, orders \\ [], invalid_inputs \\ [])

defp transform_to_orders([input_pair | rest_input_pairs], orders, invalid_inputs) do
with {:ok, name, amount} <- validate_input_pair(input_pair),
{:ok, float_amount} <- validate_amount_string(amount) do
order = {String.downcase(name), float_amount}

transform_to_orders(
rest_input_pairs,
[order | orders],
invalid_inputs
)
else
{:error, invalid_input} ->
transform_to_orders(
rest_input_pairs,
orders,
[invalid_input | invalid_inputs]
)
end
end

defp transform_to_orders([], orders, invalid_inputs) do
{Enum.reverse(orders), Enum.reverse(invalid_inputs)}
end

defp validate_input_pair(input_pair) do
case input_pair do
[name, amount] -> {:ok, name, amount}
[invalid_input] -> {:error, invalid_input}
end
end

defp validate_amount_string(string) do
case Float.parse(string) do
{float_number, ""} -> {:ok, float_number}
_ -> {:error, string}
end
end
end
Loading