api-surface #1
3 changed files with 116 additions and 0 deletions
feat(enum): open integer enum DSL with {:unknown, n} lossless round-trip
commit
022d157d65
63
lib/dexcord/enum.ex
Normal file
63
lib/dexcord/enum.ex
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
defmodule Dexcord.Enum do
|
||||
@moduledoc """
|
||||
A DSL for Discord's open integer enums.
|
||||
|
||||
Discord adds enum values over time; decoding must not break when it does.
|
||||
`decode/1` maps a recognized integer to its named atom and any other
|
||||
integer to `{:unknown, n}`; `encode/1` reverses both losslessly.
|
||||
|
||||
defmodule Dexcord.MessageType do
|
||||
use Dexcord.Enum, default: 0, reply: 19
|
||||
end
|
||||
|
||||
Dexcord.MessageType.decode(19) #=> :reply
|
||||
Dexcord.MessageType.decode(999) #=> {:unknown, 999}
|
||||
Dexcord.MessageType.encode({:unknown, 999}) #=> 999
|
||||
"""
|
||||
|
||||
defmacro __using__(values) do
|
||||
unless is_list(values) and values != [] and Keyword.keyword?(values) do
|
||||
raise ArgumentError,
|
||||
"use Dexcord.Enum expects a non-empty keyword list of name: integer pairs"
|
||||
end
|
||||
|
||||
names = Keyword.keys(values)
|
||||
|
||||
type_union =
|
||||
Enum.reduce(names, quote(do: {:unknown, integer()}), fn name, acc ->
|
||||
{:|, [], [name, acc]}
|
||||
end)
|
||||
|
||||
decode_clauses =
|
||||
for {name, int} <- values do
|
||||
quote do
|
||||
def decode(unquote(int)), do: unquote(name)
|
||||
end
|
||||
end
|
||||
|
||||
encode_clauses =
|
||||
for {name, int} <- values do
|
||||
quote do
|
||||
def encode(unquote(name)), do: unquote(int)
|
||||
end
|
||||
end
|
||||
|
||||
quote do
|
||||
@type t :: unquote(type_union)
|
||||
|
||||
@doc "Decodes a wire integer; unrecognized values become `{:unknown, n}`."
|
||||
@spec decode(integer()) :: t()
|
||||
unquote_splicing(decode_clauses)
|
||||
def decode(n) when is_integer(n), do: {:unknown, n}
|
||||
|
||||
@doc "Encodes a named atom or `{:unknown, n}` back to the wire integer."
|
||||
@spec encode(t()) :: integer()
|
||||
unquote_splicing(encode_clauses)
|
||||
def encode({:unknown, n}) when is_integer(n), do: n
|
||||
|
||||
@doc "The full name => integer table."
|
||||
@spec values() :: %{atom() => integer()}
|
||||
def values, do: unquote({:%{}, [], values})
|
||||
end
|
||||
end
|
||||
end
|
||||
49
test/dexcord/enum_test.exs
Normal file
49
test/dexcord/enum_test.exs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
defmodule Dexcord.EnumTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Dexcord.Test.Color
|
||||
|
||||
# api-surface.AC2.4: unknown enum int -> {:unknown, n}; encode(decode(n)) lossless.
|
||||
|
||||
test "decode/1 maps known integers to their named atoms" do
|
||||
assert Color.decode(0) == :red
|
||||
assert Color.decode(1) == :green
|
||||
assert Color.decode(4) == :blue
|
||||
end
|
||||
|
||||
test "decode/1 maps a gap value (unnamed but in range) to {:unknown, n}" do
|
||||
assert Color.decode(2) == {:unknown, 2}
|
||||
end
|
||||
|
||||
test "decode/1 maps out-of-range and negative integers to {:unknown, n}" do
|
||||
assert Color.decode(999) == {:unknown, 999}
|
||||
assert Color.decode(-5) == {:unknown, -5}
|
||||
end
|
||||
|
||||
test "encode(decode(n)) is lossless for known and unknown values, including negative" do
|
||||
for n <- [0, 1, 4, 2, 999, -5] do
|
||||
assert Color.encode(Color.decode(n)) == n
|
||||
end
|
||||
end
|
||||
|
||||
test "encode/1 maps named atoms to their wire integer" do
|
||||
assert Color.encode(:red) == 0
|
||||
assert Color.encode(:green) == 1
|
||||
assert Color.encode(:blue) == 4
|
||||
end
|
||||
|
||||
test "encode/1 raises FunctionClauseError for an unnamed atom" do
|
||||
# apply/3 keeps the invalid atom opaque to the compile-time type checker,
|
||||
# so this deliberate misuse doesn't emit a typing-violation warning.
|
||||
assert_raise FunctionClauseError, fn -> apply(Color, :encode, [:not_a_color]) end
|
||||
end
|
||||
|
||||
test "values/0 returns the full name => integer table" do
|
||||
assert Color.values() == %{red: 0, green: 1, blue: 4}
|
||||
end
|
||||
|
||||
test "the module generates a :t type" do
|
||||
{:ok, types} = Code.Typespec.fetch_types(Color)
|
||||
assert Enum.any?(types, fn {_kind, {name, _def, _args}} -> name == :t end)
|
||||
end
|
||||
end
|
||||
4
test/support/dsl_fixtures.ex
Normal file
4
test/support/dsl_fixtures.ex
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
defmodule Dexcord.Test.Color do
|
||||
@moduledoc false
|
||||
use Dexcord.Enum, red: 0, green: 1, blue: 4
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue