diff --git a/dev/live_views/messages.ex b/dev/live_views/messages.ex index c5c075c8c..bd7368ae2 100644 --- a/dev/live_views/messages.ex +++ b/dev/live_views/messages.ex @@ -1,11 +1,20 @@ defmodule LiveDebuggerDev.LiveViews.Messages do use DevWeb, :live_view + def mount(_params, _session, socket) do + socket + |> assign(:messages, []) + |> ok() + end + + attr(:messages, :list, required: true) + def render(assigns) do ~H""" <.box title="Messages [LiveView]" color="purple">
<.button phx-click="big-message" color="purple">Send big message +
Message count: <%= length(@messages) %>
""" @@ -13,7 +22,12 @@ defmodule LiveDebuggerDev.LiveViews.Messages do def handle_event("big-message", _, socket) do send(self(), very_big_message()) - {:noreply, socket} + + socket + |> assign(:messages, [ + {length(socket.assigns.messages), very_big_message()} | socket.assigns.messages + ]) + |> noreply() end def handle_info(_, socket) do diff --git a/lib/live_debugger/app/debugger/callback_tracing/web/components/trace.ex b/lib/live_debugger/app/debugger/callback_tracing/web/components/trace.ex index 1705d7d62..4ed4fbd4d 100644 --- a/lib/live_debugger/app/debugger/callback_tracing/web/components/trace.ex +++ b/lib/live_debugger/app/debugger/callback_tracing/web/components/trace.ex @@ -50,11 +50,7 @@ defmodule LiveDebugger.App.Debugger.CallbackTracing.Web.Components.Trace do

<.copy_button id={"#{@id}-arg-#{index}"} value={TermParser.term_to_copy_string(args)} /> - "-#{index}"} - node={TermParser.term_to_display_tree(args)} - level={1} - /> + "-#{index}"} node={TermParser.term_to_display_tree(args)} /> <% end %> diff --git a/lib/live_debugger/app/debugger/node_state/queries.ex b/lib/live_debugger/app/debugger/node_state/queries.ex index dc42ab4a8..70a404e85 100644 --- a/lib/live_debugger/app/debugger/node_state/queries.ex +++ b/lib/live_debugger/app/debugger/node_state/queries.ex @@ -9,11 +9,11 @@ defmodule LiveDebugger.App.Debugger.NodeState.Queries do alias LiveDebugger.App.Debugger.Structs.TreeNode @spec fetch_node_assigns(pid :: pid(), node_id :: TreeNode.id()) :: - {:ok, %{node_assigns: map()}} | {:error, term()} + {:ok, map()} | {:error, term()} def fetch_node_assigns(pid, node_id) when is_pid(node_id) do case fetch_node_state(pid) do {:ok, %LvState{socket: %{assigns: assigns}}} -> - {:ok, %{node_assigns: assigns}} + {:ok, assigns} {:error, reason} -> {:error, reason} @@ -48,8 +48,8 @@ defmodule LiveDebugger.App.Debugger.NodeState.Queries do nil -> {:error, "Component with CID #{cid} not found"} - component -> - {:ok, %{node_assigns: component.assigns}} + %{assigns: assigns} -> + {:ok, assigns} end end end diff --git a/lib/live_debugger/app/debugger/node_state/web/components.ex b/lib/live_debugger/app/debugger/node_state/web/components.ex index 50e93aa78..44c1f86d7 100644 --- a/lib/live_debugger/app/debugger/node_state/web/components.ex +++ b/lib/live_debugger/app/debugger/node_state/web/components.ex @@ -6,8 +6,8 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.Components do use LiveDebugger.App.Web, :component alias LiveDebugger.App.Debugger.Web.Components.ElixirDisplay - alias LiveDebugger.App.Utils.TermParser - alias LiveDebugger.App.Debugger.NodeState.Web.AssignsSearch + alias LiveDebugger.App.Debugger.NodeState.Web.HookComponents.AssignsSearch + alias LiveDebugger.App.Utils.TermNode alias LiveDebugger.Utils.Memory def loading(assigns) do @@ -27,6 +27,8 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.Components do end attr(:assigns, :list, required: true) + attr(:term_node, TermNode, required: true) + attr(:copy_string, :string, required: true) attr(:fullscreen_id, :string, required: true) attr(:assigns_search_phrase, :string, default: "") @@ -40,11 +42,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.Components do assigns_search_phrase={@assigns_search_phrase} input_id="assigns-search-input" /> - <.copy_button - id="assigns-copy-button" - variant="icon-button" - value={TermParser.term_to_copy_string(@assigns)} - /> + <.copy_button id="assigns-copy-button" variant="icon-button" value={@copy_string} /> <.fullscreen_button id={@fullscreen_id} /> @@ -56,7 +54,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.Components do
<.assigns_size_label assigns={@assigns} id="display-container-size-label" />
- + <.fullscreen id={@fullscreen_id} title="Assigns"> @@ -74,10 +72,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.Components do
<.assigns_size_label assigns={@assigns} id="display-fullscreen-size-label" />
- + diff --git a/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_search.ex b/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_search.ex index 9adcacc65..3344639d6 100644 --- a/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_search.ex +++ b/lib/live_debugger/app/debugger/node_state/web/hook_components/assigns_search.ex @@ -1,4 +1,4 @@ -defmodule LiveDebugger.App.Debugger.NodeState.Web.AssignsSearch do +defmodule LiveDebugger.App.Debugger.NodeState.Web.HookComponents.AssignsSearch do @moduledoc """ This component is used to add search functionality for assigns. It produces `search` and `search-submit` events handled by hook added via `init/1`. diff --git a/lib/live_debugger/app/debugger/node_state/web/hooks/node_assigns.ex b/lib/live_debugger/app/debugger/node_state/web/hooks/node_assigns.ex new file mode 100644 index 000000000..c21faa273 --- /dev/null +++ b/lib/live_debugger/app/debugger/node_state/web/hooks/node_assigns.ex @@ -0,0 +1,121 @@ +defmodule LiveDebugger.App.Debugger.NodeState.Web.Hooks.NodeAssigns do + @moduledoc """ + This hook is responsible for fetching assigns of specific node. + """ + + use LiveDebugger.App.Web, :hook + + alias Phoenix.LiveView.AsyncResult + alias LiveDebugger.App.Debugger.NodeState.Queries, as: NodeStateQueries + alias LiveDebugger.App.Utils.TermDiffer + alias LiveDebugger.App.Utils.TermDiffer.Diff + alias LiveDebugger.App.Utils.TermParser + + @required_assigns [ + :node_id, + :lv_process + ] + + @doc """ + Initializes the hook by attaching the hook to the socket and checking the required assigns. + """ + @spec init(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() + def init(socket) do + socket + |> check_assigns!(@required_assigns) + |> attach_hook(:node_assigns, :handle_async, &handle_async/3) + |> register_hook(:node_assigns) + |> assign(:node_assigns_info, AsyncResult.loading()) + |> assign_async_node_assigns() + end + + @doc """ + Assigns the async node assigns to the socket. + + ## Options + + - `:reset` - If `true`, assigns won't diff current assigns with new ones. + """ + @spec assign_async_node_assigns(Phoenix.LiveView.Socket.t(), Keyword.t()) :: + Phoenix.LiveView.Socket.t() + def assign_async_node_assigns(socket, opts \\ []) + + def assign_async_node_assigns( + %{assigns: %{node_id: node_id, lv_process: %{pid: pid}}} = socket, + opts + ) + when not is_nil(node_id) do + node_assigns_info = + if Keyword.get(opts, :reset, false) do + AsyncResult.loading() + else + socket.assigns.node_assigns_info + end + + socket + |> assign(:node_assigns_info, node_assigns_info) + |> start_async(:fetch_node_assigns, fn -> + NodeStateQueries.fetch_node_assigns(pid, node_id) + end) + end + + def assign_async_node_assigns(socket, _) do + socket + |> assign(:node_assigns_info, AsyncResult.failed(%AsyncResult{}, :no_node_id)) + end + + defp handle_async( + :fetch_node_assigns, + {:ok, {:ok, node_assigns}}, + %{ + assigns: %{ + node_assigns_info: %AsyncResult{ok?: true, result: {old_assigns, old_term_node, _}} + } + } = + socket + ) do + node_assigns_info = + case TermDiffer.diff(old_assigns, node_assigns) do + %Diff{type: :equal} -> + AsyncResult.ok(socket.assigns.node_assigns_info.result) + + diff -> + copy_string = TermParser.term_to_copy_string(node_assigns) + + case TermParser.update_by_diff(old_term_node, diff) do + {:ok, term_node} -> + AsyncResult.ok({node_assigns, term_node, copy_string}) + + {:error, reason} -> + AsyncResult.failed(socket.assigns.node_assigns_info, reason) + end + end + + socket + |> assign(:node_assigns_info, node_assigns_info) + |> halt() + end + + defp handle_async(:fetch_node_assigns, {:ok, {:ok, node_assigns}}, socket) do + term_node = TermParser.term_to_display_tree(node_assigns) + copy_string = TermParser.term_to_copy_string(node_assigns) + + socket + |> assign(:node_assigns_info, AsyncResult.ok({node_assigns, term_node, copy_string})) + |> halt() + end + + defp handle_async(:fetch_node_assigns, {:ok, {:error, reason}}, socket) do + socket + |> assign(:node_assigns_info, AsyncResult.failed(socket.assigns.node_assigns_info, reason)) + |> halt() + end + + defp handle_async(:fetch_node_assigns, {:exit, reason}, socket) do + socket + |> assign(:node_assigns_info, AsyncResult.failed(socket.assigns.node_assigns_info, reason)) + |> halt() + end + + defp handle_async(_, _, socket), do: {:cont, socket} +end diff --git a/lib/live_debugger/app/debugger/node_state/web/hooks/term_node_toggle.ex b/lib/live_debugger/app/debugger/node_state/web/hooks/term_node_toggle.ex new file mode 100644 index 000000000..36f974062 --- /dev/null +++ b/lib/live_debugger/app/debugger/node_state/web/hooks/term_node_toggle.ex @@ -0,0 +1,44 @@ +defmodule LiveDebugger.App.Debugger.NodeState.Web.Hooks.TermNodeToggle do + @moduledoc """ + Hook that can be used to toggle the open state of a `assigns` term node. + """ + + use LiveDebugger.App.Web, :hook + + alias LiveDebugger.App.Utils.TermNode + alias LiveDebugger.App.Utils.TermParser + alias Phoenix.LiveView.AsyncResult + + @required_assigns [:node_assigns_info] + + @spec init(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() + def init(socket) do + socket + |> check_assigns!(@required_assigns) + |> attach_hook(:term_node_toggle, :handle_event, &handle_event/3) + |> register_hook(:term_node_toggle) + end + + defp handle_event("toggle_node", %{"id" => id}, socket) do + node_assigns_info = + with %AsyncResult{ok?: true, result: {node_assigns, term_node, copy_string}} <- + socket.assigns.node_assigns_info, + {:ok, updated_term_node} <- + TermParser.update_by_id(term_node, id, &%TermNode{&1 | open?: !&1.open?}) do + AsyncResult.ok({node_assigns, updated_term_node, copy_string}) + else + {:error, reason} -> + AsyncResult.failed(socket.assigns.node_assigns_info, reason) + + _ -> + socket.assigns.node_assigns_info + end + + socket + |> assign(:node_assigns_info, node_assigns_info) + |> halt() + end + + defp handle_event("toggle_node", _, socket), do: {:halt, socket} + defp handle_event(_, _, socket), do: {:cont, socket} +end diff --git a/lib/live_debugger/app/debugger/node_state/web/node_state_live.ex b/lib/live_debugger/app/debugger/node_state/web/node_state_live.ex index 1283d67ec..ed6f05784 100644 --- a/lib/live_debugger/app/debugger/node_state/web/node_state_live.ex +++ b/lib/live_debugger/app/debugger/node_state/web/node_state_live.ex @@ -6,12 +6,10 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.NodeStateLive do use LiveDebugger.App.Web, :live_view - alias Phoenix.LiveView.AsyncResult alias LiveDebugger.Structs.LvProcess + alias LiveDebugger.App.Debugger.NodeState.Web.Hooks + alias LiveDebugger.App.Debugger.NodeState.Web.HookComponents alias LiveDebugger.App.Debugger.NodeState.Web.Components, as: NodeStateComponents - alias LiveDebugger.App.Debugger.NodeState.Queries, as: NodeStateQueries - - alias LiveDebugger.App.Debugger.NodeState.Web.AssignsSearch alias LiveDebugger.Bus alias LiveDebugger.App.Debugger.Events.NodeIdParamChanged @@ -65,8 +63,9 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.NodeStateLive do |> assign(:lv_process, lv_process) |> assign(:node_id, node_id) |> assign(:assigns_search_phrase, "") - |> assign_async_node_assigns() - |> AssignsSearch.init() + |> Hooks.NodeAssigns.init() + |> Hooks.TermNodeToggle.init() + |> HookComponents.AssignsSearch.init() |> ok() end @@ -74,7 +73,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.NodeStateLive do def render(assigns) do ~H"""
- <.async_result :let={node_assigns} assign={@node_assigns}> + <.async_result :let={{node_assigns, term_node, copy_string}} assign={@node_assigns_info}> <:loading> @@ -83,6 +82,8 @@ defmodule LiveDebugger.App.Debugger.NodeState.Web.NodeStateLive do assign(:node_id, node_id) - |> assign_async_node_assigns() + |> Hooks.NodeAssigns.assign_async_node_assigns(reset: true) |> noreply() end def handle_info(%StateChanged{}, socket) do socket - |> assign_async_node_assigns() + |> Hooks.NodeAssigns.assign_async_node_assigns() |> noreply() end def handle_info(_, socket), do: {:noreply, socket} - - defp assign_async_node_assigns( - %{assigns: %{node_id: node_id, lv_process: %{pid: pid}}} = socket - ) - when not is_nil(node_id) do - assign_async(socket, :node_assigns, fn -> - NodeStateQueries.fetch_node_assigns(pid, node_id) - end) - end - - defp assign_async_node_assigns(socket) do - assign(socket, :node, AsyncResult.failed(%AsyncResult{}, :no_node_id)) - end end diff --git a/lib/live_debugger/app/debugger/web/components/elixir_display.ex b/lib/live_debugger/app/debugger/web/components/elixir_display.ex index 32346be24..85d76542b 100644 --- a/lib/live_debugger/app/debugger/web/components/elixir_display.ex +++ b/lib/live_debugger/app/debugger/web/components/elixir_display.ex @@ -6,10 +6,8 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do use LiveDebugger.App.Web, :component - alias LiveDebugger.App.Utils.TermParser.DisplayElement - alias LiveDebugger.App.Utils.TermParser.TermNode - - @max_auto_expand_size 6 + alias LiveDebugger.App.Utils.TermNode.DisplayElement + alias LiveDebugger.App.Utils.TermNode @doc """ Returns a tree of terms. @@ -17,13 +15,12 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do attr(:id, :string, required: true) attr(:node, TermNode, required: true) - attr(:level, :integer, default: 1) def term(assigns) do assigns = assigns - |> assign(:expanded?, auto_expand?(assigns.node, assigns.level)) - |> assign(:has_children?, has_children?(assigns.node)) + |> assign(:id, "#{assigns.id}-#{assigns.node.id}") + |> assign(:has_children?, TermNode.has_children?(assigns.node)) ~H"""
@@ -33,7 +30,7 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do <.collapsible :if={@has_children?} id={@id <> "collapsible"} - open={@expanded?} + open={@node.open?} icon="icon-chevron-right" label_class="max-w-max" chevron_class="text-code-2 m-auto w-[2ch] h-[2ch]" @@ -50,11 +47,9 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do
    - <%= for {child, index} <- Enum.with_index(@node.children) do %> -
  1. - <.term id={@id <> "-#{index}"} node={child} level={@level + 1} /> -
  2. - <% end %> +
  3. + <.term id={@id} node={child} /> +
