mirror of
https://github.com/woutdp/live_svelte
synced 2026-05-23 17:09:21 +00:00
284 lines
9.8 KiB
Elixir
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
|