From 2de7f6d733009eb7974d45b9ebd52d9c8a3dbd67 Mon Sep 17 00:00:00 2001 From: Denis Donici Date: Sun, 22 Feb 2026 15:19:14 +0200 Subject: [PATCH] chore: added svelte encoder --- README.md | 17 + .../live/live_breaking_news_test.exs | 12 +- lib/live_svelte.ex | 11 +- lib/live_svelte/encoder.ex | 378 ++++++++++++++++++ lib/live_svelte/json.ex | 7 +- mix.exs | 3 + mix.lock | 3 + test/live_svelte/encoder_test.exs | 165 ++++++++ 8 files changed, 590 insertions(+), 6 deletions(-) create mode 100644 lib/live_svelte/encoder.ex create mode 100644 test/live_svelte/encoder_test.exs diff --git a/README.md b/README.md index c7d0b4c..523055f 100644 --- a/README.md +++ b/README.md @@ -561,6 +561,23 @@ you don't need `@derive Jason.Encoder` when using the default native JSON encode If you're using Jason and need custom struct encoding behavior, see the [Structs and Ecto](#structs-and-ecto) section for details on `@derive Jason.Encoder`. +#### LiveSvelte.Encoder protocol + +All props pass through the `LiveSvelte.Encoder` protocol before JSON encoding. You can control which struct fields are sent to Svelte with `@derive`: + +```elixir +defmodule User do + @derive {LiveSvelte.Encoder, except: [:password]} + defstruct [:name, :email, :password] +end +``` + +- `@derive LiveSvelte.Encoder` — encode all fields except `__struct__` +- `@derive {LiveSvelte.Encoder, only: [:name, :email]}` — encode only listed keys +- `@derive {LiveSvelte.Encoder, except: [:password]}` — encode all except listed keys + +Phoenix.HTML.Form, Ecto.Changeset, and Phoenix LiveView upload structs have built-in encoders. Date/Time types are encoded as ISO8601 strings. + ### live_json LiveSvelte has support for [live_json](https://github.com/Miserlou/live_json). diff --git a/example_project/test/example_web/live/live_breaking_news_test.exs b/example_project/test/example_web/live/live_breaking_news_test.exs index fd2267c..1395cba 100644 --- a/example_project/test/example_web/live/live_breaking_news_test.exs +++ b/example_project/test/example_web/live/live_breaking_news_test.exs @@ -13,8 +13,10 @@ defmodule ExampleWeb.LiveBreakingNewsTest do end defp wait_for_headline_with_text(session, text, attempts \\ 50) do - items = headline_items(session) - found = Enum.any?(items, fn el -> Wallaby.Element.text(el) =~ text end) + # Use container text to avoid stale refs when DOM updates (e.g. after Add/Remove). + container = session |> find(Query.css("[data-testid='breaking-news-headlines']")) + container_text = Wallaby.Element.text(container) + found = String.contains?(container_text, text) cond do found -> session attempts == 0 -> raise "timeout waiting for headline containing #{inspect(text)}" @@ -85,8 +87,10 @@ defmodule ExampleWeb.LiveBreakingNewsTest do end defp wait_for_headline_removed(session, text, attempts) do - items = headline_items(session) - found = Enum.any?(items, fn el -> Wallaby.Element.text(el) =~ text end) + # Use container text to avoid stale refs: after Remove, li elements are detached from DOM. + container = session |> find(Query.css("[data-testid='breaking-news-headlines']")) + container_text = Wallaby.Element.text(container) + found = String.contains?(container_text, text) cond do not found -> session attempts == 0 -> raise "timeout waiting for headline #{inspect(text)} to be removed" diff --git a/lib/live_svelte.ex b/lib/live_svelte.ex index 902fdd7..e3ed492 100644 --- a/lib/live_svelte.ex +++ b/lib/live_svelte.ex @@ -151,7 +151,16 @@ defmodule LiveSvelte do defp json(props) do json_library = Application.get_env(:live_svelte, :json_library, LiveSvelte.JSON) - json_library.encode!(props) + + # Ensure props pass through LiveSvelte.Encoder for all JSON libraries. + # LiveSvelte.JSON already runs the encoder internally, so avoid double work. + if json_library == LiveSvelte.JSON do + json_library.encode!(props) + else + props + |> LiveSvelte.Encoder.encode([]) + |> json_library.encode!() + end end # --- Deterministic ID generation ------------------------------------------------ diff --git a/lib/live_svelte/encoder.ex b/lib/live_svelte/encoder.ex new file mode 100644 index 0000000..f5c0a27 --- /dev/null +++ b/lib/live_svelte/encoder.ex @@ -0,0 +1,378 @@ +defprotocol LiveSvelte.Encoder do + @moduledoc """ + Protocol for encoding values for LiveSvelte JSON serialization. + + Transforms structs and other terms into JSON-compatible data (maps, lists, + primitives) before the configured JSON library encodes to a string. Supports + `@derive` with `:only` and `:except` to control which struct fields are + encoded. By default all keys except `:__struct__` are encoded. + + ## Deriving + + * `@derive LiveSvelte.Encoder` — encode all struct fields except `:__struct__` + * `@derive {LiveSvelte.Encoder, only: [:a, :b]}` — encode only listed keys + * `@derive {LiveSvelte.Encoder, except: [:secret]}` — encode all except listed keys + + ## Example + + defmodule User do + @derive {LiveSvelte.Encoder, except: [:password]} + defstruct [:name, :email, :password] + end + + For structs you don't own, use `Protocol.derive/3` outside the module. + """ + + @type t :: term() + @type opts :: Keyword.t() + @fallback_to_any true + + @doc "Encodes a value to a JSON-compatible term (map, list, or primitive)." + @spec encode(t(), opts()) :: any() + def encode(value, opts \\ []) +end + +defimpl LiveSvelte.Encoder, for: Integer do + def encode(value, _opts), do: value +end + +defimpl LiveSvelte.Encoder, for: Float do + def encode(value, _opts), do: value +end + +defimpl LiveSvelte.Encoder, for: BitString do + def encode(value, _opts), do: value +end + +defimpl LiveSvelte.Encoder, for: Atom do + def encode(atom, _opts), do: atom +end + +defimpl LiveSvelte.Encoder, for: List do + def encode(list, opts) do + Enum.map(list, &LiveSvelte.Encoder.encode(&1, opts)) + end +end + +defimpl LiveSvelte.Encoder, for: Map do + def encode(map, opts) do + Map.new(map, fn {key, value} -> + {key, LiveSvelte.Encoder.encode(value, opts)} + end) + end +end + +defimpl LiveSvelte.Encoder, for: [Date, Time, NaiveDateTime, DateTime] do + def encode(value, _opts) do + @for.to_iso8601(value) + end +end + +defimpl LiveSvelte.Encoder, for: Phoenix.HTML.Form do + def encode(%Phoenix.HTML.Form{} = form, opts) do + LiveSvelte.Encoder.encode( + %{ + name: form.name, + values: encode_form_values(form, opts), + errors: encode_form_errors(form) || %{}, + valid: get_form_validity(form) + }, + opts + ) + rescue + error in [Protocol.UndefinedError] -> + reraise maybe_enhance_error(error), __STACKTRACE__ + end + + defp get_form_validity(%{source: %{valid?: valid}}), do: valid + defp get_form_validity(_), do: true + + if Code.ensure_loaded?(Ecto.Association.NotLoaded) do + defp maybe_enhance_error(%{value: %Ecto.Association.NotLoaded{}} = error) do + Map.update!(error, :description, fn description -> + [first | rest] = String.split(description, "\n\n") + addition = "\n\nEncode form with LiveSvelte.Encoder.encode(form, nilify_not_loaded: true) to avoid." + Enum.join([first | [addition | rest]], "\n\n") + end) + end + + defp maybe_enhance_error(error), do: error + else + defp maybe_enhance_error(error), do: error + end + + if Code.ensure_loaded?(Ecto.Changeset) do + @relations [:embed, :assoc] + + defp collect_changeset_values(%Ecto.Changeset{} = source, opts) do + data = Map.new(source.types, fn {field, type} -> {field, get_field_value(source, field, type, opts)} end) + result = if is_struct(source.data), do: Map.merge(source.data, data), else: data + Map.delete(result, :__meta__) + end + + defp get_field_value(source, field, {tag, %{cardinality: :one}}, opts) when tag in @relations do + case Map.fetch(source.changes, field) do + {:ok, nil} -> nil + {:ok, %Ecto.Changeset{} = changeset} -> collect_changeset_values(changeset, opts) + :error -> + case Map.fetch!(source.data, field) do + %Ecto.Association.NotLoaded{} = not_loaded -> + if opts[:nilify_not_loaded], do: nil, else: not_loaded + %{__meta__: _} = value -> Map.delete(value, :__meta__) + value -> value + end + end + end + + defp get_field_value(source, field, {tag, %{cardinality: :many}}, opts) when tag in @relations do + case Map.fetch(source.changes, field) do + {:ok, changesets} -> + changesets + |> Enum.filter(&(&1.params != nil)) + |> Enum.map(&collect_changeset_values(&1, opts)) + :error -> + case Map.fetch!(source.data, field) do + %Ecto.Association.NotLoaded{} = not_loaded -> + if opts[:nilify_not_loaded], do: nil, else: not_loaded + [%{__meta__: _} | _] = value -> Enum.map(value, &Map.delete(&1, :__meta__)) + value -> value + end + end + end + + defp get_field_value(source, field, _type, _opts) do + Phoenix.HTML.FormData.Ecto.Changeset.input_value(source, %{params: source.params}, field) + end + + if Code.ensure_loaded?(Phoenix.HTML.FormData.Ecto.Changeset) do + def encode_form_values(%{impl: Phoenix.HTML.FormData.Ecto.Changeset, source: source}, opts) do + source |> collect_changeset_values(opts) |> LiveSvelte.Encoder.encode(opts) + end + end + end + + def encode_form_values(form, opts) do + base_values = + form.hidden + |> Map.new() + |> Map.merge(form.data) + |> Map.merge(Map.new(form.params)) + + LiveSvelte.Encoder.encode(base_values, opts) + end + + if Code.ensure_loaded?(Ecto.Changeset) do + defp collect_changeset_errors(%Ecto.Changeset{} = changeset) do + errors = translate_errors(changeset.errors) + Enum.reduce(changeset.changes, errors, fn {field, value}, acc -> + case Map.get(changeset.types, field) do + {tag, %{cardinality: :one}} when tag in @relations -> + embed_errors = collect_changeset_errors(value) + if embed_errors == %{}, do: acc, else: Map.put(acc, field, embed_errors) + + {tag, %{cardinality: :many}} when tag in @relations -> + list_errors = + value + |> Enum.filter(&(&1.params != nil)) + |> Enum.map(fn embed_changeset -> + embed_errors = collect_changeset_errors(embed_changeset) + if embed_errors == %{}, do: nil, else: embed_errors + end) + if Enum.all?(list_errors, &is_nil/1), do: acc, else: Map.put(acc, field, list_errors) + + _ -> acc + end + end) + end + + if Code.ensure_loaded?(Phoenix.HTML.FormData.Ecto.Changeset) do + def encode_form_errors(%{impl: Phoenix.HTML.FormData.Ecto.Changeset} = form) do + collect_changeset_errors(form.source) + end + end + end + + def encode_form_errors(form) do + translate_errors(form.errors) + end + + defp translate_errors(errors) do + Map.new(errors, fn {field, error} -> + {field, error |> List.wrap() |> Enum.map(&translate_error/1)} + end) + end + + defp translate_error({msg, opts}) do + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", value |> List.wrap() |> Enum.map_join(", ", &to_string/1)) + end) + end +end + +if Code.ensure_loaded?(Ecto.Changeset) do + defimpl LiveSvelte.Encoder, for: Ecto.Changeset do + def encode(%Ecto.Changeset{} = cs, opts) do + LiveSvelte.Encoder.encode( + %{ + params: cs.params, + changes: cs.changes, + errors: changeset_errors_to_map(cs), + valid?: cs.valid? + }, + opts + ) + end + + defp changeset_errors_to_map(%Ecto.Changeset{} = changeset) do + errors = changeset_errors_to_list(changeset.errors) + + Enum.reduce(changeset.changes, errors, fn {field, value}, acc -> + case Map.get(changeset.types, field) do + {:embed, %{cardinality: :one}} when is_struct(value, Ecto.Changeset) -> + embed_errors = changeset_errors_to_map(value) + if embed_errors == %{}, do: acc, else: Map.put(acc, field, embed_errors) + + {:embed, %{cardinality: :many}} when is_list(value) -> + list_errors = + value + |> Enum.filter(&match?(%Ecto.Changeset{}, &1)) + |> Enum.map(fn embed_cs -> + embed_errors = changeset_errors_to_map(embed_cs) + if embed_errors == %{}, do: nil, else: embed_errors + end) + if Enum.all?(list_errors, &is_nil/1), do: acc, else: Map.put(acc, field, list_errors) + + {:assoc, %{cardinality: :one}} when is_struct(value, Ecto.Changeset) -> + embed_errors = changeset_errors_to_map(value) + if embed_errors == %{}, do: acc, else: Map.put(acc, field, embed_errors) + + {:assoc, %{cardinality: :many}} when is_list(value) -> + list_errors = + value + |> Enum.filter(&match?(%Ecto.Changeset{}, &1)) + |> Enum.map(fn assoc_cs -> + assoc_errors = changeset_errors_to_map(assoc_cs) + if assoc_errors == %{}, do: nil, else: assoc_errors + end) + if Enum.all?(list_errors, &is_nil/1), do: acc, else: Map.put(acc, field, list_errors) + + _ -> + acc + end + end) + end + + defp changeset_errors_to_list(errors) do + Map.new(errors, fn {field, error} -> + {field, error |> List.wrap() |> Enum.map(&error_tuple_to_message/1)} + end) + end + + defp error_tuple_to_message({msg, opts}) do + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", value |> List.wrap() |> Enum.map_join(", ", &to_string/1)) + end) + end + end +end + +if Code.ensure_loaded?(Phoenix.LiveView) do + defimpl LiveSvelte.Encoder, for: Phoenix.LiveView.UploadConfig do + def encode(%Phoenix.LiveView.UploadConfig{} = struct, opts) do + errors = + Enum.map(struct.errors, fn {key, value} -> + %{ref: key, error: LiveSvelte.Encoder.encode(value, opts)} + end) + + entries = + Enum.map(struct.entries, fn entry -> + encoded = LiveSvelte.Encoder.encode(entry, opts) + entry_errors = errors |> Enum.filter(&(&1.ref == entry.ref)) |> Enum.map(& &1.error) + Map.put(encoded, :errors, entry_errors) + end) + + LiveSvelte.Encoder.encode( + %{ + ref: struct.ref, + name: struct.name, + accept: struct.accept, + max_entries: struct.max_entries, + auto_upload: struct.auto_upload?, + entries: entries, + errors: errors + }, + opts + ) + end + end + + defimpl LiveSvelte.Encoder, for: Phoenix.LiveView.UploadEntry do + def encode(%Phoenix.LiveView.UploadEntry{} = struct, opts) do + LiveSvelte.Encoder.encode( + %{ + ref: struct.ref, + client_name: struct.client_name, + client_size: struct.client_size, + client_type: struct.client_type, + progress: struct.progress, + done: struct.done?, + valid: struct.valid?, + preflighted: struct.preflighted? + }, + opts + ) + end + end +end + +defimpl LiveSvelte.Encoder, for: Any do + defmacro __deriving__(module, struct, opts) do + fields = fields_to_encode(struct, opts) + + quote do + defimpl LiveSvelte.Encoder, for: unquote(module) do + def encode(struct, opts) do + struct + |> Map.take(unquote(fields)) + |> LiveSvelte.Encoder.encode(opts) + end + end + end + end + + # Default for structs without explicit impl: encode all keys except __struct__ and __meta__ + # (matches @derive LiveSvelte.Encoder default; __meta__ stripped for Ecto schemas). + def encode(%{__struct__: _module} = struct, opts) do + keys = Map.keys(struct) -- [:__struct__, :__meta__] + struct + |> Map.take(keys) + |> LiveSvelte.Encoder.encode(opts) + end + + def encode(value, _opts), do: value + + defp fields_to_encode(struct, opts) do + fields = Map.keys(struct) + + cond do + only = Keyword.get(opts, :only) -> + case only -- fields do + [] -> only + error_keys -> + raise ArgumentError, + ":only specified keys (#{inspect(error_keys)}) not in defstruct: #{inspect(fields -- [:__struct__])}" + end + + except = Keyword.get(opts, :except) -> + case except -- fields do + [] -> fields -- [:__struct__ | except] + error_keys -> + raise ArgumentError, + ":except specified keys (#{inspect(error_keys)}) not in defstruct: #{inspect(fields -- [:__struct__])}" + end + + true -> + fields -- [:__struct__] + end + end +end diff --git a/lib/live_svelte/json.ex b/lib/live_svelte/json.ex index 4b785c1..b6bd0c5 100644 --- a/lib/live_svelte/json.ex +++ b/lib/live_svelte/json.ex @@ -48,6 +48,7 @@ defmodule LiveSvelte.JSON do @spec encode!(term()) :: binary() def encode!(term) do term + |> LiveSvelte.Encoder.encode([]) |> prepare_term() |> :json.encode() |> IO.iodata_to_binary() @@ -76,7 +77,11 @@ defmodule LiveSvelte.JSON do """ @spec prepare(term()) :: term() - def prepare(term), do: prepare_term(term) + def prepare(term) do + term + |> LiveSvelte.Encoder.encode([]) + |> prepare_term() + end # Recursively prepare terms for JSON encoding. # Converts structs to maps, nil to null, and handles nested structures. diff --git a/mix.exs b/mix.exs index 1c3851a..cae1b48 100644 --- a/mix.exs +++ b/mix.exs @@ -11,6 +11,7 @@ defmodule LiveSvelte.MixProject do elixir: "~> 1.17", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, + consolidate_protocols: Mix.env() != :test, aliases: aliases(), deps: deps(), @@ -60,6 +61,8 @@ defmodule LiveSvelte.MixProject do defp deps do [ {:ex_doc, "~> 0.37.3", only: :dev, runtime: false}, + {:ecto, ">= 3.0.0", optional: true}, + {:phoenix_ecto, ">= 4.0.0", optional: true}, {:jason, "~> 1.2", optional: true}, {:lazy_html, ">= 0.1.0", only: :test}, {:nodejs, "~> 3.1"}, diff --git a/mix.lock b/mix.lock index 6e71fd4..c743473 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,9 @@ %{ "castore": {:hex, :castore, "1.0.0", "c25cd0794c054ebe6908a86820c8b92b5695814479ec95eeff35192720b71eec", [:mix], [], "hexpm", "577d0e855983a97ca1dfa33cbb8a3b6ece6767397ffb4861514343b078fc284b"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "esbuild": {:hex, :esbuild, "0.6.1", "a774bfa7b4512a1211bf15880b462be12a4c48ed753a170c68c63b2c95888150", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "569f7409fb5a932211573fc20e2a930a0d5cf3377c5b4f6506c651b1783a1678"}, "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, @@ -15,6 +17,7 @@ "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nodejs": {:hex, :nodejs, "3.1.0", "904c07b81a7b6077af35784df32ab36c62bd2b96edb91bfd04c157c21956cfa5", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5.1", [hex: :poolboy, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.7", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "a6b4480f3f266abb5927be8afacfc7809feefd7d1337fa3ce957d0b98eeeae52"}, "phoenix": {:hex, :phoenix, "1.7.0", "cbed113bdc203e2ced75859011fe7e71eeebb6259cefa54de810d9c7048b5e22", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.4", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "8526139d4bd79ec97c5c3c8e69f6cd663597f782756cec874ba7da5429c93e34"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_html": {:hex, :phoenix_html, "3.3.1", "4788757e804a30baac6b3fc9695bf5562465dd3f1da8eb8460ad5b404d9a2178", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bed1906edd4906a15fd7b412b85b05e521e1f67c9a85418c55999277e553d0d3"}, "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.15", "58137e648fca9da56d6e931c9c3001f895ff090291052035f395bc958b82f1a5", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "888dd8ea986bebbda741acc65aef788c384d13db91fea416461b2e96aa06a193"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, diff --git a/test/live_svelte/encoder_test.exs b/test/live_svelte/encoder_test.exs new file mode 100644 index 0000000..cbb6796 --- /dev/null +++ b/test/live_svelte/encoder_test.exs @@ -0,0 +1,165 @@ +defmodule LiveSvelte.EncoderTest do + use ExUnit.Case, async: true + + require Protocol + alias LiveSvelte.Encoder + + describe "encode/2 primitives" do + test "Integer passes through" do + assert Encoder.encode(42, []) == 42 + end + + test "Float passes through" do + assert Encoder.encode(3.14, []) == 3.14 + end + + test "BitString passes through" do + assert Encoder.encode("hello", []) == "hello" + end + + test "Atom passes through" do + assert Encoder.encode(:ok, []) == :ok + end + + test "List recurses" do + assert Encoder.encode([1, "a", :x], []) == [1, "a", :x] + assert Encoder.encode([%{a: 1}], []) == [%{a: 1}] + end + + test "Map recurses" do + assert Encoder.encode(%{a: 1, b: "two"}, []) == %{a: 1, b: "two"} + end + end + + describe "encode/2 Date/Time ISO8601" do + test "Date" do + d = ~D[2026-01-31] + assert Encoder.encode(d, []) == "2026-01-31" + end + + test "Time" do + t = ~T[14:30:00] + assert Encoder.encode(t, []) == "14:30:00" + end + + test "NaiveDateTime" do + ndt = ~N[2026-01-31 14:30:00] + assert Encoder.encode(ndt, []) == "2026-01-31T14:30:00" + end + + test "DateTime" do + dt = DateTime.from_naive!(~N[2026-01-31 14:30:00], "Etc/UTC") + assert Encoder.encode(dt, []) == "2026-01-31T14:30:00Z" + end + end + + describe "@derive LiveSvelte.Encoder (default: all except __struct__)" do + defmodule DeriveDefault do + defstruct [:a, :b, :c] + end + + Protocol.derive(Encoder, DeriveDefault) + + test "encodes all struct keys except __struct__" do + s = %DeriveDefault{a: 1, b: "two", c: :three} + assert Encoder.encode(s, []) == %{a: 1, b: "two", c: :three} + end + end + + describe "@derive {LiveSvelte.Encoder, only: keys}" do + defmodule DeriveOnly do + defstruct [:a, :b, :c] + end + + Protocol.derive(Encoder, DeriveOnly, only: [:a, :c]) + + test "encodes only specified keys" do + s = %DeriveOnly{a: 1, b: "secret", c: 3} + assert Encoder.encode(s, []) == %{a: 1, c: 3} + end + end + + describe "@derive {LiveSvelte.Encoder, except: keys}" do + defmodule DeriveExcept do + defstruct [:a, :b, :c] + end + + Protocol.derive(Encoder, DeriveExcept, except: [:b]) + + test "encodes all except specified keys" do + s = %DeriveExcept{a: 1, b: "secret", c: 3} + assert Encoder.encode(s, []) == %{a: 1, c: 3} + end + end + + describe "Phoenix.HTML.Form" do + test "encodes name, values, errors, valid" do + form = %Phoenix.HTML.Form{ + name: "user", + source: %{}, + params: %{"name" => "alice", "email" => "a@b.com"}, + data: %{}, + hidden: %{}, + impl: Phoenix.HTML.FormData.Map, + id: "user-form", + errors: [name: {"can't be blank", []}], + options: [] + } + + encoded = Encoder.encode(form, []) + assert encoded.name == "user" + assert encoded.valid == true + assert encoded.values["name"] == "alice" + assert encoded.values["email"] == "a@b.com" + assert encoded.errors[:name] == ["can't be blank"] + end + end + + describe "Ecto.Changeset (when Ecto loaded)" do + if Code.ensure_loaded?(Ecto) do + defmodule EncoderTestUser do + use Ecto.Schema + + schema "users" do + field :name, :string + field :email, :string + end + end + + test "encodes params, changes, errors, valid?" do + cs = + %EncoderTestUser{} + |> Ecto.Changeset.cast(%{name: "alice", email: "a@b.com"}, [:name, :email]) + |> Ecto.Changeset.add_error(:name, "can't be blank") + |> Ecto.Changeset.apply_action(:validate) + + case cs do + {:ok, _} -> flunk("expected invalid changeset") + {:error, changeset} -> + encoded = Encoder.encode(changeset, []) + assert encoded.valid? == false + assert encoded.params["name"] == "alice" + assert encoded.errors[:name] == ["can't be blank"] + end + end + end + end + + describe "nested structs" do + defmodule Inner do + defstruct [:x] + end + + defmodule Outer do + defstruct [:inner, :tag] + end + + Protocol.derive(Encoder, Inner) + Protocol.derive(Encoder, Outer) + + test "nested structs are encoded recursively" do + s = %Outer{inner: %Inner{x: 42}, tag: "outer"} + assert Encoder.encode(s, []) == %{inner: %{x: 42}, tag: "outer"} + end + end +end