defmodule CljElixir.MalliTest do use ExUnit.Case, async: true alias CljElixir.Malli # Atoms that need quoted syntax in Elixir source @arrow :"=>" @opt :"?" # ── Helper ────────────────────────────────────────────────────────── defp string_t do {{:., [], [{:__aliases__, [alias: false], [:String]}, :t]}, [], []} end defp mapset_t do {{:., [], [{:__aliases__, [alias: false], [:MapSet]}, :t]}, [], []} end defp pv_t do {{:., [], [{:__aliases__, [alias: false], [:CljElixir, :PersistentVector]}, :t]}, [], []} end # ── Primitive types ───────────────────────────────────────────────── describe "primitive types" do test "string" do assert string_t() == Malli.schema_to_typespec(:string) end test "int" do assert {:integer, [], []} = Malli.schema_to_typespec(:int) end test "integer" do assert {:integer, [], []} = Malli.schema_to_typespec(:integer) end test "float" do assert {:float, [], []} = Malli.schema_to_typespec(:float) end test "number" do assert {:number, [], []} = Malli.schema_to_typespec(:number) end test "boolean" do assert {:boolean, [], []} = Malli.schema_to_typespec(:boolean) end test "atom" do assert {:atom, [], []} = Malli.schema_to_typespec(:atom) end test "keyword" do assert {:atom, [], []} = Malli.schema_to_typespec(:keyword) end test "any" do assert {:any, [], []} = Malli.schema_to_typespec(:any) end test "nil" do assert nil == Malli.schema_to_typespec(:nil) end test "pid" do assert {:pid, [], []} = Malli.schema_to_typespec(:pid) end test "port" do assert {:port, [], []} = Malli.schema_to_typespec(:port) end test "reference" do assert {:reference, [], []} = Malli.schema_to_typespec(:reference) end test "pos-int" do assert {:pos_integer, [], []} = Malli.schema_to_typespec(:"pos-int") end test "neg-int" do assert {:neg_integer, [], []} = Malli.schema_to_typespec(:"neg-int") end test "nat-int" do assert {:non_neg_integer, [], []} = Malli.schema_to_typespec(:"nat-int") end end # ── Compound types ───────────────────────────────────────────────── describe "compound types" do test "or with two types" do ast = Malli.schema_to_typespec([:or, :int, :string]) expected_string = string_t() assert {:|, [], [{:integer, [], []}, ^expected_string]} = ast end test "or with three types (right-associative)" do ast = Malli.schema_to_typespec([:or, :int, :string, :boolean]) assert {:|, [], [{:integer, [], []}, {:|, [], [_, {:boolean, [], []}]}]} = ast end test "maybe type" do ast = Malli.schema_to_typespec([:maybe, :string]) expected_string = string_t() assert {:|, [], [^expected_string, nil]} = ast end test "enum type" do ast = Malli.schema_to_typespec([:enum, :a, :b, :c]) assert {:|, [], [:a, {:|, [], [:b, :c]}]} = ast end test "enum with single value" do assert :a = Malli.schema_to_typespec([:enum, :a]) end test "= literal" do assert :hello = Malli.schema_to_typespec([:=, :hello]) assert 42 = Malli.schema_to_typespec([:=, 42]) end test "and with base type and constraint (general)" do ast = Malli.schema_to_typespec([:and, :int, [:<, 100]]) assert {:integer, [], []} = ast end test "and :int [:> 0] produces pos_integer()" do ast = Malli.schema_to_typespec([:and, :int, [:>, 0]]) assert {:pos_integer, [], []} = ast end test "and :int [:>= 0] produces non_neg_integer()" do ast = Malli.schema_to_typespec([:and, :int, [:>=, 0]]) assert {:non_neg_integer, [], []} = ast end end # ── Container types ──────────────────────────────────────────────── describe "container types" do test "map with fields" do ast = Malli.schema_to_typespec([:map, [:name, :string], [:age, :int]]) assert {:%{}, [], kv} = ast assert Keyword.has_key?(kv, :name) assert Keyword.has_key?(kv, :age) expected_string = string_t() assert ^expected_string = Keyword.get(kv, :name) assert {:integer, [], []} = Keyword.get(kv, :age) end test "map with optional field" do ast = Malli.schema_to_typespec([:map, [:name, :string], [:email, {:optional, true}, :string]]) assert {:%{}, [], kv} = ast assert Keyword.has_key?(kv, :name) assert Keyword.has_key?(kv, :email) end test "map-of" do ast = Malli.schema_to_typespec([:"map-of", :string, :int]) assert {:%{}, [], [optional_entry]} = ast assert {{:optional, [], [_key_t]}, {:integer, [], []}} = optional_entry end test "list" do ast = Malli.schema_to_typespec([:list, :int]) assert [{:integer, [], []}] = ast end test "vector" do ast = Malli.schema_to_typespec([:vector, :int]) assert ^ast = pv_t() end test "set" do ast = Malli.schema_to_typespec([:set, :int]) assert ^ast = mapset_t() end test "tuple" do ast = Malli.schema_to_typespec([:tuple, :int, :string]) expected_string = string_t() assert {:{}, [], [{:integer, [], []}, ^expected_string]} = ast end test "tuple with three elements" do ast = Malli.schema_to_typespec([:tuple, :int, :string, :boolean]) assert {:{}, [], [{:integer, [], []}, _, {:boolean, [], []}]} = ast end end # ── Function specs ───────────────────────────────────────────────── describe "function specs" do test "simple function spec" do specs = Malli.spec_ast(:hello, [@arrow, [:cat, :string], :string]) assert length(specs) == 1 [{:@, [], [{:spec, [], [spec_body]}]}] = specs assert {:"::", [], [{:hello, [], [_arg]}, _ret]} = spec_body end test "function with two params" do specs = Malli.spec_ast(:add, [@arrow, [:cat, :int, :int], :int]) assert length(specs) == 1 [{:@, [], [{:spec, [], [{:"::", [], [{:add, [], args}, _ret]}]}]}] = specs assert length(args) == 2 end test "function with optional param produces two specs" do specs = Malli.spec_ast(:greet, [@arrow, [:cat, :string, [@opt, :string]], :string]) assert length(specs) == 2 arities = Enum.map(specs, fn {:@, [], [{:spec, [], [{:"::", [], [{:greet, [], args}, _ret]}]}]} -> length(args) end) assert Enum.sort(arities) == [1, 2] end test "function with multiple optional params" do specs = Malli.spec_ast(:f, [@arrow, [:cat, :int, [@opt, :string], [@opt, :boolean]], :any]) assert length(specs) == 3 arities = Enum.map(specs, fn {:@, [], [{:spec, [], [{:"::", [], [{:f, [], args}, _ret]}]}]} -> length(args) end) assert Enum.sort(arities) == [1, 2, 3] end test "multi-arity function via :function" do specs = Malli.spec_ast(:greet, [ :function, [@arrow, [:cat, :string], :string], [@arrow, [:cat, :string, :string], :string] ]) assert length(specs) == 2 arities = Enum.map(specs, fn {:@, [], [{:spec, [], [{:"::", [], [{:greet, [], args}, _ret]}]}]} -> length(args) end) assert Enum.sort(arities) == [1, 2] end end # ── Type generation ──────────────────────────────────────────────── describe "type generation" do test "named type from map schema" do ast = Malli.type_ast(:user, [:map, [:name, :string], [:age, :int]]) assert {:@, [], [{:type, [], [{:"::", [], [{:user, [], []}, _map_type]}]}]} = ast end test "named type from primitive" do ast = Malli.type_ast(:name, :string) expected_string = string_t() assert {:@, [], [{:type, [], [{:"::", [], [{:name, [], []}, ^expected_string]}]}]} = ast end end # ── Schema references ───────────────────────────────────────────── describe "schema references" do test "known type reference" do ast = Malli.schema_to_typespec("User", known_types: %{"User" => :user}) assert {:user, [], []} = ast end test "unknown string reference falls back to any()" do ast = Malli.schema_to_typespec("Unknown") assert {:any, [], []} = ast end end # ── Recursive types ─────────────────────────────────────────────── describe "recursive types" do test "ref produces type call" do ast = Malli.schema_to_typespec([:ref, :tree], registry: %{tree: [:or, :int, :nil]}) assert {:tree, [], []} = ast end test "schema with registry via type_ast/2 dispatches to type_ast/3" do schema = [ :schema, %{registry: %{tree: [:or, :int, [:tuple, [:ref, :tree], [:ref, :tree]]]}}, [:ref, :tree] ] types = Malli.type_ast(:tree, schema) assert is_list(types) assert length(types) >= 1 [{:@, [], [{:type, [], [{:"::", [], [{:tree, [], []}, _body]}]}]}] = types end test "schema with registry via type_ast/3 with list of pairs" do registry = [{:tree, [:or, :int, [:tuple, [:ref, :tree], [:ref, :tree]]]}] schema = [:schema, %{registry: Map.new(registry)}, [:ref, :tree]] types = Malli.type_ast(:tree, schema, registry) assert is_list(types) assert length(types) == 1 end end # ── Compilation smoke tests ─────────────────────────────────────── describe "compilation smoke test" do test "generated spec compiles in a module" do spec_asts = Malli.spec_ast(:hello, [@arrow, [:cat, :string], :string]) fun_ast = {:def, [], [ {:hello, [], [{:name, [], nil}]}, [do: {:name, [], nil}] ]} module_body = spec_asts ++ [fun_ast] block = {:__block__, [], module_body} module_ast = {:defmodule, [context: Elixir], [{:__aliases__, [alias: false], [:MalliSmokeTest1]}, [do: block]]} assert [{MalliSmokeTest1, _binary}] = Code.compile_quoted(module_ast) after :code.purge(MalliSmokeTest1) :code.delete(MalliSmokeTest1) end test "generated type compiles in a module" do type_ast = Malli.type_ast(:user, [:map, [:name, :string], [:age, :int]]) module_ast = {:defmodule, [context: Elixir], [{:__aliases__, [alias: false], [:MalliSmokeTest2]}, [do: type_ast]]} assert [{MalliSmokeTest2, _binary}] = Code.compile_quoted(module_ast) after :code.purge(MalliSmokeTest2) :code.delete(MalliSmokeTest2) end test "multi-arity spec compiles" do specs = Malli.spec_ast(:greet, [ :function, [@arrow, [:cat, :string], :string], [@arrow, [:cat, :string, :string], :string] ]) fun1 = {:def, [], [ {:greet, [], [{:name, [], nil}]}, [do: {:name, [], nil}] ]} fun2 = {:def, [], [ {:greet, [], [{:greeting, [], nil}, {:name, [], nil}]}, [do: {:name, [], nil}] ]} module_body = specs ++ [fun1, fun2] block = {:__block__, [], module_body} module_ast = {:defmodule, [context: Elixir], [{:__aliases__, [alias: false], [:MalliSmokeTest3]}, [do: block]]} assert [{MalliSmokeTest3, _binary}] = Code.compile_quoted(module_ast) after :code.purge(MalliSmokeTest3) :code.delete(MalliSmokeTest3) end test "map-of type compiles" do type_ast = Malli.type_ast(:counts, [:"map-of", :string, :int]) module_ast = {:defmodule, [context: Elixir], [{:__aliases__, [alias: false], [:MalliSmokeTest4]}, [do: type_ast]]} assert [{MalliSmokeTest4, _binary}] = Code.compile_quoted(module_ast) after :code.purge(MalliSmokeTest4) :code.delete(MalliSmokeTest4) end test "tuple type compiles" do type_ast = Malli.type_ast(:point, [:tuple, :int, :int]) module_ast = {:defmodule, [context: Elixir], [{:__aliases__, [alias: false], [:MalliSmokeTest5]}, [do: type_ast]]} assert [{MalliSmokeTest5, _binary}] = Code.compile_quoted(module_ast) after :code.purge(MalliSmokeTest5) :code.delete(MalliSmokeTest5) end test "list type compiles" do type_ast = Malli.type_ast(:names, [:list, :string]) module_ast = {:defmodule, [context: Elixir], [{:__aliases__, [alias: false], [:MalliSmokeTest6]}, [do: type_ast]]} assert [{MalliSmokeTest6, _binary}] = Code.compile_quoted(module_ast) after :code.purge(MalliSmokeTest6) :code.delete(MalliSmokeTest6) end test "or type compiles" do type_ast = Malli.type_ast(:string_or_int, [:or, :string, :int]) module_ast = {:defmodule, [context: Elixir], [{:__aliases__, [alias: false], [:MalliSmokeTest7]}, [do: type_ast]]} assert [{MalliSmokeTest7, _binary}] = Code.compile_quoted(module_ast) after :code.purge(MalliSmokeTest7) :code.delete(MalliSmokeTest7) end test "maybe type compiles" do type_ast = Malli.type_ast(:opt_string, [:maybe, :string]) module_ast = {:defmodule, [context: Elixir], [{:__aliases__, [alias: false], [:MalliSmokeTest8]}, [do: type_ast]]} assert [{MalliSmokeTest8, _binary}] = Code.compile_quoted(module_ast) after :code.purge(MalliSmokeTest8) :code.delete(MalliSmokeTest8) end test "recursive type compiles" do schema = [ :schema, %{registry: %{tree: [:or, :int, [:tuple, [:ref, :tree], [:ref, :tree]]]}}, [:ref, :tree] ] types = Malli.type_ast(:tree, schema) block = {:__block__, [], types} module_ast = {:defmodule, [context: Elixir], [{:__aliases__, [alias: false], [:MalliSmokeTest9]}, [do: block]]} assert [{MalliSmokeTest9, _binary}] = Code.compile_quoted(module_ast) after :code.purge(MalliSmokeTest9) :code.delete(MalliSmokeTest9) end test "optional params spec compiles" do specs = Malli.spec_ast(:greet, [@arrow, [:cat, :string, [@opt, :string]], :string]) fun1 = {:def, [], [ {:greet, [], [{:name, [], nil}]}, [do: {:name, [], nil}] ]} fun2 = {:def, [], [ {:greet, [], [{:name, [], nil}, {:greeting, [], nil}]}, [do: {:name, [], nil}] ]} module_body = specs ++ [fun1, fun2] block = {:__block__, [], module_body} module_ast = {:defmodule, [context: Elixir], [{:__aliases__, [alias: false], [:MalliSmokeTest10]}, [do: block]]} assert [{MalliSmokeTest10, _binary}] = Code.compile_quoted(module_ast) after :code.purge(MalliSmokeTest10) :code.delete(MalliSmokeTest10) end end end