defmodule AB do @moduledoc """ A macro-based property testing generator that analyzes function typespecs to automatically generate appropriate test data and output validators. ## Usage defmodule MyModuleTest do use ExUnit.Case use ExUnitProperties import AB # Generate property test for a function with typespec property_test MyModule, :my_function end """ use ExUnitProperties import ExUnit.Assertions alias AB.{TypeParser, Generators, Validators, InvalidGenerators} @doc """ Macro that generates property testing utilities for a function based on its typespec. Takes a module and function name, analyzes the typespec, and generates: 1. Input generators based on the function's parameter types 2. Output validators based on the function's return type 3. A complete property test ## Example # For a function with spec: @spec sort_list([integer()]) :: [integer()] property_test MyModule, :sort_list # With verbose output property_test MyModule, :sort_list, verbose: true This will generate a property test that validates the function behavior. """ @spec property_test(module(), atom(), keyword()) :: Macro.t() defmacro property_test(module, function_name, opts \\ []) do quoted_test = property_test_quoted(module, function_name, opts) quote do: unquote(quoted_test) end @spec property_test_quoted(module(), atom(), keyword()) :: Macro.t() defp property_test_quoted(module, function_name, opts) do quote do property unquote("#{function_name} satisfies its typespec") do AB.run_property_test( unquote(module), unquote(function_name), unquote(opts) ) end property unquote("#{function_name} type consistency validation") do AB.validate_type_consistency(unquote(module), unquote(function_name)) end end end @doc false @spec run_property_test(module(), atom(), keyword()) :: :ok def run_property_test(module, function_name, opts) do case get_function_spec(module, function_name) do {:ok, {input_types, output_type}} -> run_property_test_with_spec(module, function_name, input_types, output_type, opts) {:error, reason} -> flunk("Could not generate property test: #{reason}") end end @spec run_property_test_with_spec(module(), atom(), [any()], any(), keyword()) :: :ok defp run_property_test_with_spec(module, function_name, input_types, output_type, opts) do input_generator = create_input_generator_runtime(input_types, module) output_validator = create_output_validator_runtime(output_type) verbose = Keyword.get(opts, :verbose, false) # Track successful runs agent = start_counter() try do check all(input <- input_generator) do result = apply(module, function_name, input) increment_counter(agent) maybe_log_verbose(verbose, input, result) validate_output_or_flunk(result, output_validator, output_type) end count = get_counter(agent) IO.puts(" ✓ #{count} successful property test runs") after stop_counter(agent) end end @spec maybe_log_verbose(boolean(), any(), any()) :: :ok defp maybe_log_verbose(true, input, result) do IO.puts("Input: #{inspect(input)} -> Output: #{inspect(result)}") end @spec maybe_log_verbose(boolean(), any(), any()) :: :ok defp maybe_log_verbose(false, _input, _result), do: :ok @spec validate_output_or_flunk(any(), (any() -> boolean()), any()) :: :ok defp validate_output_or_flunk(result, output_validator, output_type) do if output_validator.(result) do :ok else result_type = infer_result_type(result) expected_type = format_type_for_display(output_type) flunk( "Output type validation failed: function returned #{inspect(result)} (#{result_type}) but typespec declares return type as #{expected_type}" ) end end @doc """ Macro that compares two functions with identical typespecs using the same generated test data. Takes two module/function pairs, verifies their typespecs are identical, and if so, runs both functions on the same generated inputs to ensure they produce identical outputs. ## Example # Compare two sorting implementations compare_test {AB, :a}, {AB, :b} # With verbose output compare_test {AB, :a}, {AB, :b}, verbose: true This will generate a property test that validates both functions behave identically. """ @spec compare_test({module(), atom()}, {module(), atom()}, keyword()) :: Macro.t() defmacro compare_test({module1, function1}, {module2, function2}, opts \\ []) do quoted_test = compare_test_quoted(module1, function1, module2, function2, opts) quote do: unquote(quoted_test) end @spec compare_test_quoted(module(), atom(), module(), atom(), keyword()) :: Macro.t() defp compare_test_quoted(module1, function1, module2, function2, opts) do quote do property unquote("#{function1} and #{function2} produce identical results") do AB.run_compare_test( unquote(module1), unquote(function1), unquote(module2), unquote(function2), unquote(opts) ) end end end @doc false @spec run_compare_test(module(), atom(), module(), atom(), keyword()) :: :ok def run_compare_test(module1, function1, module2, function2, opts) do with {:ok, spec1} <- get_function_spec(module1, function1), {:ok, spec2} <- get_function_spec(module2, function2) do run_compare_test_with_specs(module1, function1, module2, function2, spec1, spec2, opts) else {:error, reason} -> flunk("Could not get typespec: #{reason}") end end @spec run_compare_test_with_specs( module(), atom(), module(), atom(), {[any()], any()}, {[any()], any()}, keyword() ) :: :ok defp run_compare_test_with_specs( module1, function1, module2, function2, {input_types1, output_type1}, {input_types2, output_type2}, opts ) do unless types_equivalent?(input_types1, input_types2) and types_equivalent?(output_type1, output_type2) do flunk( "Function typespecs do not match: #{module1}.#{function1} has #{inspect({input_types1, output_type1})}, #{module2}.#{function2} has #{inspect({input_types2, output_type2})}" ) end run_comparison_check(module1, function1, module2, function2, input_types1, output_type1, opts) end @spec run_comparison_check(module(), atom(), module(), atom(), [any()], any(), keyword()) :: :ok defp run_comparison_check( module1, function1, module2, function2, input_types, output_type, opts ) do input_generator = create_input_generator_runtime(input_types, module1) output_validator = create_output_validator_runtime(output_type) verbose = Keyword.get(opts, :verbose, false) # Track successful runs agent = start_counter() try do check all(input <- input_generator) do result1 = apply(module1, function1, input) result2 = apply(module2, function2, input) increment_counter(agent) maybe_log_comparison( verbose, input, module1, function1, result1, module2, function2, result2 ) assert_outputs_valid( module1, function1, result1, module2, function2, result2, output_validator ) assert_results_equal(result1, result2) end count = get_counter(agent) IO.puts(" ✓ #{count} successful comparison runs") after stop_counter(agent) end end @spec maybe_log_comparison(boolean(), any(), module(), atom(), any(), module(), atom(), any()) :: :ok defp maybe_log_comparison(true, input, module1, function1, result1, module2, function2, result2) do IO.puts("Input: #{inspect(input)}") IO.puts(" #{module1}.#{function1}: #{inspect(result1)}") IO.puts(" #{module2}.#{function2}: #{inspect(result2)}") end @spec maybe_log_comparison(boolean(), any(), module(), atom(), any(), module(), atom(), any()) :: :ok defp maybe_log_comparison(false, _input, _m1, _f1, _r1, _m2, _f2, _r2), do: :ok @spec assert_outputs_valid(module(), atom(), any(), module(), atom(), any(), (any() -> boolean())) :: :ok defp assert_outputs_valid(module1, function1, result1, module2, function2, result2, validator) do assert validator.(result1), "#{module1}.#{function1} output #{inspect(result1)} does not match expected type" assert validator.(result2), "#{module2}.#{function2} output #{inspect(result2)} does not match expected type" end @spec assert_results_equal(any(), any()) :: :ok defp assert_results_equal(result1, result2) do assert result1 == result2, "Functions produced different results:\n\n Result 1: #{inspect(result1, pretty: true, width: 80)}\n\n Result 2: #{inspect(result2, pretty: true, width: 80)}" end @doc """ Macro that benchmarks two functions with identical typespecs using Benchee. Takes two module/function pairs, verifies their typespecs are identical, and if so, runs a benchmark comparison using generated test data. ## Example # Benchmark two sorting implementations benchmark_test {AB, :a}, {AB, :b} # With custom options benchmark_test {AB, :a}, {AB, :b}, time: 5, memory_time: 2 This will generate a benchmark test that compares performance of both functions. """ @spec benchmark_test({module(), atom()}, {module(), atom()}, keyword()) :: Macro.t() defmacro benchmark_test({module1, function1}, {module2, function2}, opts \\ []) do quoted_test = benchmark_test_quoted(module1, function1, module2, function2, opts) quote do: unquote(quoted_test) end @spec benchmark_test_quoted(module(), atom(), module(), atom(), keyword()) :: Macro.t() defp benchmark_test_quoted(module1, function1, module2, function2, opts) do quote do test unquote("benchmark #{function1} vs #{function2}") do AB.run_benchmark_test( unquote(module1), unquote(function1), unquote(module2), unquote(function2), unquote(opts) ) end end end @doc false @spec run_benchmark_test(module(), atom(), module(), atom(), keyword()) :: :ok def run_benchmark_test(module1, function1, module2, function2, opts) do with {:ok, spec1} <- get_function_spec(module1, function1), {:ok, spec2} <- get_function_spec(module2, function2) do run_benchmark_test_with_specs(module1, function1, module2, function2, spec1, spec2, opts) else {:error, reason} -> flunk("Could not get typespec: #{reason}") end end @spec run_benchmark_test_with_specs( module(), atom(), module(), atom(), {[any()], any()}, {[any()], any()}, keyword() ) :: Benchee.Suite.t() defp run_benchmark_test_with_specs( module1, function1, module2, function2, {input_types1, output_type1}, {input_types2, output_type2}, opts ) do unless types_equivalent?(input_types1, input_types2) and types_equivalent?(output_type1, output_type2) do flunk( "Function typespecs do not match: #{module1}.#{function1} has #{inspect({input_types1, output_type1})}, #{module2}.#{function2} has #{inspect({input_types2, output_type2})}" ) end run_benchee(module1, function1, module2, function2, input_types1, opts) end @spec run_benchee(module(), atom(), module(), atom(), [any()], keyword()) :: Benchee.Suite.t() defp run_benchee(module1, function1, module2, function2, input_types, opts) do input_generator = create_input_generator_runtime(input_types, module1) test_inputs = Enum.take(input_generator, 100) time = Keyword.get(opts, :time, 3) memory_time = Keyword.get(opts, :memory_time, 1) IO.puts("\n=== Benchmarking #{module1}.#{function1} vs #{module2}.#{function2} ===") Benchee.run( %{ "#{module1}.#{function1}" => fn -> Enum.each(test_inputs, fn input -> apply(module1, function1, input) end) end, "#{module2}.#{function2}" => fn -> Enum.each(test_inputs, fn input -> apply(module2, function2, input) end) end }, time: time, memory_time: memory_time, formatters: [Benchee.Formatters.Console] ) end @doc """ Macro that generates property tests for all public functions in a module. Takes a module and automatically generates property tests for all exported functions that have typespecs defined. This is useful for validating an entire module's API. ## Example # Validate all public functions in a module validate_module MyModule # With verbose output validate_module MyModule, verbose: true This will generate property tests for each public function with a typespec. """ @spec validate_module(module(), keyword()) :: Macro.t() defmacro validate_module(module, opts \\ []) do quoted_test = validate_module_quoted(module, opts) quote do: unquote(quoted_test) end @spec validate_module_quoted(module(), keyword()) :: Macro.t() defp validate_module_quoted(module, opts) do quote do AB.run_validate_module(unquote(module), unquote(opts)) end end @doc false @spec run_validate_module(module(), keyword()) :: :ok def run_validate_module(module, opts) do case Code.ensure_loaded(module) do {:module, _} -> validate_all_module_functions(module, opts) _ -> flunk("Could not load module #{module}") end end @spec validate_all_module_functions(module(), keyword()) :: :ok defp validate_all_module_functions(module, opts) do case Code.Typespec.fetch_specs(module) do {:ok, specs} -> public_functions = get_public_functions(module, specs) run_tests_for_functions(module, public_functions, opts) _ -> flunk("Could not fetch typespecs for module #{module}") end end @spec get_public_functions(module(), [any()]) :: [{atom(), non_neg_integer()}] defp get_public_functions(module, specs) do exported_functions = module.__info__(:functions) specs |> Enum.map(fn {{function_name, arity}, _} -> {function_name, arity} end) |> Enum.filter(fn {function_name, arity} -> {function_name, arity} in exported_functions end) end @spec run_tests_for_functions(module(), [{atom(), non_neg_integer()}], keyword()) :: :ok defp run_tests_for_functions(module, functions, opts) do IO.puts("\n=== Validating #{length(functions)} public functions in #{module} ===") Enum.each(functions, fn {function_name, _arity} -> IO.puts("\nTesting #{module}.#{function_name}") run_property_test(module, function_name, opts) validate_type_consistency(module, function_name) end) IO.puts("\n✓ All #{length(functions)} functions validated successfully") end @doc """ Macro that validates struct type definitions against function typespecs. This catches inconsistencies where @type definitions don't match @spec definitions. ## Example # This will fail if @type t :: %AB{a: atom()} but @spec expects integer() validate_struct_consistency AB """ @spec validate_struct_consistency(module()) :: Macro.t() defmacro validate_struct_consistency(module) do module_name = extract_module_name(module) quoted_test = validate_struct_consistency_quoted(module, module_name) quote do: unquote(quoted_test) end @spec extract_module_name(module() | Macro.t()) :: atom() defp extract_module_name(module) do case module do {:__aliases__, _, [name]} -> name name when is_atom(name) -> name _ -> module end end @spec validate_struct_consistency_quoted(module(), atom()) :: Macro.t() defp validate_struct_consistency_quoted(module, module_name) do quote do test unquote("#{module_name} struct type consistency") do AB.run_struct_consistency_validation(unquote(module)) end end end @doc false @spec run_struct_consistency_validation(module()) :: :ok def run_struct_consistency_validation(module) do case Code.ensure_loaded(module) do {:module, _} -> validate_module_struct_consistency(module) _ -> flunk("Could not load module #{module}") end end @spec validate_module_struct_consistency(module()) :: :ok defp validate_module_struct_consistency(module) do with {:ok, types} <- Code.Typespec.fetch_types(module), true <- has_struct_type?(types), {:ok, specs} <- Code.Typespec.fetch_specs(module) do validate_each_spec(module, specs) else _ -> :ok end end @spec has_struct_type?([any()]) :: boolean() defp has_struct_type?(types) do Enum.any?(types, fn {:type, {:t, _, []}} -> true _ -> false end) end @spec validate_each_spec(module(), [any()]) :: :ok defp validate_each_spec(module, specs) do Enum.each(specs, fn {{function_name, _arity}, [spec | _]} -> validate_spec_against_type(module, function_name, spec) end) end @spec validate_spec_against_type(module(), atom(), any()) :: :ok defp validate_spec_against_type(module, function_name, spec) do case parse_spec(spec) do {:ok, {[_input_type], _output_type}} -> validate_function_with_generated_struct(module, function_name) _ -> :ok end end @spec validate_function_with_generated_struct(module(), atom()) :: :ok defp validate_function_with_generated_struct(module, function_name) do case create_struct_from_type_definition(module) do nil -> :ok gen -> test_struct_against_function(module, function_name, gen) end end @spec test_struct_against_function(module(), atom(), Enumerable.t()) :: :ok defp test_struct_against_function(module, function_name, gen) do test_input = gen |> Enum.take(1) |> List.first() try do apply(module, function_name, [test_input]) rescue e -> flunk( "Type inconsistency detected: @type definition creates structs that don't work with @spec for #{function_name}/1. Error: #{inspect(e)}" ) end end @doc """ Macro that generates robustness tests for a function based on its typespec. Takes a module and function name, analyzes the typespec, and generates: 1. Invalid input generators that create data NOT matching the function's parameter types 2. Tests that verify functions either raise errors or return invalid output when given invalid input 3. Ensures functions fail gracefully rather than silently accepting wrong input types ## Example # For a function with spec: @spec process(integer()) :: string() robust_test MyModule, :process # With verbose output robust_test MyModule, :process, verbose: true This will generate tests that verify the function properly rejects invalid input. """ @spec robust_test(module(), atom(), keyword()) :: Macro.t() defmacro robust_test(module, function_name, opts \\ []) do quoted_test = robust_test_quoted(module, function_name, opts) quote do: unquote(quoted_test) end @spec robust_test_quoted(module(), atom(), keyword()) :: Macro.t() defp robust_test_quoted(module, function_name, opts) do quote do property unquote("#{function_name} properly rejects invalid input") do AB.run_robust_test( unquote(module), unquote(function_name), unquote(opts) ) end end end @doc false @spec run_robust_test(module(), atom(), keyword()) :: :ok def run_robust_test(module, function_name, opts) do case get_function_spec(module, function_name) do {:ok, {input_types, _output_type}} -> run_robust_test_with_spec(module, function_name, input_types, opts) {:error, reason} -> flunk("Could not generate robust test: #{reason}") end end @spec run_robust_test_with_spec(module(), atom(), [any()], keyword()) :: :ok defp run_robust_test_with_spec(module, function_name, input_types, opts) do invalid_input_generator = create_invalid_input_generator_runtime(input_types, module) verbose = Keyword.get(opts, :verbose, false) # Track successful runs agent = start_counter() try do check all(invalid_input <- invalid_input_generator) do test_invalid_input(module, function_name, invalid_input, verbose) increment_counter(agent) end count = get_counter(agent) IO.puts(" ✓ #{count} successful invalid input test runs") after stop_counter(agent) end end @spec test_invalid_input(module(), atom(), any(), boolean()) :: :ok defp test_invalid_input(module, function_name, invalid_input, verbose) do try do result = apply(module, function_name, invalid_input) handle_unexpected_success(invalid_input, result, verbose) rescue e in [ExUnit.AssertionError] -> reraise e, __STACKTRACE__ e -> handle_expected_exception(invalid_input, e, verbose) end end @spec handle_unexpected_success(any(), any(), boolean()) :: no_return() defp handle_unexpected_success(invalid_input, result, verbose) do if verbose do IO.puts("Invalid input: #{inspect(invalid_input)} -> Output: #{inspect(result)}") end formatted_input = format_arguments(invalid_input) flunk( "Function accepted invalid input without validation and returned #{inspect(result)}. Expected function to either raise an exception or validate input types.\n\nGenerated arguments:\n#{formatted_input}" ) end @spec format_arguments(list()) :: String.t() defp format_arguments(args) when is_list(args) do args |> Enum.with_index(1) |> Enum.map(fn {arg, index} -> " #{index}. #{inspect(arg, pretty: true, width: 80)}" end) |> Enum.join(",\n") end defp format_arguments(arg), do: " #{inspect(arg, pretty: true, width: 80)}" @spec handle_expected_exception(any(), Exception.t(), boolean()) :: :ok defp handle_expected_exception(invalid_input, exception, true) do IO.puts( "Invalid input: #{inspect(invalid_input)} -> Exception: #{Exception.message(exception)} ✓" ) end @spec handle_expected_exception(any(), Exception.t(), boolean()) :: :ok defp handle_expected_exception(_invalid_input, _exception, false), do: :ok # Public API functions delegating to submodules @doc "Extracts the typespec for a given function." defdelegate get_function_spec(module, function_name), to: TypeParser @doc "Compares two type specifications for equivalence." defdelegate types_equivalent?(type1, type2), to: TypeParser @doc "Creates a struct generator from @type definition." defdelegate create_struct_from_type_definition(module), to: TypeParser @doc "Parses a spec into input and output types." defdelegate parse_spec(spec), to: TypeParser, as: :parse_spec @doc "Creates input generators from type specifications. Optionally accepts a module to resolve user-defined type aliases." @spec create_input_generator_runtime([any()], module() | nil) :: Enumerable.t() def create_input_generator_runtime(input_types, module \\ nil) do Generators.create_input_generator(input_types, module) end @doc "Creates output validators from type specifications." @spec create_output_validator_runtime(any()) :: (any() -> boolean()) def create_output_validator_runtime(output_type) do Validators.create_output_validator(output_type) end @doc "Creates invalid input generators from type specifications." @spec create_invalid_input_generator_runtime([any()], module() | nil) :: Enumerable.t() def create_invalid_input_generator_runtime(input_types, module \\ nil) do InvalidGenerators.create_invalid_input_generator(input_types, module) end @doc """ Validates type consistency between @type definitions and @spec definitions. """ @spec validate_type_consistency(module(), atom()) :: :ok def validate_type_consistency(module, function_name) do try do case Code.Typespec.fetch_types(module) do {:ok, types} -> type_def = Enum.find_value(types, fn {:type, {:t, type_ast, []}} -> type_ast _ -> nil end) case type_def do {:type, _, :map, field_types} -> validate_struct_field_consistency(module, function_name, field_types) _ -> :ok end _ -> :ok end rescue e -> raise e end end @doc """ Formats a typespec AST into a human-readable string. """ @spec format_type_for_display(any()) :: String.t() def format_type_for_display(type_ast) do case type_ast do {:type, _, :integer, []} -> "integer()" {:type, _, :float, []} -> "float()" {:type, _, :boolean, []} -> "boolean()" {:type, _, :atom, []} -> "atom()" {:type, _, :binary, []} -> "binary()" {:type, _, :list, [inner]} -> "list(#{format_type_for_display(inner)})" {:type, _, :list, []} -> "list()" {:type, _, :map, []} -> "map()" {:type, _, :tuple, elements} -> "{#{Enum.map_join(elements, ", ", &format_type_for_display/1)}}" {:type, _, :union, types} -> Enum.map_join(types, " | ", &format_type_for_display/1) {:remote_type, _, [{:atom, _, module}, {:atom, _, type}, args]} -> formatted_args = if args == [], do: "", else: "(#{Enum.map_join(args, ", ", &format_type_for_display/1)})" "#{module}.#{type}#{formatted_args}" {:user_type, _, name, []} -> "#{name}()" {:atom, _, value} -> ":#{value}" _ -> inspect(type_ast) end end @doc """ Infers a human-readable type name from a result value. """ @spec infer_result_type(any()) :: String.t() def infer_result_type(result) do cond do is_boolean(result) -> "boolean()" is_integer(result) -> "integer()" is_float(result) -> "float()" is_atom(result) -> "atom()" is_binary(result) -> "binary()" is_list(result) -> infer_list_type(result) is_map(result) -> infer_map_type(result) is_tuple(result) -> infer_tuple_type(result) true -> "term()" end end @spec infer_list_type([any()]) :: String.t() defp infer_list_type([]), do: "list(term())" @spec infer_list_type([any()]) :: String.t() defp infer_list_type(list) do element_types = list |> Enum.map(&infer_result_type/1) |> Enum.uniq() case element_types do [single_type] -> "list(#{single_type})" _ -> "list(term())" end end @spec infer_map_type(map()) :: String.t() defp infer_map_type(map) when map == %{}, do: "map()" @spec infer_map_type(map()) :: String.t() defp infer_map_type(map) do # Check if it's a struct case Map.get(map, :__struct__) do nil -> infer_regular_map_type(map) struct_name -> "%#{inspect(struct_name)}{}" end end @spec infer_regular_map_type(map()) :: String.t() defp infer_regular_map_type(map) do entries = Map.to_list(map) key_types = entries |> Enum.map(fn {k, _v} -> infer_result_type(k) end) |> Enum.uniq() value_types = entries |> Enum.map(fn {_k, v} -> infer_result_type(v) end) |> Enum.uniq() cond do # Small map with atom keys - show specific keys length(entries) <= 5 and key_types == ["atom()"] -> infer_map_with_specific_keys(entries) # Consistent key and value types - show pattern length(key_types) == 1 and length(value_types) == 1 -> "%{#{hd(key_types)} => #{hd(value_types)}}" # Consistent key type but mixed values length(key_types) == 1 -> "%{#{hd(key_types)} => term()}" # Mixed keys and values true -> "map()" end end @spec infer_map_with_specific_keys([{any(), any()}]) :: String.t() defp infer_map_with_specific_keys(entries) do fields = entries |> Enum.sort_by(fn {k, _v} -> k end) |> Enum.map(fn {key, value} -> "#{key}: #{infer_result_type(value)}" end) |> Enum.join(", ") "%{#{fields}}" end @spec infer_tuple_type(tuple()) :: String.t() defp infer_tuple_type(tuple) do elements = Tuple.to_list(tuple) case elements do [] -> "{}" _ -> element_types = Enum.map(elements, &infer_result_type/1) "{#{Enum.join(element_types, ", ")}}" end end # Private helper functions @spec validate_struct_field_consistency(module(), atom(), [any()]) :: :ok defp validate_struct_field_consistency(module, function_name, type_field_types) do with {:ok, {[spec_input_type], spec_output_type}} <- get_function_spec(module, function_name), type_fields when not is_nil(type_fields) <- TypeParser.extract_struct_fields(type_field_types), spec_fields when not is_nil(spec_fields) <- extract_struct_fields_from_spec(spec_input_type) do validate_all_fields(module, function_name, type_fields, spec_fields, spec_output_type) else _ -> :ok end end @spec validate_all_fields( module(), atom(), [{atom(), any()}] | nil, %{atom() => any()} | nil, any() ) :: :ok defp validate_all_fields(module, function_name, type_fields, spec_fields, spec_output_type) do Enum.each(type_fields, fn {field_name, type_field_type} -> validate_single_field( module, function_name, field_name, type_field_type, spec_fields, spec_output_type ) end) end @spec validate_single_field(module(), atom(), atom(), any(), %{atom() => any()}, any()) :: :ok defp validate_single_field( module, function_name, field_name, type_field_type, spec_fields, spec_output_type ) do case Map.get(spec_fields, field_name) do nil -> :ok ^type_field_type -> :ok spec_field_type -> validate_field_type_mismatch( module, function_name, field_name, type_field_type, spec_field_type, spec_output_type ) end end @spec validate_field_type_mismatch(module(), atom(), atom(), any(), any(), any()) :: :ok defp validate_field_type_mismatch( module, function_name, field_name, type_field_type, spec_field_type, spec_output_type ) do if types_equivalent?(type_field_type, spec_field_type) do :ok else test_field_inconsistency( module, function_name, field_name, type_field_type, spec_field_type, spec_output_type ) end end @spec test_field_inconsistency(module(), atom(), atom(), any(), any(), any()) :: :ok defp test_field_inconsistency( module, function_name, field_name, type_field_type, spec_field_type, spec_output_type ) do case create_struct_from_type_definition(module) do nil -> :ok type_generator -> test_struct = type_generator |> Enum.take(1) |> List.first() try do result = apply(module, function_name, [test_struct]) output_validator = create_output_validator_runtime(spec_output_type) unless output_validator.(result) do raise_type_inconsistency_error( module, field_name, type_field_type, spec_field_type, "This causes function to return invalid output." ) end rescue e -> raise_type_inconsistency_error( module, field_name, type_field_type, spec_field_type, "Error: #{inspect(e)}" ) end end end @spec raise_type_inconsistency_error(module(), atom(), any(), any(), String.t()) :: no_return() defp raise_type_inconsistency_error( module, field_name, type_field_type, spec_field_type, suffix ) do raise "Type inconsistency: @type #{module}.t defines field :#{field_name} as #{inspect(type_field_type)} but @spec expects #{inspect(spec_field_type)}. #{suffix}" end @spec extract_struct_fields_from_spec(any()) :: %{atom() => any()} | nil defp extract_struct_fields_from_spec({:type, _, :map, field_types}) do TypeParser.extract_struct_fields(field_types) end @spec extract_struct_fields_from_spec(any()) :: %{atom() => any()} | nil defp extract_struct_fields_from_spec(_), do: nil # Counter helpers for tracking successful test runs @spec start_counter() :: pid() defp start_counter do {:ok, agent} = Agent.start_link(fn -> 0 end) agent end @spec increment_counter(pid()) :: :ok defp increment_counter(agent) do Agent.update(agent, &(&1 + 1)) end @spec get_counter(pid()) :: non_neg_integer() defp get_counter(agent) do Agent.get(agent, & &1) end @spec stop_counter(pid()) :: :ok defp stop_counter(agent) do Agent.stop(agent) end end