defmodule MixUnused.AnalyzerTest do use ExUnit.Case, async: false alias MixUnused.Analyzer setup_all do # Compute the heavy analyzer state once and share it across tests. # Tests that need an exclude-filtered result pass `precomputed_state` to # `analyze/1` to avoid re-scanning every BEAM file. state = Analyzer.compute_state() {:ok, baseline: Analyzer.analyze(precomputed_state: state), state: state} end describe "format/1" do test "renders a friendly message when there are no entries" do assert Analyzer.format([]) =~ "No unused public functions" end test "groups by module, lists each entry, and includes file:line" do entries = [ %{module: Foo, name: :a, arity: 1, file: "lib/foo.ex", line: 5}, %{module: Foo, name: :b, arity: 0, file: "lib/foo.ex", line: 10}, %{module: Bar, name: :c, arity: 2, file: "lib/bar.ex", line: 7} ] out = Analyzer.format(entries) assert out =~ "Unused public functions" assert out =~ "Foo" assert out =~ "def a/1" assert out =~ "def b/0" assert out =~ "lib/foo.ex:5" assert out =~ "lib/foo.ex:10" assert out =~ "Bar" assert out =~ "def c/2" assert out =~ "lib/bar.ex:7" end test "shows just the file when line is nil" do entries = [ %{module: Foo, name: :x, arity: 0, file: "lib/foo.ex", line: nil} ] out = Analyzer.format(entries) assert out =~ "(lib/foo.ex)" end test "shows '?' when both file and line are missing" do entries = [ %{module: Foo, name: :x, arity: 0, file: nil, line: nil} ] out = Analyzer.format(entries) assert out =~ "(?)" end end describe "analyze/1 against the running project" do # credo:disable-for-next-line Jump.CredoChecks.VacuousTest test "returns a list of unused-entry maps", %{baseline: result} do assert result != [] # Every entry has the expected shape. Enum.each(result, fn entry -> assert entry.module assert entry.name assert is_integer(entry.arity) # credo:disable-for-next-line Jump.CredoChecks.ConditionalAssertion assert is_nil(entry.file) or is_binary(entry.file) # credo:disable-for-next-line Jump.CredoChecks.ConditionalAssertion assert is_nil(entry.line) or is_integer(entry.line) end) end # credo:disable-for-next-line Jump.CredoChecks.VacuousTest test "result is sorted by module then name then arity", %{baseline: result} do sorted = Enum.sort_by(result, fn e -> {Atom.to_string(e.module), e.name, e.arity} end) # The analyzer's own sort key uses the atom directly, but inspect-string # ordering differs only for funky atoms — so we just confirm the result # is non-decreasing under our string-based sort. assert Enum.map(result, & &1.module) == Enum.map(sorted, & &1.module) end test "exclude option removes whole modules from the output", %{baseline: result, state: state} do [first | _] = result # first verified by pattern match excluded = Analyzer.analyze(exclude: [first.module], precomputed_state: state) refute Enum.any?(excluded, &(&1.module == first.module)) end test "exclude option removes specific MFA triples", %{baseline: result, state: state} do [first | _] = result # first verified by pattern match excluded = Analyzer.analyze( exclude: [{first.module, first.name, first.arity}], precomputed_state: state ) refute Enum.any?( excluded, &(&1.module == first.module and &1.name == first.name and &1.arity == first.arity) ) end test "honors :app option — empty list when filtered to a non-existent app" do result = Analyzer.analyze(app: :no_such_app_for_real) assert result == [] end test ":app=nil hits in_app?(_, nil) → true (no filtering)" do # When app is nil, in_app? returns true for every module — so the # analyzer scans everything in the compile path, not just the project. # This exercises line 86 (defp in_app?(_module, nil), do: true). tmp = Path.join(System.tmp_dir!(), "mu_app_nil_#{System.unique_integer([:positive])}") File.mkdir_p!(tmp) try do result = Analyzer.analyze(app: nil, compile_path: tmp) assert result == [] after File.rm_rf(tmp) end end test "honors :compile_path option pointing to an empty directory" do tmp = Path.join(System.tmp_dir!(), "mu_empty_#{System.unique_integer([:positive])}") File.mkdir_p!(tmp) try do result = Analyzer.analyze(compile_path: tmp) assert result == [] after File.rm_rf(tmp) end end test "honors :compile_path option pointing to a non-existent directory" do result = Analyzer.analyze(compile_path: "/nonexistent/path/for/test/#{System.unique_integer([:positive])}") assert result == [] end test "exclude with both module atoms and MFA tuples filters both kinds", %{baseline: result, state: state} do [first, second | _] = result excluded = Analyzer.analyze( exclude: [first.module, {second.module, second.name, second.arity}], precomputed_state: state ) refute Enum.any?(excluded, &(&1.module == first.module)) refute Enum.any?( excluded, &(&1.module == second.module and &1.name == second.name and &1.arity == second.arity) ) end end describe "format/1 with grouped multiple-modules and missing line metadata" do test "formats a mix of present and missing line numbers" do entries = [ %{module: A, name: :one, arity: 1, file: "lib/a.ex", line: 5}, %{module: A, name: :two, arity: 0, file: "lib/a.ex", line: nil}, %{module: B, name: :three, arity: 2, file: nil, line: nil} ] out = Analyzer.format(entries) assert out =~ "A" assert out =~ "B" assert out =~ "lib/a.ex:5" assert out =~ "(lib/a.ex)" assert out =~ "(?)" end end end