diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 5df06c6..d391e64 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -15,8 +15,8 @@ jobs: - name: Setup BEAM / Elixir uses: erlef/setup-beam@v1 with: - otp-version: 26 - elixir-version: 1.16.2 + otp-version: 28 + elixir-version: 1.18.4 - name: Cache dependencies uses: actions/cache@v4 diff --git a/README.md b/README.md index 0e41cfb..330b073 100644 --- a/README.md +++ b/README.md @@ -15,19 +15,20 @@ If you wish to invite this bot to your server, use [this link.](https://discord. ### Dependencies -This application has been built and tested with Elixir 1.16 / OTP 26. +This application has been built and tested with Elixir 1.18 / OTP 28. ```elixir # mix.exs defp deps do [ + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, {:nostrum, "~> 0.10"}, {:httpoison, "~> 2.2"}, {:image, "~> 0.55"}, {:jason, "~> 1.4"}, - {:logger_webhook_backend, "~> 0.0.2"}, + {:logger_webhook_backend, "~> 0.0.4"}, {:plug, "~> 1.12"}, {:plug_cowboy, "~> 2.7"}, {:quantum, "~> 3.5"} @@ -40,9 +41,12 @@ defp deps do Below are the environment variables that you need to set for the program to function: ``` +# needed in development and production PLANTID_DISCORD_BOT_TOKEN= client secret from the bot's application PLANTID_LOGS_DISCORD_WEBHOOK_URL= webhook url for the log channel PLANTNET_API_KEY= API key for the PlantNet service + +# needed in production PLANTID_FILESERVER_URL URL and port(if needed) for the http file server ``` diff --git a/TODO b/TODO new file mode 100644 index 0000000..8703287 --- /dev/null +++ b/TODO @@ -0,0 +1,27 @@ +# 📝 Project TODOs + +## 🚀 High Priority + +- [x] Update deps and run on Elixir 1.18 / otp 28 +- [ ] Run static analysis tools and fix code +- [ ] Increase test coverage +- [ ] Fix spec errors +- [ ] Improve error return message + +## 📦 Medium Priority + +- [ ] - add credo, dialyxir and test to GH actions +- [ ] - use Req instead of HTTPoison + +## 🛠️ Low Priority + +- [ ] add checks on branch push / PR creation +- [ ] add specs where missing + +## 📚 Documentation + +- [ ] + +--- + +✅ Keep this file updated as tasks are completed. Use `git diff` to track progress. diff --git a/lib/application.ex b/lib/application.ex index b4e68f9..c0cdc88 100644 --- a/lib/application.ex +++ b/lib/application.ex @@ -1,4 +1,5 @@ defmodule PlantIdDiscordBot.Application do + @moduledoc false use Application @impl true diff --git a/lib/commands.ex b/lib/commands.ex index f0e921c..df21ed5 100644 --- a/lib/commands.ex +++ b/lib/commands.ex @@ -1,4 +1,7 @@ defmodule PlantIdDiscordBot.Consumer.Commands do + @moduledoc """ + Application commands. + """ def global_application_commands do [ %{ diff --git a/lib/metrics/message.ex b/lib/metrics/message.ex index a46502c..b89a741 100644 --- a/lib/metrics/message.ex +++ b/lib/metrics/message.ex @@ -1,4 +1,7 @@ defmodule PlantIdDiscordBot.Metrics.Message do + @moduledoc """ + Metrics messaging. + """ def send() do PlantIdDiscordBot.Metrics.requests() |> format_message() diff --git a/lib/metrics/requests.ex b/lib/metrics/requests.ex index a4789ee..0a24505 100644 --- a/lib/metrics/requests.ex +++ b/lib/metrics/requests.ex @@ -1,4 +1,7 @@ defmodule PlantIdDiscordBot.Metrics.Requests do + @moduledoc """ + Basic metrics with usage count per guild. + """ use Agent alias PlantIdDiscordBot.Metrics.Requests alias PlantIdDiscordBot.ProcessRegistry diff --git a/lib/mocks/nostrum_api.ex b/lib/mocks/nostrum_api.ex index bee3487..468c109 100644 --- a/lib/mocks/nostrum_api.ex +++ b/lib/mocks/nostrum_api.ex @@ -6,7 +6,7 @@ defmodule PlantIdDiscordBotTest.Mocks.Nostrum.Api do # def create_message(_channel_id, content), do: {:ok, content} def create_message(_channel_id, content) do - send(self(), {:create_message, 123456, content}) + send(self(), {:create_message, 123_456, content}) {:ok, content} end diff --git a/lib/plantid_discord_bot.ex b/lib/plantid_discord_bot.ex index b513c47..5665fe9 100644 --- a/lib/plantid_discord_bot.ex +++ b/lib/plantid_discord_bot.ex @@ -26,10 +26,15 @@ defmodule PlantIdDiscordBot.Consumer do end def handle_event({:MESSAGE_CREATE, %{attachments: attachments} = message, _ws_state}) do - if length(attachments) > 0 do - # deprecated -> Nostrum.Api.Channel.start_typing/1 in v1.0 + if !Enum.empty?(attachments) do Api.start_typing!(message.channel_id) Cog.PlantNetMessage.id(message) end + + # if length(attachments) > 0 do + # # deprecated -> Nostrum.Api.Channel.start_typing/1 in v1.0 + # Api.start_typing!(message.channel_id) + # Cog.PlantNetMessage.id(message) + # end end end diff --git a/lib/plantid_discord_bot/cogs/info.ex b/lib/plantid_discord_bot/cogs/info.ex index 0e241fa..ef97682 100644 --- a/lib/plantid_discord_bot/cogs/info.ex +++ b/lib/plantid_discord_bot/cogs/info.ex @@ -1,4 +1,9 @@ defmodule PlantIdDiscordBot.Cog.Info do + @moduledoc """ + Functions for using the /info application command. + + Returns a message embed of application commands available to the user. + """ use Nostrum.Consumer import Nostrum.Struct.Embed alias PlantIdDiscordBot.Utils diff --git a/lib/plantid_discord_bot/cogs/plantnet_message.ex b/lib/plantid_discord_bot/cogs/plantnet_message.ex index cce68a9..acf3140 100644 --- a/lib/plantid_discord_bot/cogs/plantnet_message.ex +++ b/lib/plantid_discord_bot/cogs/plantnet_message.ex @@ -1,4 +1,7 @@ defmodule PlantIdDiscordBot.Cog.PlantNetMessage do + @moduledoc """ + Functions for sending and receiving data from PLantNet + """ require Logger use Nostrum.Consumer @@ -49,7 +52,6 @@ defmodule PlantIdDiscordBot.Cog.PlantNetMessage do nil end - # TODO improve composition if saved_images do try do prepare_images(saved_images) diff --git a/lib/plantid_discord_bot/file_server.ex b/lib/plantid_discord_bot/file_server.ex index 3ec3541..2edc839 100644 --- a/lib/plantid_discord_bot/file_server.ex +++ b/lib/plantid_discord_bot/file_server.ex @@ -1,4 +1,7 @@ defmodule PlantIdDiscordBot.FileServer do + @moduledoc """ + File server for temporary storage of images until a response is made. + """ use Plug.Builder plug(Plug.Logger) diff --git a/lib/plantid_discord_bot/file_server/file.ex b/lib/plantid_discord_bot/file_server/file.ex index 499c79b..b2627d2 100644 --- a/lib/plantid_discord_bot/file_server/file.ex +++ b/lib/plantid_discord_bot/file_server/file.ex @@ -2,6 +2,8 @@ defmodule PlantIdDiscordBot.FileServer.File do @moduledoc """ File utilities """ + require Logger + alias PlantIdDiscordBot.FileServer.ImageConverter @image_path Application.compile_env(:plantid_discord_bot, :image_path) @@ -56,7 +58,6 @@ defmodule PlantIdDiscordBot.FileServer.File do |> File.read() end - # TODO make into a task for async deletion and deal with errors @spec delete_files!([String.t()]) :: :ok def delete_files!(filenames) do tasks = @@ -65,8 +66,7 @@ defmodule PlantIdDiscordBot.FileServer.File do try do File.rm!(Path.join(@image_path, filename)) rescue - # TODO Logger - e -> IO.inspect(e) + e -> Logger.error(e) end end) end) diff --git a/lib/plantid_discord_bot/guild.ex b/lib/plantid_discord_bot/guild.ex index d7cff98..d6c2309 100644 --- a/lib/plantid_discord_bot/guild.ex +++ b/lib/plantid_discord_bot/guild.ex @@ -1,4 +1,7 @@ defmodule PlantIdDiscordBot.Guild do + @moduledoc """ + Guild related functions. + """ alias Nostrum.Cache.GuildCache def get_guild_name!(guild_id) do diff --git a/lib/plantid_discord_bot/process_registry.ex b/lib/plantid_discord_bot/process_registry.ex index e97c683..fb3c65e 100644 --- a/lib/plantid_discord_bot/process_registry.ex +++ b/lib/plantid_discord_bot/process_registry.ex @@ -1,4 +1,7 @@ defmodule PlantIdDiscordBot.ProcessRegistry do + @moduledoc """ + Process registry. + """ def start_link do Registry.start_link(keys: :unique, name: __MODULE__) end diff --git a/lib/plantid_discord_bot/rate_limiter.ex b/lib/plantid_discord_bot/rate_limiter.ex index 2bfd83b..1ed8f2b 100644 --- a/lib/plantid_discord_bot/rate_limiter.ex +++ b/lib/plantid_discord_bot/rate_limiter.ex @@ -1,4 +1,10 @@ defmodule PlantIdDiscordBot.RateLimiter do + @moduledoc """ + Basic rate limiter to prevent one guild from calling too many requests in 24 hours. + + Custom limits have been set for some guilds that are trusted. + """ + use GenServer require Logger diff --git a/lib/plantid_discord_bot/utils.ex b/lib/plantid_discord_bot/utils.ex index 5f02427..8d158d1 100644 --- a/lib/plantid_discord_bot/utils.ex +++ b/lib/plantid_discord_bot/utils.ex @@ -1,4 +1,7 @@ defmodule PlantIdDiscordBot.Utils do + @moduledoc """ + Bot related utility functions. + """ def get_uptime() do start_time = Application.get_env(:plantid_discord_bot, :start_time) diff --git a/lib/plantnet/parser.ex b/lib/plantnet/parser.ex index d66200e..e670d16 100644 --- a/lib/plantnet/parser.ex +++ b/lib/plantnet/parser.ex @@ -33,7 +33,7 @@ defmodule PlantIdDiscordBot.PlantNet.Parser do @doc """ Parses the response from the PlantNet API into a map. """ - @spec parse(String.t()) :: map() + @spec parse(String.t()) :: String.t() def parse(response) do response |> to_map!() @@ -85,7 +85,7 @@ defmodule PlantIdDiscordBot.PlantNet.Parser do "My best guess is **#{best_guess_name}** with a confidence of **#{score}%**.#{get_common_names(best_result)}\n\nSpecies info from plant databases:\n[GBIF](<#{best_result["gbif_url"]}>) | [PFAF](<#{best_result["pfaf_url"]}>) | [POWO](<#{best_result["powo_url"]}>)#{if best_result_iucn_category, do: "\n\nConservation status: #{iucn_parser(best_result_iucn_category)}"}#{get_alternatives(other_results)}" end - @spec generate_gbif_url(map()) :: map() + @spec generate_gbif_url([map()]) :: [map()] defp generate_gbif_url(data) do Enum.map(data, fn result -> gbif_id = result["gbif"]["id"] @@ -93,7 +93,7 @@ defmodule PlantIdDiscordBot.PlantNet.Parser do end) end - @spec generate_pfaf_url(map()) :: map() + @spec generate_pfaf_url([map()]) :: [map()] defp generate_pfaf_url(data) do Enum.map(data, fn result -> pfaf_slug = String.replace(result["species"]["scientificNameWithoutAuthor"], " ", "+") @@ -104,7 +104,7 @@ defmodule PlantIdDiscordBot.PlantNet.Parser do end) end - @spec generate_powo_url(map()) :: map() + @spec generate_powo_url([map()]) :: [map()] defp generate_powo_url(data) do Enum.map(data, fn result -> powo_id = result["powo"]["id"] @@ -123,29 +123,40 @@ defmodule PlantIdDiscordBot.PlantNet.Parser do end @spec iucn_parser(String.t()) :: String.t() - defp iucn_parser(abbreviation) do - case abbreviation do - "DD" -> "Data Deficient" - "LC" -> "Least Concern" - "NT" -> "Near Threatened" - "VU" -> "Vulnerable" - "EN" -> "Endangered" - "CR" -> "Critically Endangered" - "EW" -> "Extinct in the Wild" - "EX" -> "Extinct" - "NE" -> "Not Evaluated" - _ -> "Unknown" - end - end + def iucn_parser("DD"), do: "Data Deficient" + def iucn_parser("LC"), do: "Least Concern" + def iucn_parser("NT"), do: "Near Threatened" + def iucn_parser("VU"), do: "Vulnerable" + def iucn_parser("EN"), do: "Endangered" + def iucn_parser("CR"), do: "Critically Endangered" + def iucn_parser("EW"), do: "Extinct in the Wild" + def iucn_parser("EX"), do: "Extinct" + def iucn_parser("NE"), do: "Not Evaluated" + def iucn_parser(_), do: "Unknown" @spec get_alternatives(map()) :: String.t() defp get_alternatives(data) do - if length(data) > 0 do + if !Enum.empty?(data) do alternatives = - Enum.map(data, & &1["species"]["scientificNameWithoutAuthor"]) - |> Enum.join(", ") + Enum.map_join(data, ", ", & &1["species"]["scientificNameWithoutAuthor"]) "\n\nAlternatives include **#{alternatives}**." end + + # if !Enum.empty?(data) do + # alternatives = + # Enum.map(data, & &1["species"]["scientificNameWithoutAuthor"]) + # |> Enum.join(", ") + + # "\n\nAlternatives include **#{alternatives}**." + # end + + # if length(data) > 0 do + # alternatives = + # Enum.map(data, & &1["species"]["scientificNameWithoutAuthor"]) + # |> Enum.join(", ") + + # "\n\nAlternatives include **#{alternatives}**." + # end end end diff --git a/lib/scheduler.ex b/lib/scheduler.ex index 2d0898a..6a619f0 100644 --- a/lib/scheduler.ex +++ b/lib/scheduler.ex @@ -1,3 +1,6 @@ defmodule PlantIdDiscordBot.Scheduler do + @moduledoc """ + CRON job scheduler. + """ use Quantum, otp_app: :plantid_discord_bot end diff --git a/lib/utils/duration.ex b/lib/utils/duration.ex index 8bf9a8f..1ab8727 100644 --- a/lib/utils/duration.ex +++ b/lib/utils/duration.ex @@ -1,11 +1,21 @@ -# https://rosettacode.org/wiki/Convert_seconds_to_compound_duration defmodule PlantIdDiscordBot.Utils.Duration do + @moduledoc """ + Duration conversion functions. + """ @minute 60 @hour @minute * 60 @day @hour * 24 @week @day * 7 @divisor [@week, @day, @hour, @minute, 1] + @doc """ + Convert seconds to a time string. + # https://rosettacode.org/wiki/Convert_seconds_to_compound_duration + + ## Examples + iex> PlantIdDiscordBot.Utils.Duration.sec_to_str(65) + iex> "1m 5s" + """ def sec_to_str(sec) do {_, [s, m, h, d, w]} = Enum.reduce(@divisor, {sec, []}, fn divisor, {n, acc} -> diff --git a/mix.exs b/mix.exs index 9ead46b..803656a 100644 --- a/mix.exs +++ b/mix.exs @@ -4,8 +4,8 @@ defmodule PlantidDiscordBot.MixProject do def project do [ app: :plantid_discord_bot, - version: "0.2.0", - elixir: "~> 1.16", + version: "0.2.1", + elixir: "~> 1.8", start_permanent: Mix.env() == :prod, deps: deps() ] @@ -24,6 +24,7 @@ defmodule PlantidDiscordBot.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, {:nostrum, "~> 0.10"}, {:httpoison, "~> 2.2"}, diff --git a/mix.lock b/mix.lock index c844aed..857c99b 100644 --- a/mix.lock +++ b/mix.lock @@ -1,4 +1,5 @@ %{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "castle": {:hex, :castle, "0.3.0", "47b1a550b2348a6d7e60e43ded1df19dca601ed21ef6f267c3dbb1b3a301fbf5", [:mix], [{:forecastle, "~> 0.1.0", [hex: :forecastle, repo: "hexpm", optional: false]}], "hexpm", "dbdc1c171520c4591101938a3d342dec70d36b7f5b102a5c138098581e35fcef"}, "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"}, @@ -6,10 +7,12 @@ "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, + "credo": {:hex, :credo, "1.7.14", "c7e75216cea8d978ba8c60ed9dede4cc79a1c99a266c34b3600dd2c33b96bc92", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "12a97d6bb98c277e4fb1dff45aaf5c137287416009d214fb46e68147bd9e0203"}, "crontab": {:hex, :crontab, "1.1.14", "233fcfdc2c74510cabdbcb800626babef414e7cb13cea11ddf62e10e16e2bf76", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "4e3b9950bc22ae8d0395ffb5f4b127a140005cba95745abf5ff9ee7e8203c6fa"}, "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "forecastle": {:hex, :forecastle, "0.1.2", "f8dab08962c7a33010ebd39182513129f17b8814aa16fa453ddd536040882daf", [:mix], [], "hexpm", "8efaeb2e7d0fa24c605605e42562e2dbb0ffd11dc1dd99ef77d78884536ce501"}, "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, "gun": {:hex, :gun, "2.1.0", "b4e4cbbf3026d21981c447e9e7ca856766046eff693720ba43114d7f5de36e87", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "52fc7fc246bfc3b00e01aea1c2854c70a366348574ab50c57dfe796d24a0101d"}, diff --git a/test/error_handling_test.exs b/test/error_handling_test.exs index df8bddf..ee1a0c7 100644 --- a/test/error_handling_test.exs +++ b/test/error_handling_test.exs @@ -7,15 +7,15 @@ defmodule PlantIdDiscordBot.ErrorHandlingTest do test "do_identification/1 returns invokes logger on error" do message = PlantNetFixtures.Message.message() - log = capture_log(fn -> - PlantIdDiscordBot.Cog.PlantNetMessage.do_identification(message) + log = + capture_log(fn -> + PlantIdDiscordBot.Cog.PlantNetMessage.do_identification(message) - assert_received {:create_message, 123456, content: "An error has occured. Please try again later."} - end) + assert_received {:create_message, 123_456, + content: "An error has occured. Please try again later."} + end) - IO.inspect(log) - - assert log =~ "guild_id=#{message.guild_id}" - assert log =~ "guild_name=#{@guild.get_guild_name!(message.guild_id)}" + assert log =~ "guild_id=#{message.guild_id}" + assert log =~ "guild_name=#{@guild.get_guild_name!(message.guild_id)}" end end diff --git a/test/file_server/mime_types_test.exs b/test/file_server/mime_types_test.exs new file mode 100644 index 0000000..cc5dcb4 --- /dev/null +++ b/test/file_server/mime_types_test.exs @@ -0,0 +1,43 @@ +defmodule PlantIdDiscordBotTest.FileServer.MimeTypes do + use ExUnit.Case, async: true + + alias PlantIdDiscordBot.FileServer.MimeTypes + doctest MimeTypes + + describe "detect_mime_type/1" do + test "detects webp" do + bin = <<0x52, 0x49, 0x46, 0x46, 0x00>> + assert MimeTypes.detect_mime_type(bin) == {:ok, "image/webp"} + end + + test "detects jpeg" do + bin = <<0xFF, 0xD8, 0xFF, 0x00>> + assert MimeTypes.detect_mime_type(bin) == {:ok, "image/jpeg"} + end + + test "detects png" do + bin = <<0x89, 0x50, 0x4E, 0x47, 0x00>> + assert MimeTypes.detect_mime_type(bin) == {:ok, "image/png"} + end + + test "detects gif" do + bin = <<0x47, 0x49, 0x46, 0x38, 0x00>> + assert MimeTypes.detect_mime_type(bin) == {:ok, "image/gif"} + end + + test "detects bmp" do + bin = <<0x42, 0x4D, 0x00>> + assert MimeTypes.detect_mime_type(bin) == {:ok, "image/bmp"} + end + + test "detects tiff" do + bin = <<0x49, 0x49, 0x2A, 0x00, 0x00>> + assert MimeTypes.detect_mime_type(bin) == {:ok, "image/tiff"} + end + + test "returns error for unsupported type" do + bin = <<0x00, 0x11, 0x22, 0x33>> + assert MimeTypes.detect_mime_type(bin) == {:error, "Unsupported file type"} + end + end +end diff --git a/test/plantnet/parser_test.exs b/test/plantnet/parser_test.exs index cef558b..5bb72bc 100644 --- a/test/plantnet/parser_test.exs +++ b/test/plantnet/parser_test.exs @@ -63,4 +63,19 @@ defmodule PlantIdDiscordBotTest.PlantNet.Parser do "My best guess is **Prunus cerasifera** with a confidence of **88%**.\n\nSpecies info from plant databases:\n[GBIF]() | [PFAF]() | [POWO]()\n\nConservation status: Data Deficient\n\nAlternatives include **Prunus × cistena**." end end + + describe "iucn_parser/1" do + test "return correct string for abbreviation" do + assert Parser.iucn_parser("DD") == "Data Deficient" + assert Parser.iucn_parser("LC") == "Least Concern" + assert Parser.iucn_parser("NT") == "Near Threatened" + assert Parser.iucn_parser("VU") == "Vulnerable" + assert Parser.iucn_parser("EN") == "Endangered" + assert Parser.iucn_parser("CR") == "Critically Endangered" + assert Parser.iucn_parser("EW") == "Extinct in the Wild" + assert Parser.iucn_parser("EX") == "Extinct" + assert Parser.iucn_parser("NE") == "Not Evaluated" + assert Parser.iucn_parser("ABC") == "Unknown" + end + end end diff --git a/test/test_helper.exs b/test/test_helper.exs index b805285..a1986dd 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -2,9 +2,9 @@ ExUnit.start() defmodule PlantNetFixtures.Message do @message %{ - channel_id: 1178600825380155412, + channel_id: 1_178_600_825_380_155_412, guild_id: 1_002_507_312_159_797_318, - attachments: [%{url: "http://invalid-url.com/image.jpg"}], + attachments: [%{url: "http://invalid-url.com/image.jpg"}] } def message(), do: @message diff --git a/test/utils/duration_test.exs b/test/utils/duration_test.exs new file mode 100644 index 0000000..178721b --- /dev/null +++ b/test/utils/duration_test.exs @@ -0,0 +1,39 @@ +defmodule PlantIdDiscordBotTest.Utils.Duration do + use ExUnit.Case, async: true + + alias PlantIdDiscordBot.Utils.Duration + + doctest Duration + + describe "sec_to_str/1" do + test "seconds" do + assert Duration.sec_to_str(5) == "5s" + refute Duration.sec_to_str(60) == "60s" + end + + test "minutes" do + assert Duration.sec_to_str(60) == "1m" + assert Duration.sec_to_str(75) == "1m 15s" + refute Duration.sec_to_str(3600) == "60m" + end + + test "hours" do + assert Duration.sec_to_str(3600) == "1h" + assert Duration.sec_to_str(7205) == "2h 5s" + assert Duration.sec_to_str(36_300) == "10h 5m" + assert Duration.sec_to_str(36_315) == "10h 5m 15s" + refute Duration.sec_to_str(86_400) == "24h" + end + + test "days" do + assert Duration.sec_to_str(86_400) == "1d" + assert Duration.sec_to_str(86_715) == "1d 5m 15s" + refute Duration.sec_to_str(604_800) == "7d" + end + + test "weeks" do + assert Duration.sec_to_str(604_800) == "1w" + assert Duration.sec_to_str(6_048_000) == "10w" + end + end +end