api-surface #1
2 changed files with 191 additions and 3 deletions
feat(struct): tolerant from_map decode engine — zero atoms, never raises
commit
e9ebf34f32
|
|
@ -1,7 +1,71 @@
|
|||
defmodule Dexcord.Struct.Codec do
|
||||
@moduledoc false
|
||||
# Runtime walker behind every generated from_map/1, to_map/1, merge_map/2.
|
||||
# All decode tolerance semantics live here, in one place.
|
||||
|
||||
def from_map(_module, _map), do: raise("not implemented until Task 5")
|
||||
def to_map(_struct), do: raise("not implemented until Task 5")
|
||||
def merge_map(_struct, _map), do: raise("not implemented until Task 5")
|
||||
@doc false
|
||||
def from_map(module, map) when is_map(map) and not is_struct(map) do
|
||||
fields =
|
||||
for f <- module.__fields__() do
|
||||
{f.name, decode_field(f, map)}
|
||||
end
|
||||
|
||||
struct!(module, fields)
|
||||
end
|
||||
|
||||
def from_map(_module, _non_map), do: nil
|
||||
|
||||
@doc false
|
||||
def decode_field(%{presence: true} = f, map), do: Map.has_key?(map, f.key)
|
||||
|
||||
def decode_field(f, map) do
|
||||
case Map.fetch(map, f.key) do
|
||||
:error -> f.default
|
||||
{:ok, nil} -> nil
|
||||
{:ok, value} -> decode_value(f.type, value, f.default)
|
||||
end
|
||||
end
|
||||
|
||||
# decode_value(type, non-nil wire value, default) — wrong container shape
|
||||
# falls to the default; failed scalar casts keep the raw value.
|
||||
|
||||
defp decode_value(:snowflake, v, _d) when is_integer(v), do: v
|
||||
|
||||
defp decode_value(:snowflake, v, _d) when is_binary(v) do
|
||||
case Dexcord.Snowflake.cast(v) do
|
||||
{:ok, int} -> int
|
||||
:error -> v
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_value(:datetime, v, _d) when is_binary(v) do
|
||||
case DateTime.from_iso8601(v) do
|
||||
{:ok, dt, _offset} -> dt
|
||||
{:error, _} -> v
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_value({:struct, mod}, v, _d) when is_map(v) and not is_struct(v),
|
||||
do: mod.from_map(v)
|
||||
|
||||
defp decode_value({:struct, _mod}, _v, d), do: d
|
||||
|
||||
defp decode_value({:list, inner}, v, _d) when is_list(v),
|
||||
do: Enum.map(v, &decode_element(inner, &1))
|
||||
|
||||
defp decode_value({:list, _inner}, _v, d), do: d
|
||||
|
||||
defp decode_value({:enum, mod}, v, _d) when is_integer(v), do: mod.decode(v)
|
||||
|
||||
defp decode_value({:flags, _mod}, v, _d) when is_integer(v), do: v
|
||||
|
||||
# :string, :integer, :boolean, :raw, plus every failed scalar cast:
|
||||
# the raw wire value passes through untouched.
|
||||
defp decode_value(_type, v, _d), do: v
|
||||
|
||||
defp decode_element(_inner, nil), do: nil
|
||||
defp decode_element(inner, v), do: decode_value(inner, v, nil)
|
||||
|
||||
def to_map(_struct), do: raise("not implemented until Task 6")
|
||||
def merge_map(_struct, _map), do: raise("not implemented until Task 6")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,8 +1,23 @@
|
|||
defmodule Dexcord.StructTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Dexcord.Test.Gadget
|
||||
alias Dexcord.Test.Widget
|
||||
|
||||
# A representative full wire map (string-keyed), reused across decode/encode tests.
|
||||
defp full_wire_map do
|
||||
%{
|
||||
"id" => "123",
|
||||
"name" => "w",
|
||||
"count" => 7,
|
||||
"enabled" => true,
|
||||
"created_at" => "2026-07-04T12:00:00Z",
|
||||
"gadget" => %{"id" => 9, "name" => "g"},
|
||||
"gadgets" => [%{"id" => 1, "name" => "a"}, %{"id" => 2, "name" => "b"}],
|
||||
"tag_ids" => ["111", 222]
|
||||
}
|
||||
end
|
||||
|
||||
describe "compile shape (Task 4)" do
|
||||
test "struct has all declared keys with correct defaults" do
|
||||
w = %Widget{}
|
||||
|
|
@ -112,4 +127,113 @@ defmodule Dexcord.StructTest do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "from_map decode (Task 5)" do
|
||||
test "api-surface.AC2.1: decodes declared fields including nested structs and lists" do
|
||||
w = Widget.from_map(full_wire_map())
|
||||
|
||||
# snowflake string normalized to integer
|
||||
assert w.id == 123
|
||||
assert w.name == "w"
|
||||
assert w.count == 7
|
||||
assert w.enabled == true
|
||||
|
||||
# datetime parsed
|
||||
assert %DateTime{} = w.created_at
|
||||
assert w.created_at == ~U[2026-07-04 12:00:00Z]
|
||||
|
||||
# nested struct
|
||||
assert w.gadget == %Gadget{id: 9, name: "g"}
|
||||
|
||||
# list of nested structs
|
||||
assert w.gadgets == [%Gadget{id: 1, name: "a"}, %Gadget{id: 2, name: "b"}]
|
||||
|
||||
# list of snowflakes: string and int both normalized to integer
|
||||
assert w.tag_ids == [111, 222]
|
||||
end
|
||||
|
||||
test "api-surface.AC2.2: unknown keys ignored, decode identical, zero atoms minted" do
|
||||
base = Widget.from_map(full_wire_map())
|
||||
|
||||
with_junk =
|
||||
Widget.from_map(
|
||||
Map.merge(full_wire_map(), %{
|
||||
"totally_unknown_key_ab12cd" => 1,
|
||||
"another_unknown_key_ab12cd" => %{"deep" => true}
|
||||
})
|
||||
)
|
||||
|
||||
assert with_junk == base
|
||||
|
||||
# zero atoms minted from wire data: these strings never became atoms.
|
||||
assert_raise ArgumentError, fn ->
|
||||
String.to_existing_atom("totally_unknown_key_ab12cd")
|
||||
end
|
||||
|
||||
assert_raise ArgumentError, fn ->
|
||||
String.to_existing_atom("another_unknown_key_ab12cd")
|
||||
end
|
||||
end
|
||||
|
||||
test "api-surface.AC2.3: wrong-shaped values never raise, fall to default/raw" do
|
||||
# list where a map is expected -> nil (declared default for :gadget)
|
||||
assert Widget.from_map(%{"gadget" => [1, 2]}).gadget == nil
|
||||
|
||||
# map where a list is expected -> declared default []
|
||||
assert Widget.from_map(%{"gadgets" => %{"not" => "a list"}}).gadgets == []
|
||||
|
||||
# junk scalars keep the raw wire value
|
||||
assert Widget.from_map(%{"count" => "not-a-number"}).count == "not-a-number"
|
||||
assert Widget.from_map(%{"enabled" => "yes"}).enabled == "yes"
|
||||
assert Widget.from_map(%{"created_at" => "not-a-date"}).created_at == "not-a-date"
|
||||
assert Widget.from_map(%{"id" => "12x"}).id == "12x"
|
||||
assert Widget.from_map(%{"color" => "red"}).color == "red"
|
||||
assert Widget.from_map(%{"flags" => "3"}).flags == "3"
|
||||
|
||||
# whole-value junk -> nil
|
||||
assert Widget.from_map("hello") == nil
|
||||
assert Widget.from_map(42) == nil
|
||||
|
||||
# empty map -> all-defaults struct
|
||||
assert Widget.from_map(%{}) == %Widget{}
|
||||
end
|
||||
|
||||
test "enum and flags decode through the struct" do
|
||||
assert Widget.from_map(%{"color" => 4}).color == :blue
|
||||
assert Widget.from_map(%{"color" => 2}).color == {:unknown, 2}
|
||||
|
||||
# flags keep the raw integer, unknown bit (bit 7) preserved
|
||||
assert Widget.from_map(%{"flags" => 0b1000_0001}).flags == 129
|
||||
end
|
||||
|
||||
test "api-surface.AC2.6: tristate field decodes to :absent | nil | value" do
|
||||
# key absent -> :absent
|
||||
assert Widget.from_map(%{}).parent == :absent
|
||||
|
||||
# present with null -> nil
|
||||
assert Widget.from_map(%{"parent" => nil}).parent == nil
|
||||
|
||||
# present with value -> self-referencing struct decodes
|
||||
w = Widget.from_map(%{"parent" => %{"id" => 1, "name" => "p"}})
|
||||
assert %Widget{} = w.parent
|
||||
assert w.parent.id == 1
|
||||
assert w.parent.name == "p"
|
||||
end
|
||||
|
||||
test "api-surface.AC2.7: presence field decodes key-presence to boolean" do
|
||||
# key absent -> false
|
||||
assert Widget.from_map(%{}).premium == false
|
||||
|
||||
# present with null -> true (present-with-null still counts as present)
|
||||
assert Widget.from_map(%{"premium" => nil}).premium == true
|
||||
|
||||
# present with value -> true
|
||||
assert Widget.from_map(%{"premium" => true}).premium == true
|
||||
end
|
||||
|
||||
test "list element null does not crash" do
|
||||
w = Widget.from_map(%{"gadgets" => [%{"id" => 1, "name" => "a"}, nil]})
|
||||
assert w.gadgets == [%Gadget{id: 1, name: "a"}, nil]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue