live_svelte/test/props_diff_test.exs
2026-03-19 12:43:32 -07:00

284 lines
9.8 KiB
Elixir

defmodule LiveSvelte.PropsDiffTest do
# This test mutates Application env in a couple cases.
use ExUnit.Case, async: false
defp base_assigns(opts \\ []) do
%{
__changed__: Keyword.get(opts, :__changed__, nil),
socket: Keyword.get(opts, :socket),
name: Keyword.get(opts, :name, "Demo"),
id: nil,
key: nil,
props: Keyword.get(opts, :props, %{}),
ssr: false,
class: nil,
loading: [],
inner_block: []
}
end
defp render_html(assigns) do
assigns
|> LiveSvelte.svelte()
|> Phoenix.HTML.Safe.to_iodata()
|> IO.iodata_to_binary()
end
defp data_use_diff_from_html(html) do
case Regex.run(~r/data-use-diff="([^"]*)"/, html) do
[_, val] -> val
_ -> nil
end
end
defp decode_props(html) do
case Regex.run(~r/data-props="([^"]*)"/, html) do
[_, encoded] ->
# Attribute value may be HTML-escaped (e.g. " for ")
unescaped =
encoded
|> String.replace(""", "\"")
|> String.replace("'", "'")
# Use Erlang :json (same family as default LiveSvelte.JSON encoder)
:json.decode(unescaped)
_ ->
nil
end
end
describe "props_changed_only/2 (changed-keys extraction)" do
test "returns only keys that differ between new and old" do
old_p = %{"a" => 1, "b" => 2, "c" => 3}
new_p = %{"a" => 1, "b" => 99, "c" => 3}
result = LiveSvelte.props_changed_only(new_p, old_p)
assert result == %{"b" => 99}
end
test "includes keys only in new (added)" do
old_p = %{"a" => 1}
new_p = %{"a" => 1, "b" => 2}
result = LiveSvelte.props_changed_only(new_p, old_p)
assert result == %{"b" => 2}
end
test "includes keys only in old as nil (removed)" do
old_p = %{"a" => 1, "b" => 2}
new_p = %{"a" => 1}
result = LiveSvelte.props_changed_only(new_p, old_p)
assert result == %{"b" => nil}
end
test "when old is empty, returns all new keys" do
new_p = %{"x" => 1, "y" => 2}
result = LiveSvelte.props_changed_only(new_p, %{})
assert result == new_p
end
end
describe "props_for_payload/1 (payload selection logic)" do
test "init (__changed__ nil) returns full props" do
props = %{"a" => 1, "b" => 2}
assigns = base_assigns(props: props, __changed__: nil)
result = LiveSvelte.props_for_payload(assigns)
assert result == props
end
test "when enable_props_diff is false, always returns full props" do
Application.put_env(:live_svelte, :enable_props_diff, false)
try do
assigns =
base_assigns(
props: %{"x" => 10, "y" => 20},
__changed__: %{props: %{"x" => 10, "y" => 0}}
)
# In real component rendering, `diff` is present (default true). Ensure global config still wins.
assigns = Map.put(assigns, :diff, true)
result = LiveSvelte.props_for_payload(assigns)
assert result == %{"x" => 10, "y" => 20}
after
Application.put_env(:live_svelte, :enable_props_diff, true)
end
end
end
defp decode_props_diff(html) do
case Regex.run(~r/data-props-diff="([^"]*)"/, html) do
[_, encoded] ->
unescaped =
encoded
|> String.replace(""", "\"")
|> String.replace("'", "'")
|> String.replace("+", "+")
:json.decode(unescaped)
_ ->
nil
end
end
describe "calculate_props_diff/2 (Tier 2 - JSON Patch computation)" do
test "returns empty list when props are identical" do
props = %{"count" => 1, "label" => "hello"}
assert LiveSvelte.calculate_props_diff(props, props) == []
end
test "simple value change produces replace operation" do
diff = LiveSvelte.calculate_props_diff(%{"count" => 2}, %{"count" => 1})
# includes test op + replace op
assert length(diff) == 2
replace = Enum.find(diff, &(&1.op == "replace"))
assert replace.path == "/count"
assert replace.value == 2
end
test "added key produces add operation" do
diff = LiveSvelte.calculate_props_diff(%{"a" => 1, "b" => 2}, %{"a" => 1})
add = Enum.find(diff, &(&1.op == "add"))
assert add.path == "/b"
assert add.value == 2
end
test "removed key produces remove operation" do
diff = LiveSvelte.calculate_props_diff(%{"a" => 1}, %{"a" => 1, "b" => 2})
remove = Enum.find(diff, &(&1.op == "remove"))
assert remove.path == "/b"
end
test "nested map field change produces minimal diff" do
old_p = %{"user" => %{"name" => "Alice", "age" => 30}}
new_p = %{"user" => %{"name" => "Alice", "age" => 31}}
diff = LiveSvelte.calculate_props_diff(new_p, old_p)
content_ops = Enum.reject(diff, &(&1.op == "test"))
assert length(content_ops) == 1
assert hd(content_ops).path == "/user/age"
end
test "compressed format is [op, path, value] or [op, path] for remove" do
diff = LiveSvelte.calculate_props_diff(%{"count" => 5}, %{"count" => 3})
compressed = Enum.map(diff, &LiveSvelte.prepare_diff/1)
replace = Enum.find(compressed, fn [op | _] -> op == "replace" end)
assert replace == ["replace", "/count", 5]
end
test "remove compresses to [op, path] without value" do
diff = LiveSvelte.calculate_props_diff(%{"a" => 1}, %{"a" => 1, "b" => 2})
compressed = Enum.map(diff, &LiveSvelte.prepare_diff/1)
remove = Enum.find(compressed, fn [op | _] -> op == "remove" end)
assert remove == ["remove", "/b"]
end
test "unchanged props return empty (no test op)" do
props = %{"x" => 42, "y" => [1, 2, 3]}
assert LiveSvelte.calculate_props_diff(props, props) == []
end
test "diff is empty when prev_props is nil" do
assert LiveSvelte.calculate_props_diff(%{"a" => 1}, nil) == []
end
end
describe "Tier 3 - ID-based list diffing via object_hash" do
test "inserting new item at front with id-list produces fewer ops than N replaces" do
items_old = [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}, %{id: 3, name: "Carol"}]
items_new = [
%{id: 4, name: "Dave"},
%{id: 1, name: "Alice"},
%{id: 2, name: "Bob"},
%{id: 3, name: "Carol"}
]
diff = LiveSvelte.calculate_props_diff(%{items: items_new}, %{items: items_old})
content_ops = Enum.reject(diff, &(&1.op == "test"))
# With object_hash: should be 1 add (not 3 replaces + 1 add)
replace_ops = Enum.filter(content_ops, &(&1.op == "replace"))
assert length(replace_ops) == 0
assert length(content_ops) <= 2
end
test "deleting middle item from id-list produces minimal ops" do
items_old = [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}, %{id: 3, name: "Carol"}]
items_new = [%{id: 1, name: "Alice"}, %{id: 3, name: "Carol"}]
diff = LiveSvelte.calculate_props_diff(%{items: items_new}, %{items: items_old})
content_ops = Enum.reject(diff, &(&1.op == "test"))
# With object_hash: should be 1 remove (not replace all)
assert length(content_ops) == 1
assert hd(content_ops).op == "remove"
end
test "reordering id-list produces no replace operations (move semantics)" do
items_old = [%{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}, %{id: 3, name: "Carol"}]
# Move Carol to front
items_new = [%{id: 3, name: "Carol"}, %{id: 1, name: "Alice"}, %{id: 2, name: "Bob"}]
diff = LiveSvelte.calculate_props_diff(%{items: items_new}, %{items: items_old})
content_ops = Enum.reject(diff, &(&1.op == "test"))
# With object_hash: reorder should not produce N replace operations
replace_ops = Enum.filter(content_ops, &(&1.op == "replace"))
assert length(replace_ops) == 0
assert length(content_ops) > 0
end
test "list without :id fields still diffs correctly (no regression)" do
items_old = [%{name: "Alice"}, %{name: "Bob"}]
items_new = [%{name: "Alice"}, %{name: "Bob"}, %{name: "Carol"}]
diff = LiveSvelte.calculate_props_diff(%{items: items_new}, %{items: items_old})
content_ops = Enum.reject(diff, &(&1.op == "test"))
# Should produce some ops (add for the new item)
assert length(content_ops) > 0
end
end
describe "rendered output" do
test "initial render sends full props and data-use-diff true" do
assigns = base_assigns(props: %{"a" => 1, "b" => 2}, __changed__: nil)
html = render_html(assigns)
props = decode_props(html)
assert props["a"] == 1
assert props["b"] == 2
assert data_use_diff_from_html(html) == "true"
end
test "when diff false, data-use-diff is false" do
assigns = base_assigns(props: %{"x" => 1}, __changed__: nil) |> Map.put(:diff, false)
html = render_html(assigns)
assert data_use_diff_from_html(html) == "false"
end
test "initial render includes data-props-diff attribute as empty array" do
assigns = base_assigns(props: %{"a" => 1}, __changed__: nil)
html = render_html(assigns)
diff = decode_props_diff(html)
assert diff == []
end
test "diff nil is treated as false (strict == true guard), disabling diffing" do
assigns = base_assigns(props: %{"x" => 1}, __changed__: nil) |> Map.put(:diff, nil)
html = render_html(assigns)
assert data_use_diff_from_html(html) == "false"
end
test "when enable_props_diff is false, data-use-diff is false even if diff defaults to true" do
Application.put_env(:live_svelte, :enable_props_diff, false)
try do
# No explicit `diff` attr; component default is true.
assigns = base_assigns(props: %{"x" => 1}, __changed__: nil)
html = render_html(assigns)
assert data_use_diff_from_html(html) == "false"
after
Application.put_env(:live_svelte, :enable_props_diff, true)
end
end
end
end