defmodule MST.NodeTest do use ExUnit.Case, async: true doctest MST.Node alias DASL.CID alias MST.Node alias MST.Node.Entry # Shared fixtures @cid_a CID.compute("value_a", :raw) @cid_b CID.compute("value_b", :raw) @cid_c CID.compute("value_c", :raw) @prefix_interop Path.join([__DIR__, "..", "fixtures", "interop", "common_prefix.json"]) |> File.read!() |> Jason.decode!() describe "empty/0" do test "returns an empty node" do assert %Node{left: nil, entries: []} = Node.empty() end end describe "encode/1 and decode/1 round-trip" do test "empty node" do node = Node.empty() assert {:ok, bytes} = Node.encode(node) assert {:ok, ^node} = Node.decode(bytes) end test "node with single entry, no subtrees" do entry = %Entry{prefix_len: 0, key_suffix: "col/key", value: @cid_a, right: nil} node = %Node{left: nil, entries: [entry]} assert {:ok, bytes} = Node.encode(node) assert {:ok, decoded} = Node.decode(bytes) assert decoded.left == nil assert length(decoded.entries) == 1 assert hd(decoded.entries).key_suffix == "col/key" assert hd(decoded.entries).value == @cid_a assert hd(decoded.entries).right == nil end test "node with left subtree pointer" do entry = %Entry{prefix_len: 0, key_suffix: "col/key", value: @cid_a, right: nil} node = %Node{left: @cid_b, entries: [entry]} assert {:ok, bytes} = Node.encode(node) assert {:ok, decoded} = Node.decode(bytes) assert decoded.left == @cid_b end test "node with right subtree pointer" do entry = %Entry{prefix_len: 0, key_suffix: "col/key", value: @cid_a, right: @cid_b} node = %Node{left: nil, entries: [entry]} assert {:ok, bytes} = Node.encode(node) assert {:ok, decoded} = Node.decode(bytes) assert hd(decoded.entries).right == @cid_b end test "node with multiple entries and prefix compression" do # "app.bsky.feed.post/" is 19 bytes, so prefix_len for bbb/ccc is 19 entries = [ %Entry{prefix_len: 0, key_suffix: "app.bsky.feed.post/aaa", value: @cid_a, right: nil}, %Entry{prefix_len: 19, key_suffix: "bbb", value: @cid_b, right: nil}, %Entry{prefix_len: 19, key_suffix: "ccc", value: @cid_c, right: nil} ] node = %Node{left: nil, entries: entries} assert {:ok, bytes} = Node.encode(node) assert {:ok, decoded} = Node.decode(bytes) assert Node.keys(decoded) == [ "app.bsky.feed.post/aaa", "app.bsky.feed.post/bbb", "app.bsky.feed.post/ccc" ] end test "CID is stable across encode → decode → re-encode" do entry = %Entry{prefix_len: 0, key_suffix: "col/key", value: @cid_a, right: nil} node = %Node{left: nil, entries: [entry]} assert {:ok, bytes1} = Node.encode(node) assert {:ok, decoded} = Node.decode(bytes1) assert {:ok, bytes2} = Node.encode(decoded) assert bytes1 == bytes2 end test "explicit null for nil left is required for determinism" do # Two encodings of a node with left=nil must produce the same bytes node1 = Node.empty() node2 = Node.empty() assert {:ok, bytes1} = Node.encode(node1) assert {:ok, bytes2} = Node.encode(node2) assert bytes1 == bytes2 end end describe "cid/1" do test "returns a :drisl codec CID" do assert {:ok, cid} = Node.cid(Node.empty()) assert cid.codec == :drisl end test "same node always produces the same CID" do node = Node.empty() assert {:ok, cid1} = Node.cid(node) assert {:ok, cid2} = Node.cid(node) assert cid1 == cid2 end test "different nodes produce different CIDs" do node_a = Node.empty() entry = %Entry{prefix_len: 0, key_suffix: "col/key", value: @cid_a, right: nil} node_b = %Node{left: nil, entries: [entry]} assert {:ok, cid_a} = Node.cid(node_a) assert {:ok, cid_b} = Node.cid(node_b) assert cid_a != cid_b end end describe "keys/1" do test "empty node returns empty list" do assert Node.keys(Node.empty()) == [] end test "reconstructs full keys from prefix-compressed entries" do entries = [ %Entry{prefix_len: 0, key_suffix: "foo/aaa", value: @cid_a, right: nil}, %Entry{prefix_len: 4, key_suffix: "bbb", value: @cid_b, right: nil}, %Entry{prefix_len: 4, key_suffix: "ccc", value: @cid_c, right: nil} ] node = %Node{left: nil, entries: entries} assert Node.keys(node) == ["foo/aaa", "foo/bbb", "foo/ccc"] end test "first entry always has prefix_len 0" do entry = %Entry{prefix_len: 0, key_suffix: "full/key", value: @cid_a, right: nil} node = %Node{left: nil, entries: [entry]} assert Node.keys(node) == ["full/key"] end end describe "compress_entries/1" do test "single entry has prefix_len 0" do entries = Node.compress_entries([{"col/key", @cid_a, nil}]) assert hd(entries).prefix_len == 0 assert hd(entries).key_suffix == "col/key" end test "adjacent entries with common prefix are compressed" do # "app.bsky.feed.post/" = 19 bytes shared; then 'a' vs 'b' diverge entries = Node.compress_entries([ {"app.bsky.feed.post/aaa", @cid_a, nil}, {"app.bsky.feed.post/bbb", @cid_b, nil} ]) [e1, e2] = entries assert e1.prefix_len == 0 assert e1.key_suffix == "app.bsky.feed.post/aaa" assert e2.prefix_len == 19 assert e2.key_suffix == "bbb" end test "no shared prefix means prefix_len stays 0" do entries = Node.compress_entries([{"aaa/x", @cid_a, nil}, {"zzz/y", @cid_b, nil}]) assert Enum.at(entries, 1).prefix_len == 0 end test "compress then expand is identity" do keys = ["col/aaa", "col/bbb", "col/ccc"] triples = Enum.map(keys, fn k -> {k, @cid_a, nil} end) entries = Node.compress_entries(triples) node = %Node{left: nil, entries: entries} assert Node.keys(node) == keys end end describe "common_prefix.json interop" do # The common prefix length between two keys is what determines prefix_len # in the second of any two adjacent compress_entries inputs. We test via # compress_entries since common_prefix_length/2 is private. test "all fixture pairs produce correct prefix_len" do cid = CID.compute("test", :raw) for %{"left" => left, "right" => right, "len" => expected} <- @prefix_interop do [_first, second] = Node.compress_entries([{left, cid, nil}, {right, cid, nil}]) assert second.prefix_len == expected, "common_prefix(#{inspect(left)}, #{inspect(right)}) " <> "expected #{expected}, got #{second.prefix_len}" end end end describe "decode/1 error cases" do test "returns error for non-CBOR bytes" do assert {:error, :decode, _} = Node.decode(<<0xFF, 0xFF, 0xFF>>) end test "returns error for trailing bytes" do {:ok, bytes} = Node.encode(Node.empty()) assert {:error, :decode, :trailing_bytes} = Node.decode(bytes <> <<0x00>>) end test "returns error for invalid structure (not a map)" do # CBOR-encode a plain integer {:ok, not_a_map} = DASL.DRISL.encode(42) assert {:error, :decode, _} = Node.decode(not_a_map) end end end