<.text_items items={@node.expanded_after} /> @@ -64,6 +59,48 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do """ end + attr(:node, TermNode, required: true) + + def static_term(assigns) do + assigns = + assigns + |> assign(:has_children?, TermNode.has_children?(assigns.node)) + + ~H""" +
+ <%= if @has_children? do %> + <.static_collapsible + open={@node.open?} + label_class="max-w-max" + chevron_class="text-code-2 m-auto w-[2ch] h-[2ch]" + phx-click="toggle_node" + phx-value-id={@node.id} + > + <:label :let={open}> + <%= if open do %> + <.text_items items={@node.expanded_before} /> + <% else %> + <.text_items items={@node.content} /> + <% end %> + +
    +
  1. + <.static_term node={child} /> +
  2. +
+
+ <.text_items items={@node.expanded_after} /> +
+ + <% else %> +
+ <.text_items items={@node.content} /> +
+ <% end %> +
+ """ + end + attr(:items, :list, required: true) defp text_items(assigns) do @@ -81,16 +118,4 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do defp text_item_color_class(%DisplayElement{color: color}) do if color, do: "#{color}", else: "" end - - defp auto_expand?(%TermNode{}, 1), do: true - - defp auto_expand?(%TermNode{} = node, _level) do - node.kind == :tuple and children_number(node) <= @max_auto_expand_size - end - - defp has_children?(%TermNode{children: []}), do: false - defp has_children?(%TermNode{}), do: true - - defp children_number(%TermNode{children: nil}), do: 0 - defp children_number(%TermNode{children: children}), do: length(children) end diff --git a/lib/live_debugger/app/utils/term_differ.ex b/lib/live_debugger/app/utils/term_differ.ex index bbf4f449c..12cc213b8 100644 --- a/lib/live_debugger/app/utils/term_differ.ex +++ b/lib/live_debugger/app/utils/term_differ.ex @@ -54,7 +54,7 @@ defmodule LiveDebugger.App.Utils.TermDiffer do Struct for representing a diff between two terms. """ - defstruct [:type, ins: %{}, del: %{}, diff: %{}] + defstruct type: :equal, ins: %{}, del: %{}, diff: %{} @type type() :: :map | :list | :tuple | :struct | :primitive | :equal @type key() :: any() @@ -72,6 +72,14 @@ defmodule LiveDebugger.App.Utils.TermDiffer do """ def primitive_key, do: @primitive_key + @doc """ + Returns the new value for `primitive` type. + """ + @spec primitive_new_value(Diff.t()) :: term() + def primitive_new_value(%Diff{type: :primitive, ins: %{@primitive_key => new_value}}) do + new_value + end + @doc """ Calculates the diff between two terms. """ diff --git a/lib/live_debugger/app/utils/term_node.ex b/lib/live_debugger/app/utils/term_node.ex new file mode 100644 index 000000000..8271c5bae --- /dev/null +++ b/lib/live_debugger/app/utils/term_node.ex @@ -0,0 +1,142 @@ +defmodule LiveDebugger.App.Utils.TermNode do + @moduledoc """ + Represents a node in the display tree. + Based on [Kino.Tree](https://github.com/livebook-dev/kino/blob/main/lib/kino/tree.ex) + + - `id`: The id of the node. It uses dot notation to represent the path to the node. + - `open?`: Whether the node is expanded + - `kind`: The type of the node (e.g., :atom, :list, :map). + - `children`: A map of child nodes with keys (integer indices for lists/tuples, actual keys for maps). + - `content`: Display elements that represent the content of the node when has no children or not expanded. + - `expanded_before`: Display elements shown before the node's children when expanded. + - `expanded_after`: Display elements shown after the node's children when expanded. + """ + + defmodule DisplayElement do + @moduledoc false + defstruct [:text, color: nil] + + @type t :: %__MODULE__{ + text: String.t(), + color: String.t() | nil + } + + @spec blue(String.t()) :: t() + def blue(text), do: %__MODULE__{text: text, color: "text-code-1"} + + @spec black(String.t()) :: t() + def black(text), do: %__MODULE__{text: text, color: "text-code-2"} + + @spec magenta(String.t()) :: t() + def magenta(text), do: %__MODULE__{text: text, color: "text-code-3"} + + @spec green(String.t()) :: t() + def green(text), do: %__MODULE__{text: text, color: "text-code-4"} + end + + defstruct [:id, :kind, :children, :content, :expanded_before, :expanded_after, open?: false] + + @type kind() :: :atom | :binary | :number | :tuple | :list | :map | :struct | :regex | :other + + @type t :: %__MODULE__{ + id: String.t(), + kind: kind(), + open?: boolean(), + children: [{any(), t()}], + content: [DisplayElement.t()], + expanded_before: [DisplayElement.t()], + expanded_after: [DisplayElement.t()] + } + + @type ok_error() :: {:ok, t()} | {:error, any()} + + @spec new(kind(), [DisplayElement.t()], Keyword.t()) :: t() + def new(kind, content, args \\ []) do + children = Keyword.get(args, :children, []) + expanded_before = Keyword.get(args, :expanded_before, []) + expanded_after = Keyword.get(args, :expanded_after, []) + open? = Keyword.get(args, :open?, false) + id = Keyword.get(args, :id, "not_indexed") + + %__MODULE__{ + id: id, + kind: kind, + content: content, + children: children, + expanded_before: expanded_before, + expanded_after: expanded_after, + open?: open? + } + end + + @spec comma_suffix() :: DisplayElement.t() + def comma_suffix(), do: DisplayElement.black(",") + + @spec has_children?(t()) :: boolean() + def has_children?(%__MODULE__{children: []}), do: false + def has_children?(%__MODULE__{}), do: true + + @spec children_number(t()) :: integer() + def children_number(%__MODULE__{children: children}), do: length(children) + + @spec add_suffix(t(), [DisplayElement.t()]) :: t() + def add_suffix( + %__MODULE__{content: content, expanded_after: expanded_after} = term_node, + suffix + ) + when is_list(suffix) do + content = content ++ suffix + expanded_after = expanded_after ++ suffix + %__MODULE__{term_node | content: content, expanded_after: expanded_after} + end + + @spec remove_suffix!(t()) :: t() + def remove_suffix!(%__MODULE__{content: [_ | _], expanded_after: [_ | _]} = term_node) do + content = term_node.content |> Enum.reverse() |> tl() |> Enum.reverse() + expanded_after = term_node.expanded_after |> Enum.reverse() |> tl() |> Enum.reverse() + + %__MODULE__{term_node | content: content, expanded_after: expanded_after} + end + + def remove_suffix!(%__MODULE__{}), do: raise("Term node has no suffix to remove") + + @spec add_prefix(t(), [DisplayElement.t()]) :: t() + def add_prefix( + %__MODULE__{content: content, expanded_before: expanded_before} = term_node, + prefix + ) + when is_list(prefix) do + content = prefix ++ content + expanded_before = prefix ++ expanded_before + %__MODULE__{term_node | content: content, expanded_before: expanded_before} + end + + @spec remove_child(t(), any()) :: ok_error() + def remove_child(%__MODULE__{children: children} = term, key) do + children + |> Enum.find_index(fn {child_key, _} -> child_key == key end) + |> case do + nil -> + {:error, :child_not_found} + + index -> + children = List.delete_at(children, index) + {:ok, %__MODULE__{term | children: children}} + end + end + + @spec update_child(t(), any(), (t() -> t())) :: ok_error() + def update_child(%__MODULE__{children: children} = term, key, update_fn) do + children + |> Enum.with_index() + |> Enum.filter(fn {{child_key, _}, _} -> child_key == key end) + |> case do + [{{^key, child}, index}] -> + children = List.replace_at(children, index, {key, update_fn.(child)}) + {:ok, %__MODULE__{term | children: children}} + + _ -> + {:error, :child_not_found} + end + end +end diff --git a/lib/live_debugger/app/utils/term_parser.ex b/lib/live_debugger/app/utils/term_parser.ex index c3bf59c8e..6a6bbd4b2 100644 --- a/lib/live_debugger/app/utils/term_parser.ex +++ b/lib/live_debugger/app/utils/term_parser.ex @@ -1,44 +1,18 @@ defmodule LiveDebugger.App.Utils.TermParser do @moduledoc """ - This module provides functions to parse terms into display tree. - Based on [Kino.Tree](https://github.com/livebook-dev/kino/blob/main/lib/kino/tree.ex) + This module provides functions to parse elixir terms. """ - defmodule DisplayElement do - @moduledoc false - defstruct [:text, color: nil] + alias LiveDebugger.App.Utils.TermDiffer.Diff + alias LiveDebugger.App.Utils.TermDiffer + alias LiveDebugger.App.Utils.TermNode.DisplayElement + alias LiveDebugger.App.Utils.TermNode - @type t :: %__MODULE__{ - text: String.t(), - color: String.t() | nil - } - end - - defmodule TermNode do - @moduledoc """ - Represents a node in the display tree. - - - `id`: The id of the node (it represents the path to the node in the tree). - - `kind`: The type of the node (e.g., :atom, :list, :map). - - `children`: A list of child nodes. - - `content`: Display elements that represent the content of the node when has no children or not expanded. - - `expanded_before`: Display elements shown before the node's children when expanded. - - `expanded_after`: Display elements shown after the node's children when expanded. - """ - defstruct [:id, :kind, :children, :content, :expanded_before, :expanded_after] - - @type kind() :: :atom | :binary | :number | :tuple | :list | :map | :struct | :regex | :other - - @type t :: %__MODULE__{ - id: String.t(), - kind: kind(), - children: [t()], - content: [DisplayElement.t()], - expanded_before: [DisplayElement.t()] | nil, - expanded_after: [DisplayElement.t()] | nil - } - end + @list_and_tuple_open_limit 3 + @doc """ + Convert term into infinite string which can be copied to IEx console. + """ @spec term_to_copy_string(term()) :: String.t() def term_to_copy_string(term) do term @@ -50,178 +24,404 @@ defmodule LiveDebugger.App.Utils.TermParser do |> String.replace(~r/#.+?<.*?>/, &"\"#{&1}\"") end + @doc """ + It creates a TermNode tree ready for display. + + It includes: + - Indexing the tree + - Adding comma suffixes + - Opening proper elements + """ @spec term_to_display_tree(term()) :: TermNode.t() def term_to_display_tree(term) do - to_node(term, [], "root") + term + |> to_node() + |> index_term_node() + |> update_comma_suffixes() + |> default_open_settings() + end + + @doc """ + Updates the term node by id. + + ## Examples + + iex> term_node = TermParser.term_to_display_tree(term) + iex> update_by_id(term_node, "root.0", fn term_node -> + ...> %{term_node | content: [DisplayElement.blue("new value")]} + ...> end) + {:ok, %TermNode{...}} + """ + @spec update_by_id(TermNode.t(), String.t(), (TermNode.t() -> TermNode.t())) :: + TermNode.ok_error() + def update_by_id(term_node, path, update_fn) + + def update_by_id(term_node, "root", update_fn) do + {:ok, update_fn.(term_node)} + end + + def update_by_id(term_node, "root" <> _ = id, update_fn) do + ["root" | string_path] = String.split(id, ".") + path = string_path |> Enum.map(&String.to_integer/1) + update_by_path(term_node, path, update_fn) + end + + @doc """ + Updates the term node using calculated term #{Diff}. + + ## Examples + + iex> term_node = TermParser.term_to_display_tree(term) + iex> diff = TermDiffer.diff(term, new_term) + iex> update_by_diff(term_node, diff) + {:ok, %TermNode{...}} + """ + @spec update_by_diff(TermNode.t(), Diff.t()) :: TermNode.ok_error() + def update_by_diff(term_node, diff) do + term_node = + term_node + |> update_by_diff!(diff) + |> index_term_node() + |> update_comma_suffixes() + + {:ok, term_node} + rescue + error -> + {:error, "Invalid diff or term node: #{inspect(error)}"} + end + + @spec update_by_path(TermNode.t(), [integer()], (TermNode.t() -> TermNode.t())) :: + TermNode.ok_error() + defp update_by_path(term, [index | path], update_fn) do + with {key, child} <- Enum.at(term.children, index), + {:ok, updated_child} <- update_by_path(child, path, update_fn) do + children = List.replace_at(term.children, index, {key, updated_child}) + {:ok, %TermNode{term | children: children}} + else + nil -> + {:error, :child_not_found} + + {:error, reason} -> + {:error, reason} + end + end + + defp update_by_path(term, [], update_fn) do + {:ok, update_fn.(term)} + end + + @spec update_by_diff!(TermNode.t(), Diff.t(), Keyword.t()) :: TermNode.t() + defp update_by_diff!(term_node, diff, opts \\ []) + + defp update_by_diff!(term_node, %Diff{type: :equal}, _opts), do: term_node + + defp update_by_diff!(_, %Diff{type: :primitive} = diff, opts) do + term = TermDiffer.primitive_new_value(diff) + + case Keyword.get(opts, :key, nil) do + nil -> + to_node(term) + + key -> + {key, term} + |> to_key_value_node() + |> elem(1) + end + end + + defp update_by_diff!(term_node, %Diff{type: type, ins: ins, del: del}, _opts) + when type in [:list, :tuple] do + term_node + |> term_node_reduce_del(del) + |> term_node_reduce_list_ins(ins) + end + + defp update_by_diff!(term_node, %Diff{type: :struct, diff: diff}, _opts) do + term_node_reduce_diff!(term_node, diff) + end + + defp update_by_diff!(term_node, %Diff{type: :map, ins: ins, del: del, diff: diff}, _opts) do + term_node + |> term_node_reduce_del(del) + |> term_node_reduce_map_ins(ins) + |> term_node_reduce_diff!(diff) + end + + defp term_node_reduce_del(term_node, del) do + Enum.reduce(del, term_node, fn {key, _}, term_node_acc -> + {:ok, term_node_acc} = TermNode.remove_child(term_node_acc, key) + term_node_acc + end) + end + + defp term_node_reduce_list_ins(term_node, ins) do + Enum.reduce(ins, term_node, fn {index, term}, term_node_acc -> + children = List.insert_at(term_node_acc.children, index, {index, to_node(term)}) + %TermNode{term_node_acc | children: children} + end) + end + + defp term_node_reduce_map_ins(term_node, ins) do + child_keys = term_node.children |> Enum.map(fn {key, _} -> key end) + + {term_node_acc, _} = + Enum.reduce(ins, {term_node, child_keys}, fn {key, term}, {term_node_acc, child_keys} -> + child_keys = Enum.sort(child_keys ++ [key]) + index = Enum.find_index(child_keys, &(&1 == key)) + + children = List.insert_at(term_node_acc.children, index, to_key_value_node({key, term})) + {%TermNode{term_node_acc | children: children}, child_keys} + end) + + term_node_acc end - @spec to_node(term(), [DisplayElement.t()], String.t()) :: TermNode.t() - defp to_node(string, suffix, id_path) when is_binary(string) do - leaf_node(id_path, :binary, [green(inspect(string)) | suffix]) + defp term_node_reduce_diff!(term_node, diff) do + Enum.reduce(diff, term_node, fn {key, child_diff}, term_node_acc -> + {:ok, term_node_acc} = + TermNode.update_child(term_node_acc, key, fn child -> + update_by_diff!(child, child_diff, key: key) + end) + + term_node_acc + end) end - defp to_node(atom, suffix, id_path) when is_atom(atom) do + @spec index_term_node(TermNode.t()) :: TermNode.t() + defp index_term_node(%TermNode{children: children} = term_node, id_path \\ "root") do + new_children = + children + |> Enum.with_index() + |> Enum.map(fn {{key, child}, idx} -> + {key, index_term_node(child, "#{id_path}.#{idx}")} + end) + + %TermNode{term_node | children: new_children, id: id_path} + end + + @spec update_comma_suffixes(TermNode.t()) :: TermNode.t() + defp update_comma_suffixes(%TermNode{children: []} = term_node), do: term_node + + defp update_comma_suffixes(%TermNode{children: children} = term_node) do + size = length(children) + + children = + Enum.with_index(children, fn + {key, child}, index -> + child = update_comma_suffixes(child) + + last_child? = index == size - 1 + + child = + case {last_child?, has_comma_suffix?(child)} do + {true, true} -> + TermNode.remove_suffix!(child) + + {false, false} -> + TermNode.add_suffix(child, [TermNode.comma_suffix()]) + + _ -> + child + end + + {key, child} + end) + + %TermNode{term_node | children: children} + end + + @spec to_node(term()) :: TermNode.t() + defp to_node(string) when is_binary(string) do + TermNode.new(:binary, [DisplayElement.green(inspect(string))]) + end + + defp to_node(atom) when is_atom(atom) do span = if atom in [nil, true, false] do - magenta(inspect(atom)) + DisplayElement.magenta(inspect(atom)) else - blue(inspect(atom)) + DisplayElement.blue(inspect(atom)) end - leaf_node(id_path, :atom, [span | suffix]) + TermNode.new(:atom, [span]) end - defp to_node(number, suffix, id_path) when is_number(number) do - leaf_node(id_path, :number, [blue(inspect(number)) | suffix]) + defp to_node(number) when is_number(number) do + TermNode.new(:number, [DisplayElement.blue(inspect(number))]) end - defp to_node({}, suffix, id_path) do - leaf_node(id_path, :tuple, [black("{}") | suffix]) + defp to_node({}) do + TermNode.new(:tuple, [DisplayElement.black("{}")], + open?: false, + expanded_before: [DisplayElement.black("{")], + expanded_after: [DisplayElement.black("}")] + ) end - defp to_node(tuple, suffix, id_path) when is_tuple(tuple) do - size = tuple_size(tuple) - children = tuple |> Tuple.to_list() |> to_children(size, id_path) + defp to_node(tuple) when is_tuple(tuple) do + children = tuple |> Tuple.to_list() |> to_children() - branch_node(id_path, :tuple, [black("{...}") | suffix], children, [black("{")], [ - black("}") | suffix - ]) + TermNode.new(:tuple, [DisplayElement.black("{...}")], + children: children, + expanded_before: [DisplayElement.black("{")], + expanded_after: [DisplayElement.black("}")] + ) end - defp to_node([], suffix, id_path) do - leaf_node(id_path, :list, [black("[]") | suffix]) + defp to_node([]) do + TermNode.new(:list, [DisplayElement.black("[]")], + open?: false, + expanded_before: [DisplayElement.black("[")], + expanded_after: [DisplayElement.black("]")] + ) end - defp to_node(list, suffix, id_path) when is_list(list) do - size = length(list) - + defp to_node(list) when is_list(list) do children = if Keyword.keyword?(list) do - to_key_value_children(list, size, id_path) + to_key_value_children(list) else - to_children(list, size, id_path) + to_children(list) end - branch_node(id_path, :list, [black("[...]") | suffix], children, [black("[")], [ - black("]") | suffix - ]) + TermNode.new(:list, [DisplayElement.black("[...]")], + children: children, + expanded_before: [DisplayElement.black("[")], + expanded_after: [DisplayElement.black("]")] + ) end - defp to_node(%Regex{} = regex, suffix, id_path) do - leaf_node(id_path, :regex, [black(inspect(regex)) | suffix]) + defp to_node(%Regex{} = regex) do + TermNode.new(:regex, [DisplayElement.black(inspect(regex))]) end - defp to_node(%module{} = struct, suffix, id_path) when is_struct(struct) do + defp to_node(%module{} = struct) when is_struct(struct) do content = if Inspect.impl_for(struct) in [Inspect.Any, Inspect.Phoenix.LiveView.Socket] do - [black("%"), blue(inspect(module)), black("{...}") | suffix] + [ + DisplayElement.black("%"), + DisplayElement.blue(inspect(module)), + DisplayElement.black("{...}") + ] else - [black(inspect(struct)) | suffix] + [DisplayElement.black(inspect(struct))] end - map = Map.from_struct(struct) - size = map_size(map) - children = to_key_value_children(map, size, id_path) + children = + struct + |> Map.from_struct() + |> Map.to_list() + |> to_key_value_children() - branch_node( - id_path, + TermNode.new( :struct, content, - children, - [black("%"), blue(inspect(module)), black("{")], - [black("}") | suffix] + children: children, + expanded_before: [ + DisplayElement.black("%"), + DisplayElement.blue(inspect(module)), + DisplayElement.black("{") + ], + expanded_after: [DisplayElement.black("}")] ) end - defp to_node(%{} = map, suffix, id_path) when map_size(map) == 0 do - leaf_node(id_path, :map, [black("%{}") | suffix]) + defp to_node(%{} = map) when map_size(map) == 0 do + TermNode.new(:map, [DisplayElement.black("%{}")], + expanded_before: [DisplayElement.black("%{")], + expanded_after: [DisplayElement.black("}")] + ) end - defp to_node(map, suffix, id_path) when is_map(map) do - size = map_size(map) - children = map |> Enum.sort() |> to_key_value_children(size, id_path) + defp to_node(map) when is_map(map) do + children = map |> Enum.sort() |> to_key_value_children() - branch_node(id_path, :map, [black("%{...}") | suffix], children, [black("%{")], [ - black("}") | suffix - ]) + TermNode.new(:map, [DisplayElement.black("%{...}")], + children: children, + expanded_before: [DisplayElement.black("%{")], + expanded_after: [DisplayElement.black("}")] + ) end - defp to_node(other, suffix, id_path) do - leaf_node(id_path, :other, [black(inspect(other)) | suffix]) + defp to_node(other) do + TermNode.new(:other, [DisplayElement.black(inspect(other))]) end - defp to_key_value_node({key, value}, suffix, id_path) do + defp to_key_value_node({key, value}) do {key_span, sep_span} = - case to_node(key, [], "not_used_id_path") do + case to_node(key) do %TermNode{content: [%DisplayElement{text: ":" <> name} = span]} when is_atom(key) -> - {%{span | text: name <> ":"}, black(" ")} + {%{span | text: name <> ":"}, DisplayElement.black(" ")} %TermNode{content: [span]} -> - {%{span | text: inspect(key, width: :infinity)}, black(" => ")} + {%{span | text: inspect(key, width: :infinity)}, DisplayElement.black(" => ")} %TermNode{content: _content} -> {%DisplayElement{text: inspect(key, width: :infinity), color: "text-code-1"}, - black(" => ")} + DisplayElement.black(" => ")} end - case to_node(value, suffix, id_path) do - %TermNode{content: content, children: []} = node -> - %TermNode{node | content: [key_span, sep_span | content]} + node = value |> to_node() |> TermNode.add_prefix([key_span, sep_span]) - %TermNode{content: content, expanded_before: expanded_before} = node -> - %TermNode{ - node - | content: [key_span, sep_span | content], - expanded_before: [key_span, sep_span | expanded_before] - } - end + {key, node} end - defp to_children(items, container_size, id_path) do + defp to_children(items) when is_list(items) do Enum.with_index(items, fn item, index -> - to_node(item, suffix(index, container_size), "#{id_path}.#{index}") + {index, to_node(item)} end) end - defp to_key_value_children(items, container_size, id_path) do - Enum.with_index(items, fn item, index -> - to_key_value_node(item, suffix(index, container_size), "#{id_path}.#{index}") - end) + defp to_key_value_children(items) when is_list(items) do + Enum.map(items, &to_key_value_node/1) end - defp suffix(index, container_size) do - if index != container_size - 1 do - [black(",")] + defp default_open_settings(term_node) do + term_node + |> open_first_element() + |> open_small_lists_and_tuples() + end + + defp open_first_element(term_node) when is_struct(term_node, TermNode) do + %TermNode{term_node | open?: true} + end + + defp open_small_lists_and_tuples(%TermNode{kind: kind, children: children} = term_node) + when kind in [:list, :tuple] do + children = + Enum.map(children, fn {key, child} -> + {key, open_small_lists_and_tuples(child)} + end) + + if length(children) < @list_and_tuple_open_limit do + %TermNode{term_node | open?: true, children: children} else - [] + %TermNode{term_node | children: children} end end - defp leaf_node(id_path, kind, content) when is_binary(id_path) and is_atom(kind) do - %TermNode{ - id: id_path, - kind: kind, - content: content, - children: [], - expanded_before: nil, - expanded_after: nil - } + defp open_small_lists_and_tuples(%TermNode{children: children} = term_node) do + children = + Enum.map(children, fn {key, child} -> + {key, open_small_lists_and_tuples(child)} + end) + + %TermNode{term_node | children: children} + end + + defp has_comma_suffix?(%TermNode{content: content, expanded_after: expanded_after}) do + content? = last_item_equal?(content, TermNode.comma_suffix()) + expanded_after? = last_item_equal?(expanded_after, TermNode.comma_suffix()) + + if content? != expanded_after?, + do: raise("Content and expanded_after must have the same comma suffix") + + content? end - defp branch_node(id_path, kind, content, children, expanded_before, expanded_after) - when is_binary(id_path) and is_atom(kind) do - %TermNode{ - id: id_path, - kind: kind, - content: content, - children: children, - expanded_before: expanded_before, - expanded_after: expanded_after - } + defp last_item_equal?([_ | _] = items, item) do + items |> Enum.reverse() |> hd() |> Kernel.==(item) end - defp blue(text), do: %DisplayElement{text: text, color: "text-code-1"} - defp black(text), do: %DisplayElement{text: text, color: "text-code-2"} - defp magenta(text), do: %DisplayElement{text: text, color: "text-code-3"} - defp green(text), do: %DisplayElement{text: text, color: "text-code-4"} + defp last_item_equal?([], _), do: false end diff --git a/lib/live_debugger/app/web/components.ex b/lib/live_debugger/app/web/components.ex index 155bad397..673b087da 100644 --- a/lib/live_debugger/app/web/components.ex +++ b/lib/live_debugger/app/web/components.ex @@ -165,6 +165,45 @@ defmodule LiveDebugger.App.Web.Components do """ end + @doc """ + Static collapsible element. It doesn't perform any client-side actions. + + + ## Examples + + <.static_collapsible id="collapsible" open={true}> + <:label :let={open}> + <%= if(open, do: "Open", else: "Closed") %> + +
Content
+ + """ + + attr(:open, :boolean, required: true, doc: "State of the collapsible") + attr(:class, :any, default: nil, doc: "CSS class for parent container") + attr(:label_class, :any, default: nil, doc: "CSS class for the label") + attr(:chevron_class, :any, default: nil, doc: "CSS class for the chevron icon") + + attr(:rest, :global) + + slot(:label, required: true) + slot(:inner_block, required: true) + + def static_collapsible(assigns) do + ~H""" +
+
+ <.icon + name="icon-chevron-right" + class={["shrink-0", if(@open, do: "rotate-90") | List.wrap(@chevron_class)]} + /> + <%= render_slot(@label, @open) %> +
+ <%= if(@open, do: render_slot(@inner_block)) %> +
+ """ + end + @doc """ Renders flash notices. diff --git a/test/app/debugger/node_state/queries_test.exs b/test/app/debugger/node_state/queries_test.exs index 2dc7fe2c2..f09703fbb 100644 --- a/test/app/debugger/node_state/queries_test.exs +++ b/test/app/debugger/node_state/queries_test.exs @@ -18,7 +18,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.QueriesTest do MockAPIStatesStorage |> expect(:get!, fn ^pid -> %LvState{socket: %{assigns: assigns}} end) - assert {:ok, %{node_assigns: ^assigns}} = NodeStateQueries.fetch_node_assigns(pid, pid) + assert {:ok, ^assigns} = NodeStateQueries.fetch_node_assigns(pid, pid) end test "returns assigns for a valid LiveView node when not saved in storage" do @@ -32,7 +32,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.QueriesTest do |> expect(:socket, fn ^pid -> {:ok, %{assigns: assigns}} end) |> expect(:live_components, fn ^pid -> {:ok, []} end) - assert {:ok, %{node_assigns: ^assigns}} = NodeStateQueries.fetch_node_assigns(pid, pid) + assert {:ok, ^assigns} = NodeStateQueries.fetch_node_assigns(pid, pid) end test "returns assigns for a valid component CID when saved in storage" do @@ -43,7 +43,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.QueriesTest do MockAPIStatesStorage |> expect(:get!, fn ^pid -> %LvState{components: [%{cid: cid.cid, assigns: assigns}]} end) - assert {:ok, %{node_assigns: ^assigns}} = NodeStateQueries.fetch_node_assigns(pid, cid) + assert {:ok, ^assigns} = NodeStateQueries.fetch_node_assigns(pid, cid) end test "returns assigns for a valid component CID when not saved in storage" do @@ -58,7 +58,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.QueriesTest do |> expect(:socket, fn ^pid -> {:ok, %{}} end) |> expect(:live_components, fn ^pid -> {:ok, [%{cid: cid.cid, assigns: assigns}]} end) - assert {:ok, %{node_assigns: ^assigns}} = NodeStateQueries.fetch_node_assigns(pid, cid) + assert {:ok, ^assigns} = NodeStateQueries.fetch_node_assigns(pid, cid) end end end diff --git a/test/app/utils/term_node_test.exs b/test/app/utils/term_node_test.exs new file mode 100644 index 000000000..e9f9a5f65 --- /dev/null +++ b/test/app/utils/term_node_test.exs @@ -0,0 +1,173 @@ +defmodule LiveDebugger.App.Utils.TermNodeTest do + use ExUnit.Case, async: true + + alias LiveDebugger.App.Utils.TermNode + alias LiveDebugger.App.Utils.TermNode.DisplayElement + alias LiveDebugger.Fakes + + describe "new/3" do + test "creates a new term node with default values" do + content = [%DisplayElement{text: "atom", color: "text-code-1"}] + kind = :atom + + assert %TermNode{ + id: "not_indexed", + kind: :atom, + content: ^content, + children: [], + expanded_before: [], + expanded_after: [], + open?: false + } = TermNode.new(kind, content) + end + + test "creates a new term node with custom values" do + id = "custom_id" + content = [Fakes.display_element()] + kind = :atom + + children = [ + Fakes.term_node(id: "not_indexed", content: [Fakes.display_element(text: "child")]) + ] + + expanded_before = [Fakes.display_element(text: "expanded_before")] + expanded_after = [Fakes.display_element(text: "expanded_after")] + open? = true + + assert %TermNode{ + id: ^id, + kind: ^kind, + content: ^content, + children: ^children, + expanded_before: ^expanded_before, + expanded_after: ^expanded_after, + open?: ^open? + } = + TermNode.new(kind, content, + id: id, + children: children, + expanded_before: expanded_before, + expanded_after: expanded_after, + open?: open? + ) + end + end + + describe "has_children?/1" do + test "returns true if the term node has children" do + assert TermNode.has_children?(%TermNode{children: [1, 2, 3]}) + end + + test "returns false if the term node has no children" do + assert not TermNode.has_children?(%TermNode{children: []}) + end + end + + describe "children_number/1" do + test "returns the number of children" do + assert TermNode.children_number(%TermNode{children: [1, 2, 3]}) == 3 + end + + test "returns 0 if the term node has no children" do + assert TermNode.children_number(%TermNode{children: []}) == 0 + end + end + + describe "add_suffix/2 properly adds suffix to the term node's content and expanded_after" do + content = Fakes.display_element(text: "content") + + term_node = Fakes.term_node(id: "root", content: [content]) + + suffix = Fakes.display_element(text: "suffix") + + assert %TermNode{ + content: [^content, ^suffix], + children: [], + expanded_before: [], + expanded_after: [^suffix] + } = TermNode.add_suffix(term_node, [suffix]) + end + + describe "remove_suffix!/1" do + test "removes last element from the term node's content and expanded_after" do + suffix = Fakes.display_element(text: "suffix") + + term_node = Fakes.term_node(id: "root", content: [suffix], expanded_after: [suffix]) + + assert %TermNode{ + id: "root", + kind: :atom, + content: [], + children: [], + expanded_before: [], + expanded_after: [] + } = TermNode.remove_suffix!(term_node) + end + + test "raises an error if the term node has no suffix to remove" do + assert_raise RuntimeError, "Term node has no suffix to remove", fn -> + TermNode.remove_suffix!(%TermNode{content: [], expanded_after: []}) + end + end + end + + test "add_prefix/2 properly adds prefix to the term node's content and expanded_before" do + content = Fakes.display_element(text: "content") + + term_node = Fakes.term_node(id: "root", content: [content]) + + prefix = Fakes.display_element(text: "prefix") + + assert %TermNode{ + content: [^prefix, ^content], + children: [], + expanded_before: [^prefix], + expanded_after: [] + } = TermNode.add_prefix(term_node, [prefix]) + end + + describe "remove_child/2" do + test "removes the child from the term node and returns ok tuple" do + child2 = Fakes.term_node(id: "root.1") + child1 = Fakes.term_node(id: "root.0") + + term_node = Fakes.term_node(kind: :tuple, id: "root", children: [{0, child1}, {1, child2}]) + + assert {:ok, + %TermNode{ + id: "root", + kind: :tuple, + children: [{0, ^child1}] + }} = TermNode.remove_child(term_node, 1) + end + + test "returns error if the child is not found" do + child1 = Fakes.term_node(id: "root.0") + term_node = Fakes.term_node(kind: :tuple, id: "root", children: [{0, child1}]) + + assert {:error, :child_not_found} = TermNode.remove_child(term_node, 1) + end + end + + describe "update_child/3" do + test "updates the child and returns ok tuple" do + child = Fakes.term_node(id: "root.0") + term_node = Fakes.term_node(kind: :tuple, id: "root", children: [{0, child}]) + + updated_content = [Fakes.display_element(text: "updated")] + + update_function = fn child -> %TermNode{child | content: updated_content} end + + assert {:ok, %TermNode{children: [{0, %TermNode{content: ^updated_content}}]}} = + TermNode.update_child(term_node, 0, update_function) + end + + test "returns error if the child is not found" do + child = Fakes.term_node(id: "root.0") + term_node = Fakes.term_node(kind: :tuple, id: "root", children: [{0, child}]) + + assert {:error, :child_not_found} = + TermNode.update_child(term_node, 1, &Function.identity/1) + end + end +end diff --git a/test/app/utils/term_parser_test.exs b/test/app/utils/term_parser_test.exs index fb40c48d4..90233703a 100644 --- a/test/app/utils/term_parser_test.exs +++ b/test/app/utils/term_parser_test.exs @@ -1,9 +1,12 @@ defmodule LiveDebugger.App.Utils.TermParserTest do use ExUnit.Case, async: true - alias LiveDebugger.App.Utils.TermParser.DisplayElement - alias LiveDebugger.App.Utils.TermParser.TermNode + alias LiveDebugger.App.Utils.TermNode + alias LiveDebugger.App.Utils.TermNode.DisplayElement alias LiveDebugger.App.Utils.TermParser + alias LiveDebugger.App.Utils.TermDiffer + alias LiveDebugger.App.Utils.TermDiffer.Diff + alias LiveDebugger.Fakes defmodule TestStruct do defstruct [:field1, :field2] @@ -16,10 +19,11 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :binary, + open?: true, children: [], content: [%DisplayElement{text: "\"Hello, World!\"", color: "text-code-4"}], - expanded_before: nil, - expanded_after: nil + expanded_before: [], + expanded_after: [] } assert TermParser.term_to_display_tree(term) == expected @@ -31,10 +35,11 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :atom, + open?: true, children: [], content: [%DisplayElement{text: ":hello", color: "text-code-1"}], - expanded_before: nil, - expanded_after: nil + expanded_before: [], + expanded_after: [] } assert TermParser.term_to_display_tree(term) == expected @@ -46,10 +51,11 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :number, + open?: true, children: [], content: [%DisplayElement{text: "42", color: "text-code-1"}], - expanded_before: nil, - expanded_after: nil + expanded_before: [], + expanded_after: [] } assert TermParser.term_to_display_tree(term) == expected @@ -61,26 +67,31 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :tuple, + open?: true, children: [ - %TermNode{ - id: "root.0", - kind: :atom, - children: [], - content: [ - %DisplayElement{text: ":ok", color: "text-code-1"}, - %DisplayElement{text: ",", color: "text-code-2"} - ], - expanded_before: nil, - expanded_after: nil - }, - %TermNode{ - id: "root.1", - kind: :binary, - children: [], - content: [%DisplayElement{text: "\"Hello\"", color: "text-code-4"}], - expanded_before: nil, - expanded_after: nil - } + {0, + %TermNode{ + id: "root.0", + kind: :atom, + open?: false, + children: [], + content: [ + %DisplayElement{text: ":ok", color: "text-code-1"}, + %DisplayElement{text: ",", color: "text-code-2"} + ], + expanded_before: [], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] + }}, + {1, + %TermNode{ + id: "root.1", + kind: :binary, + open?: false, + children: [], + content: [%DisplayElement{text: "\"Hello\"", color: "text-code-4"}], + expanded_before: [], + expanded_after: [] + }} ], content: [%DisplayElement{text: "{...}", color: "text-code-2"}], expanded_before: [%DisplayElement{text: "{", color: "text-code-2"}], @@ -96,10 +107,11 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :tuple, + open?: true, children: [], content: [%DisplayElement{text: "{}", color: "text-code-2"}], - expanded_before: nil, - expanded_after: nil + expanded_before: [%DisplayElement{text: "{", color: "text-code-2"}], + expanded_after: [%DisplayElement{text: "}", color: "text-code-2"}] } assert TermParser.term_to_display_tree(term) == expected @@ -111,37 +123,44 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :list, + open?: true, children: [ - %TermNode{ - id: "root.0", - kind: :number, - children: [], - content: [ - %DisplayElement{text: "1", color: "text-code-1"}, - %DisplayElement{text: ",", color: "text-code-2"} - ], - expanded_before: nil, - expanded_after: nil - }, - %TermNode{ - id: "root.1", - kind: :number, - children: [], - content: [ - %DisplayElement{text: "2", color: "text-code-1"}, - %DisplayElement{text: ",", color: "text-code-2"} - ], - expanded_before: nil, - expanded_after: nil - }, - %TermNode{ - id: "root.2", - kind: :number, - children: [], - content: [%DisplayElement{text: "3", color: "text-code-1"}], - expanded_before: nil, - expanded_after: nil - } + {0, + %TermNode{ + id: "root.0", + kind: :number, + open?: false, + children: [], + content: [ + %DisplayElement{text: "1", color: "text-code-1"}, + %DisplayElement{text: ",", color: "text-code-2"} + ], + expanded_before: [], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] + }}, + {1, + %TermNode{ + id: "root.1", + kind: :number, + open?: false, + children: [], + content: [ + %DisplayElement{text: "2", color: "text-code-1"}, + %DisplayElement{text: ",", color: "text-code-2"} + ], + expanded_before: [], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] + }}, + {2, + %TermNode{ + id: "root.2", + kind: :number, + open?: false, + children: [], + content: [%DisplayElement{text: "3", color: "text-code-1"}], + expanded_before: [], + expanded_after: [] + }} ], content: [%DisplayElement{text: "[...]", color: "text-code-2"}], expanded_before: [%DisplayElement{text: "[", color: "text-code-2"}], @@ -157,10 +176,11 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :list, + open?: true, children: [], content: [%DisplayElement{text: "[]", color: "text-code-2"}], - expanded_before: nil, - expanded_after: nil + expanded_before: [%DisplayElement{text: "[", color: "text-code-2"}], + expanded_after: [%DisplayElement{text: "]", color: "text-code-2"}] } assert TermParser.term_to_display_tree(term) == expected @@ -172,10 +192,11 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :regex, + open?: true, children: [], content: [%DisplayElement{text: "~r/hello/", color: "text-code-2"}], - expanded_before: nil, - expanded_after: nil + expanded_before: [], + expanded_after: [] } assert TermParser.term_to_display_tree(term) == expected @@ -188,10 +209,11 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :atom, + open?: true, children: [], content: [%DisplayElement{text: inspect(term), color: "text-code-3"}], - expanded_before: nil, - expanded_after: nil + expanded_before: [], + expanded_after: [] } assert TermParser.term_to_display_tree(term) == expected @@ -204,10 +226,11 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :map, + open?: true, children: [], content: [%DisplayElement{text: "%{}", color: "text-code-2"}], - expanded_before: nil, - expanded_after: nil + expanded_before: [%DisplayElement{text: "%{", color: "text-code-2"}], + expanded_after: [%DisplayElement{text: "}", color: "text-code-2"}] } assert TermParser.term_to_display_tree(term) == expected @@ -221,10 +244,12 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :struct, + open?: true, children: [ - %TermNode{ + field1: %TermNode{ id: "root.0", kind: :binary, + open?: false, children: [], content: [ %DisplayElement{text: "field1:", color: "text-code-1"}, @@ -232,20 +257,27 @@ defmodule LiveDebugger.App.Utils.TermParserTest do %DisplayElement{text: "\"value1\"", color: "text-code-4"}, %DisplayElement{text: ",", color: "text-code-2"} ], - expanded_before: nil, - expanded_after: nil + expanded_before: [ + %DisplayElement{text: "field1:", color: "text-code-1"}, + %DisplayElement{text: " ", color: "text-code-2"} + ], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] }, - %TermNode{ + field2: %TermNode{ id: "root.1", kind: :number, + open?: false, children: [], content: [ %DisplayElement{text: "field2:", color: "text-code-1"}, %DisplayElement{text: " ", color: "text-code-2"}, %DisplayElement{text: "42", color: "text-code-1"} ], - expanded_before: nil, - expanded_after: nil + expanded_before: [ + %DisplayElement{text: "field2:", color: "text-code-1"}, + %DisplayElement{text: " ", color: "text-code-2"} + ], + expanded_after: [] } ], content: [ @@ -269,10 +301,13 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", + kind: :struct, + open?: true, children: [ - %TermNode{ + calendar: %TermNode{ id: "root.0", kind: :atom, + open?: false, children: [], content: [ %DisplayElement{text: "calendar:", color: "text-code-1"}, @@ -280,12 +315,16 @@ defmodule LiveDebugger.App.Utils.TermParserTest do %DisplayElement{text: "Calendar.ISO", color: "text-code-1"}, %DisplayElement{text: ",", color: "text-code-2"} ], - expanded_before: nil, - expanded_after: nil + expanded_before: [ + %DisplayElement{text: "calendar:", color: "text-code-1"}, + %DisplayElement{text: " ", color: "text-code-2"} + ], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] }, - %TermNode{ + month: %TermNode{ id: "root.1", kind: :number, + open?: false, children: [], content: [ %DisplayElement{text: "month:", color: "text-code-1"}, @@ -293,12 +332,16 @@ defmodule LiveDebugger.App.Utils.TermParserTest do %DisplayElement{text: "5", color: "text-code-1"}, %DisplayElement{text: ",", color: "text-code-2"} ], - expanded_before: nil, - expanded_after: nil + expanded_before: [ + %DisplayElement{text: "month:", color: "text-code-1"}, + %DisplayElement{text: " ", color: "text-code-2"} + ], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] }, - %TermNode{ + day: %TermNode{ id: "root.2", kind: :number, + open?: false, children: [], content: [ %DisplayElement{text: "day:", color: "text-code-1"}, @@ -306,20 +349,27 @@ defmodule LiveDebugger.App.Utils.TermParserTest do %DisplayElement{text: "10", color: "text-code-1"}, %DisplayElement{text: ",", color: "text-code-2"} ], - expanded_before: nil, - expanded_after: nil + expanded_before: [ + %DisplayElement{text: "day:", color: "text-code-1"}, + %DisplayElement{text: " ", color: "text-code-2"} + ], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] }, - %TermNode{ + year: %TermNode{ id: "root.3", kind: :number, + open?: false, children: [], content: [ %DisplayElement{text: "year:", color: "text-code-1"}, %DisplayElement{text: " ", color: "text-code-2"}, %DisplayElement{text: "2023", color: "text-code-1"} ], - expanded_before: nil, - expanded_after: nil + expanded_before: [ + %DisplayElement{text: "year:", color: "text-code-1"}, + %DisplayElement{text: " ", color: "text-code-2"} + ], + expanded_after: [] } ], content: [%DisplayElement{text: "~D[2023-05-10]", color: "text-code-2"}], @@ -328,8 +378,7 @@ defmodule LiveDebugger.App.Utils.TermParserTest do %DisplayElement{text: "Date", color: "text-code-1"}, %DisplayElement{text: "{", color: "text-code-2"} ], - expanded_after: [%DisplayElement{text: "}", color: "text-code-2"}], - kind: :struct + expanded_after: [%DisplayElement{text: "}", color: "text-code-2"}] } assert TermParser.term_to_display_tree(term) == expected @@ -345,45 +394,61 @@ defmodule LiveDebugger.App.Utils.TermParserTest do expected = %TermNode{ id: "root", kind: :map, + open?: true, children: [ - %TermNode{ - id: "root.0", - kind: :binary, - children: [], - content: [ - %DisplayElement{text: "\"key1\"", color: "text-code-4"}, - %DisplayElement{text: " => ", color: "text-code-2"}, - %DisplayElement{text: "\"value1\"", color: "text-code-4"}, - %DisplayElement{text: ",", color: "text-code-2"} - ], - expanded_before: nil, - expanded_after: nil - }, - %TermNode{ - id: "root.1", - kind: :number, - children: [], - content: [ - %DisplayElement{text: "\"key2\"", color: "text-code-4"}, - %DisplayElement{text: " => ", color: "text-code-2"}, - %DisplayElement{text: "42", color: "text-code-1"}, - %DisplayElement{text: ",", color: "text-code-2"} - ], - expanded_before: nil, - expanded_after: nil - }, - %TermNode{ - id: "root.2", - kind: :atom, - children: [], - content: [ - %DisplayElement{text: "\"key3\"", color: "text-code-4"}, - %DisplayElement{text: " => ", color: "text-code-2"}, - %DisplayElement{text: ":test", color: "text-code-1"} - ], - expanded_before: nil, - expanded_after: nil - } + {"key1", + %TermNode{ + id: "root.0", + kind: :binary, + open?: false, + children: [], + content: [ + %DisplayElement{text: "\"key1\"", color: "text-code-4"}, + %DisplayElement{text: " => ", color: "text-code-2"}, + %DisplayElement{text: "\"value1\"", color: "text-code-4"}, + %DisplayElement{text: ",", color: "text-code-2"} + ], + expanded_before: [ + %DisplayElement{text: "\"key1\"", color: "text-code-4"}, + %DisplayElement{text: " => ", color: "text-code-2"} + ], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] + }}, + {"key2", + %TermNode{ + id: "root.1", + kind: :number, + open?: false, + children: [], + content: [ + %DisplayElement{text: "\"key2\"", color: "text-code-4"}, + %DisplayElement{text: " => ", color: "text-code-2"}, + %DisplayElement{text: "42", color: "text-code-1"}, + %DisplayElement{text: ",", color: "text-code-2"} + ], + expanded_before: [ + %DisplayElement{text: "\"key2\"", color: "text-code-4"}, + %DisplayElement{text: " => ", color: "text-code-2"} + ], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] + }}, + {"key3", + %TermNode{ + id: "root.2", + kind: :atom, + open?: false, + children: [], + content: [ + %DisplayElement{text: "\"key3\"", color: "text-code-4"}, + %DisplayElement{text: " => ", color: "text-code-2"}, + %DisplayElement{text: ":test", color: "text-code-1"} + ], + expanded_before: [ + %DisplayElement{text: "\"key3\"", color: "text-code-4"}, + %DisplayElement{text: " => ", color: "text-code-2"} + ], + expanded_after: [] + }} ], content: [%DisplayElement{text: "%{...}", color: "text-code-2"}], expanded_before: [%DisplayElement{text: "%{", color: "text-code-2"}], @@ -399,35 +464,48 @@ defmodule LiveDebugger.App.Utils.TermParserTest do Date.new(2025, 7, 8) => "Date" } + {{:ok, date}, cid} = {Date.new(2025, 7, 8), %Phoenix.LiveComponent.CID{cid: 1}} + expected = %TermNode{ id: "root", kind: :map, + open?: true, children: [ - %TermNode{ - id: "root.0", - kind: :binary, - children: [], - content: [ - %DisplayElement{text: "{:ok, ~D[2025-07-08]}", color: "text-code-2"}, - %DisplayElement{text: " => ", color: "text-code-2"}, - %DisplayElement{text: "\"Date\"", color: "text-code-4"}, - %DisplayElement{text: ",", color: "text-code-2"} - ], - expanded_before: nil, - expanded_after: nil - }, - %TermNode{ - id: "root.1", - kind: :binary, - children: [], - content: [ - %DisplayElement{text: "%Phoenix.LiveComponent.CID{cid: 1}", color: "text-code-1"}, - %DisplayElement{text: " => ", color: "text-code-2"}, - %DisplayElement{text: "\"CID\"", color: "text-code-4"} - ], - expanded_before: nil, - expanded_after: nil - } + {{:ok, date}, + %TermNode{ + id: "root.0", + kind: :binary, + open?: false, + children: [], + content: [ + %DisplayElement{text: "{:ok, ~D[2025-07-08]}", color: "text-code-2"}, + %DisplayElement{text: " => ", color: "text-code-2"}, + %DisplayElement{text: "\"Date\"", color: "text-code-4"}, + %DisplayElement{text: ",", color: "text-code-2"} + ], + expanded_before: [ + %DisplayElement{text: "{:ok, ~D[2025-07-08]}", color: "text-code-2"}, + %DisplayElement{text: " => ", color: "text-code-2"} + ], + expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}] + }}, + {cid, + %TermNode{ + id: "root.1", + kind: :binary, + open?: false, + children: [], + content: [ + %DisplayElement{text: "%Phoenix.LiveComponent.CID{cid: 1}", color: "text-code-1"}, + %DisplayElement{text: " => ", color: "text-code-2"}, + %DisplayElement{text: "\"CID\"", color: "text-code-4"} + ], + expanded_before: [ + %DisplayElement{text: "%Phoenix.LiveComponent.CID{cid: 1}", color: "text-code-1"}, + %DisplayElement{text: " => ", color: "text-code-2"} + ], + expanded_after: [] + }} ], content: [%DisplayElement{text: "%{...}", color: "text-code-2"}], expanded_before: [%DisplayElement{text: "%{", color: "text-code-2"}], @@ -471,4 +549,252 @@ defmodule LiveDebugger.App.Utils.TermParserTest do "%{pid: :erlang.list_to_pid(~c\"<0.123.0>\")}" end end + + describe "update_by_id/3" do + test "updates root node" do + term_node = Fakes.term_node(id: "root", open?: true) + + assert {:ok, %TermNode{open?: false}} = + TermParser.update_by_id(term_node, "root", &close_term_node/1) + end + + test "updates child node" do + child = Fakes.term_node(id: "root.0", open?: true) + term_node = Fakes.term_node(id: "root", open?: true, children: [{0, child}]) + + assert {:ok, %TermNode{open?: true, children: [{0, %TermNode{open?: false}}]}} = + TermParser.update_by_id(term_node, "root.0", &close_term_node/1) + end + + test "returns error if the term node is not found" do + term_node = Fakes.term_node(id: "root", open?: true) + + assert {:error, :child_not_found} = + TermParser.update_by_id(term_node, "root.1", &close_term_node/1) + end + end + + describe "update_by_diff/2" do + test "doesn't update if type is equal" do + term = %{a: 1, b: [1, 2, 3]} + term_node = TermParser.term_to_display_tree(term) + + diff = %Diff{type: :equal} + + assert {:ok, ^term_node} = TermParser.update_by_diff(term_node, diff) + end + + test "updates primitive values" do + old_term = "Old Term" + new_term = "New Term" + + term_node = %TermNode{content: [%DisplayElement{text: "Old Term", color: "text-code-1"}]} + + diff = TermDiffer.diff(old_term, new_term) + + assert {:ok, + %TermNode{content: [%DisplayElement{text: "\"New Term\"", color: "text-code-4"}]}} = + TermParser.update_by_diff(term_node, diff) + end + + test "updates nested elements in maps" do + old_term = %{ + user: %{ + name: "Alice", + settings: %{ + theme: "light", + notifications: true + } + } + } + + new_term = %{ + user: %{ + name: "Alice", + settings: %{ + theme: "dark", + notifications: false + } + } + } + + term_node = TermParser.term_to_display_tree(old_term) + diff = TermDiffer.diff(old_term, new_term) + + assert {:ok, updated_node} = TermParser.update_by_diff(term_node, diff) + + {_, user_node} = Enum.find(updated_node.children, fn {key, _} -> key == :user end) + {_, settings_node} = Enum.find(user_node.children, fn {key, _} -> key == :settings end) + {_, theme_node} = Enum.find(settings_node.children, fn {key, _} -> key == :theme end) + + assert theme_node.content == [ + %DisplayElement{text: "theme:", color: "text-code-1"}, + %DisplayElement{text: " ", color: "text-code-2"}, + %DisplayElement{text: "\"dark\"", color: "text-code-4"} + ] + end + + test "handles list insertions and deletions" do + old_term = [1, 2, 3] + new_term = [0, 2, 3, 4] + + term_node = TermParser.term_to_display_tree(old_term) + diff = TermDiffer.diff(old_term, new_term) + + assert {:ok, updated_node} = TermParser.update_by_diff(term_node, diff) + + assert length(updated_node.children) == 4 + + assert {0, + %TermNode{ + content: [ + %DisplayElement{text: "0", color: "text-code-1"}, + %DisplayElement{text: ",", color: "text-code-2"} + ] + }} = Enum.at(updated_node.children, 0) + + assert {3, + %TermNode{ + content: [ + %DisplayElement{text: "4", color: "text-code-1"} + ] + }} = Enum.at(updated_node.children, 3) + end + + test "properly adds and removes comma suffixes" do + term_node1 = %{a: 1, b: 2} + term_node2 = %{a: 1, b: 2, c: 3} + + term_node = TermParser.term_to_display_tree(term_node1) + diff = TermDiffer.diff(term_node1, term_node2) + + assert {:ok, updated_node} = TermParser.update_by_diff(term_node, diff) + + {_, b_node} = Enum.find(updated_node.children, fn {key, _} -> key == :b end) + assert List.last(b_node.content) == %DisplayElement{text: ",", color: "text-code-2"} + assert List.last(b_node.expanded_after) == %DisplayElement{text: ",", color: "text-code-2"} + + term_node3 = %{a: 1, b: 2} + diff = TermDiffer.diff(term_node2, term_node3) + + assert {:ok, final_node} = TermParser.update_by_diff(updated_node, diff) + + {_, b_node} = Enum.find(final_node.children, fn {key, _} -> key == :b end) + refute List.last(b_node.content) == %DisplayElement{text: ",", color: "text-code-2"} + refute List.last(b_node.expanded_after) == %DisplayElement{text: ",", color: "text-code-2"} + end + + test "properly opens lists and tuples within default limits" do + old_term = %{small: [1], large: [1, 2, 3, 4]} + new_term = %{small: [1, 2], large: [1, 2, 3, 4, 5]} + + term_node = TermParser.term_to_display_tree(old_term) + diff = TermDiffer.diff(old_term, new_term) + + assert {:ok, updated_node} = TermParser.update_by_diff(term_node, diff) + + assert updated_node.open? == true + + assert {_, %TermNode{open?: true}} = + Enum.find(updated_node.children, fn {key, _} -> key == :small end) + + assert {_, %TermNode{open?: false}} = + Enum.find(updated_node.children, fn {key, _} -> key == :large end) + end + + test "handles struct updates" do + old_term = %TestStruct{field1: "old", field2: 1} + new_term = %TestStruct{field1: "new", field2: 2} + + term_node = TermParser.term_to_display_tree(old_term) + diff = TermDiffer.diff(old_term, new_term) + + assert {:ok, updated_node} = TermParser.update_by_diff(term_node, diff) + + {_, field1_node} = Enum.find(updated_node.children, fn {key, _} -> key == :field1 end) + + assert field1_node.content == [ + %DisplayElement{text: "field1:", color: "text-code-1"}, + %DisplayElement{text: " ", color: "text-code-2"}, + %DisplayElement{text: "\"new\"", color: "text-code-4"}, + %DisplayElement{text: ",", color: "text-code-2"} + ] + + {_, field2_node} = Enum.find(updated_node.children, fn {key, _} -> key == :field2 end) + + assert field2_node.content == [ + %DisplayElement{text: "field2:", color: "text-code-1"}, + %DisplayElement{text: " ", color: "text-code-2"}, + %DisplayElement{text: "2", color: "text-code-1"} + ] + end + + test "handles map key additions and deletions" do + old_term = %{"a" => 1, "b" => 2} + new_term = %{"b" => 2, "d" => 4} + + term_node = TermParser.term_to_display_tree(old_term) + diff = TermDiffer.diff(old_term, new_term) + + assert {:ok, updated_node} = TermParser.update_by_diff(term_node, diff) + + refute Enum.any?(updated_node.children, fn {key, _} -> key == "a" end) + + assert {_, d_node} = Enum.find(updated_node.children, fn {key, _} -> key == "d" end) + + assert d_node.content == [ + %DisplayElement{text: "\"d\"", color: "text-code-4"}, + %DisplayElement{text: " => ", color: "text-code-2"}, + %DisplayElement{text: "4", color: "text-code-1"} + ] + end + + test "correctly updates node ids" do + old_term = %{items: [1, 2], metadata: %{version: 1}} + new_term = %{items: [1, 2, 3], metadata: %{version: 2}} + + term_node = TermParser.term_to_display_tree(old_term) + diff = TermDiffer.diff(old_term, new_term) + + assert {:ok, + %TermNode{ + id: "root", + children: [ + {:items, + %TermNode{ + id: "root.0", + children: [ + {0, %TermNode{id: "root.0.0"}}, + {1, %TermNode{id: "root.0.1"}}, + {2, %TermNode{id: "root.0.2"}} + ] + }}, + {:metadata, + %TermNode{ + id: "root.1", + children: [ + {:version, %TermNode{id: "root.1.0"}} + ] + }} + ] + }} = TermParser.update_by_diff(term_node, diff) + end + + test "returns error if the term node and diff are not compatible" do + invalid_diff = %Diff{ + type: :map, + diff: %{non_existent_key: Fakes.term_diff_primitive()} + } + + old_term = :not_a_map + + term_node = TermParser.term_to_display_tree(old_term) + + assert {:error, _} = TermParser.update_by_diff(term_node, invalid_diff) + end + end + + defp close_term_node(term_node) do + %TermNode{term_node | open?: false} + end end diff --git a/test/support/fakes.ex b/test/support/fakes.ex index 72e23fbce..fca43523b 100644 --- a/test/support/fakes.ex +++ b/test/support/fakes.ex @@ -3,6 +3,25 @@ defmodule LiveDebugger.Fakes do Fake complex structures """ + def display_element(opts \\ []) do + %LiveDebugger.App.Utils.TermNode.DisplayElement{ + text: Keyword.get(opts, :text, "text"), + color: Keyword.get(opts, :color, "text-code-1") + } + end + + def term_node(opts \\ []) do + %LiveDebugger.App.Utils.TermNode{ + id: Keyword.get(opts, :id, "indexed"), + kind: Keyword.get(opts, :kind, :atom), + content: Keyword.get(opts, :content, [display_element()]), + children: Keyword.get(opts, :children, []), + expanded_before: Keyword.get(opts, :expanded_before, []), + expanded_after: Keyword.get(opts, :expanded_after, []), + open?: Keyword.get(opts, :open?, false) + } + end + def term_diff_primitive(opts \\ []) do old_value = Keyword.get(opts, :old_value, 1) new_value = Keyword.get(opts, :new_value, 2)