api-surface #1
2 changed files with 163 additions and 2 deletions
feat(struct): to_map encode + merge_map partial-update semantics
commit
d0962cc28a
|
|
@ -66,6 +66,48 @@ defmodule Dexcord.Struct.Codec do
|
|||
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")
|
||||
@doc false
|
||||
def to_map(%module{} = struct) do
|
||||
Enum.reduce(module.__fields__(), %{}, fn f, acc ->
|
||||
case encode_field(f, Map.fetch!(struct, f.name)) do
|
||||
:skip -> acc
|
||||
{:ok, encoded} -> Map.put(acc, f.key, encoded)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp encode_field(%{tristate: true}, :absent), do: :skip
|
||||
defp encode_field(%{presence: true}, true), do: {:ok, nil}
|
||||
defp encode_field(%{presence: true}, false), do: :skip
|
||||
defp encode_field(_f, nil), do: :skip
|
||||
defp encode_field(f, value), do: {:ok, encode_value(f.type, value)}
|
||||
|
||||
defp encode_value(:snowflake, v) when is_integer(v), do: Integer.to_string(v)
|
||||
defp encode_value(:datetime, %DateTime{} = v), do: DateTime.to_iso8601(v)
|
||||
# Route through the struct's own to_map/1 (not Codec.to_map directly): generated
|
||||
# structs delegate straight back here, while hand-written fallback structs
|
||||
# (UnknownChannel, Component.Unknown — no __fields__/0) provide their own.
|
||||
defp encode_value({:struct, _mod}, %_{} = v), do: v.__struct__.to_map(v)
|
||||
defp encode_value({:list, inner}, v) when is_list(v), do: Enum.map(v, &encode_element(inner, &1))
|
||||
defp encode_value({:enum, mod}, v) when is_atom(v) and not is_nil(v), do: mod.encode(v)
|
||||
defp encode_value({:enum, _mod}, {:unknown, n}) when is_integer(n), do: n
|
||||
# raw passthrough: junk scalars that decode kept raw re-encode raw; a raw
|
||||
# int in an enum/flags field is already wire format.
|
||||
defp encode_value(_type, v), do: v
|
||||
|
||||
defp encode_element(_inner, nil), do: nil
|
||||
defp encode_element(inner, v), do: encode_value(inner, v)
|
||||
|
||||
@doc false
|
||||
def merge_map(%module{} = struct, map) when is_map(map) and not is_struct(map) do
|
||||
Enum.reduce(module.__fields__(), struct, fn f, acc ->
|
||||
if Map.has_key?(map, f.key) do
|
||||
Map.put(acc, f.name, decode_field(f, map))
|
||||
else
|
||||
acc
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def merge_map(struct, _non_map), do: struct
|
||||
end
|
||||
|
|
|
|||
|
|
@ -236,4 +236,123 @@ defmodule Dexcord.StructTest do
|
|||
assert w.gadgets == [%Gadget{id: 1, name: "a"}, nil]
|
||||
end
|
||||
end
|
||||
|
||||
describe "to_map encode (Task 6)" do
|
||||
test "api-surface.AC2.9: value encodings for a decoded struct" do
|
||||
w = Widget.from_map(full_wire_map())
|
||||
m = Widget.to_map(w)
|
||||
|
||||
# snowflake integer -> decimal string
|
||||
assert m["id"] == "123"
|
||||
|
||||
# DateTime -> ISO8601
|
||||
assert m["created_at"] == "2026-07-04T12:00:00Z"
|
||||
|
||||
# nested struct/list encode as plain maps
|
||||
assert m["gadget"] == %{"id" => "9", "name" => "g"}
|
||||
assert m["gadgets"] == [%{"id" => "1", "name" => "a"}, %{"id" => "2", "name" => "b"}]
|
||||
|
||||
# scalars pass through
|
||||
assert m["name"] == "w"
|
||||
assert m["count"] == 7
|
||||
assert m["enabled"] == true
|
||||
|
||||
# empty-list default still encodes (real information)
|
||||
assert m["tag_ids"] == ["111", "222"]
|
||||
|
||||
# nil fields are omitted
|
||||
refute Map.has_key?(m, "color")
|
||||
refute Map.has_key?(m, "flags")
|
||||
refute Map.has_key?(m, "extra")
|
||||
|
||||
# :absent tristate is omitted (AC2.6 encode direction)
|
||||
refute Map.has_key?(m, "parent")
|
||||
|
||||
# presence false -> omitted
|
||||
refute Map.has_key?(m, "premium")
|
||||
end
|
||||
|
||||
test "enum encodes losslessly for known and unknown values" do
|
||||
known = Widget.to_map(%Widget{color: :blue})
|
||||
assert known["color"] == 4
|
||||
|
||||
unknown = Widget.to_map(%Widget{color: {:unknown, 2}})
|
||||
assert unknown["color"] == 2
|
||||
end
|
||||
|
||||
test "flags raw int encodes back as the raw int" do
|
||||
m = Widget.to_map(%Widget{flags: 129})
|
||||
assert m["flags"] == 129
|
||||
end
|
||||
|
||||
test "presence true encodes as present-with-null" do
|
||||
m = Widget.to_map(%Widget{premium: true})
|
||||
assert Map.has_key?(m, "premium")
|
||||
assert m["premium"] == nil
|
||||
end
|
||||
|
||||
test "full round-trip is stable for a junk-free map" do
|
||||
first = Widget.from_map(full_wire_map())
|
||||
round_tripped = first |> Widget.to_map() |> Widget.from_map()
|
||||
assert round_tripped == first
|
||||
end
|
||||
end
|
||||
|
||||
describe "merge_map partial update (Task 6)" do
|
||||
test "api-surface.AC2.8: updates only keys present in the partial map" do
|
||||
w = Widget.from_map(full_wire_map())
|
||||
merged = Widget.merge_map(w, %{"name" => "renamed"})
|
||||
|
||||
assert merged.name == "renamed"
|
||||
|
||||
# every other field is untouched
|
||||
assert merged.id == w.id
|
||||
assert merged.count == w.count
|
||||
assert merged.enabled == w.enabled
|
||||
assert merged.created_at == w.created_at
|
||||
assert merged.gadget == w.gadget
|
||||
assert merged.gadgets == w.gadgets
|
||||
assert merged.tag_ids == w.tag_ids
|
||||
assert merged.parent == :absent
|
||||
assert merged.premium == w.premium
|
||||
assert merged.gadget_full == w.gadget_full
|
||||
end
|
||||
|
||||
test "present-as-null sets nil, absent key leaves value alone" do
|
||||
w = Widget.from_map(full_wire_map())
|
||||
assert w.parent == :absent
|
||||
|
||||
# present with null -> nil
|
||||
assert Widget.merge_map(w, %{"parent" => nil}).parent == nil
|
||||
|
||||
# absent key leaves the existing :absent alone (not reset)
|
||||
assert Widget.merge_map(w, %{}).parent == :absent
|
||||
|
||||
# count present-as-null -> nil, and only count changes
|
||||
merged = Widget.merge_map(w, %{"count" => nil})
|
||||
assert merged.count == nil
|
||||
assert merged.name == w.name
|
||||
end
|
||||
|
||||
test "non-map partial returns the struct unchanged" do
|
||||
w = Widget.from_map(full_wire_map())
|
||||
assert Widget.merge_map(w, "junk") == w
|
||||
end
|
||||
|
||||
test "unknown key in partial changes nothing and mints no atom" do
|
||||
w = Widget.from_map(full_wire_map())
|
||||
merged = Widget.merge_map(w, %{"totally_unknown_merge_key_xy99" => 1})
|
||||
assert merged == w
|
||||
|
||||
assert_raise ArgumentError, fn ->
|
||||
String.to_existing_atom("totally_unknown_merge_key_xy99")
|
||||
end
|
||||
end
|
||||
|
||||
test "merge decode parity: nested struct decodes like from_map" do
|
||||
w = Widget.from_map(full_wire_map())
|
||||
merged = Widget.merge_map(w, %{"gadget" => %{"id" => 5, "name" => "x"}})
|
||||
assert merged.gadget == %Gadget{id: 5, name: "x"}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue