Skip to content

Commit

Permalink
Remove public functions from ParameterizedTest (#20)
Browse files Browse the repository at this point in the history
This makes `import ParameterizedTest` give you *only* the `param_test`
macro, rather than (accidentally) pulling in the `parse_examples`
function which is really an implementation detail.

Resolves #19
  • Loading branch information
s3cur3 authored Aug 15, 2024
1 parent 88c5c4a commit 8a7a431
Show file tree
Hide file tree
Showing 6 changed files with 184 additions and 176 deletions.
169 changes: 4 additions & 165 deletions lib/parameterized_test.ex
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ defmodule ParameterizedTest do
See the README for more information.
"""
alias ParameterizedTest.Parser

@doc """
Defines tests that use your parameters or example data.
Expand All @@ -99,18 +100,18 @@ defmodule ParameterizedTest do
case file_extension do
ext when ext in [".md", ".markdown", ".csv"] ->
str
|> parse_file_path_examples(context)
|> Parser.parse_file_path_examples(context)
|> Macro.escape()

_ ->
str
|> parse_examples(context)
|> Parser.parse_examples(context)
|> Macro.escape()
end

list when is_list(list) ->
list
|> parse_examples(context)
|> Parser.parse_examples(context)
|> Macro.escape()

already_escaped when is_tuple(already_escaped) ->
Expand Down Expand Up @@ -146,166 +147,4 @@ defmodule ParameterizedTest do
end
end
end

@typep context :: [{:line, integer} | {:file, String.t()}]

@spec parse_examples(String.t() | list, context()) :: [map()]
def parse_examples(table, context \\ [])

def parse_examples(table, context) when is_binary(table) do
table
|> String.split("\n", trim: true)
|> Enum.map(&String.trim/1)
|> parse_md_rows(context)
end

# This function head handles a list of already-parsed examples, like:
# param_test "accepts a list of maps or keyword lists",
# [
# [int_1: 99, int_2: 100],
# %{int_1: 101, int_2: 102}
# ], %{int_1: int_1, int_2: int_2} do
def parse_examples(table, context) when is_list(table) do
{evaled_table, _, _} = Code.eval_quoted_with_env(table, [], __ENV__)

case evaled_table do
str when is_binary(str) -> parse_file_path_examples(str, context)
list when is_list(list) -> parse_hand_rolled_table(list, context)
end
end

defp parse_hand_rolled_table(evaled_table, context) do
parsed_table = Enum.map(evaled_table, &Map.new/1)

keys = MapSet.new(parsed_table, &Map.keys/1)

if MapSet.size(keys) > 1 do
raise """
The keys in each row must be the same across all rows in your example table.
Found differing key sets#{file_meta(context)}:
#{for key_set <- Enum.sort(keys), do: inspect(key_set)}
"""
end

parsed_table
end

defp parse_file_path_examples(path, context) do
file = File.read!(path)

case path |> Path.extname() |> String.downcase() do
md when md in [".md", ".markdown"] -> parse_examples(file, context)
".csv" -> parse_csv_file(file, context)
".tsv" -> parse_tsv_file(file, context)
_ -> raise "Unsupported file extension for parameterized tests #{path} #{file_meta(context)}"
end
end

defp parse_csv_file(file, context) do
file
|> ParameterizedTest.CsvParser.parse_string(skip_headers: false)
|> parse_csv_rows(context)
end

defp parse_tsv_file(file, context) do
file
|> ParameterizedTest.TsvParser.parse_string()
|> Enum.map(&String.trim/1)
|> parse_csv_rows(context)
end

defp parse_md_rows(rows, context)
defp parse_md_rows([], _context), do: []

defp parse_md_rows([header | rows], context) do
headers =
header
|> split_cells()
|> Enum.map(&String.to_atom/1)

rows
|> Enum.reject(&separator_or_comment_row?/1)
|> Enum.map(fn row ->
cells =
row
|> split_cells()
|> Enum.map(&eval_cell(&1, row, context))

check_cell_count(cells, headers, row, context)

headers
|> Enum.zip(cells)
|> Map.new()
end)
|> Enum.reject(&(&1 == %{}))
end

defp parse_csv_rows(rows, context)
defp parse_csv_rows([], _context), do: []

defp parse_csv_rows([header | rows], context) do
headers = Enum.map(header, &String.to_atom/1)

rows
|> Enum.map(fn row ->
cells = Enum.map(row, &eval_cell(&1, row, context))

check_cell_count(cells, headers, row, context)

headers
|> Enum.zip(cells)
|> Map.new()
end)
|> Enum.reject(&(&1 == %{}))
end

defp eval_cell(cell, row, _context) do
case Code.eval_string(cell, [], log: false) do
{val, []} -> val
_ -> raise "Failed to evaluate example cell `#{cell}` in row `#{row}`}"
end
rescue
_e in [SyntaxError, CompileError, TokenMissingError] ->
String.trim(cell)

e ->
reraise "Failed to evaluate example cell `#{cell}` in row `#{row}`. #{inspect(e)}", __STACKTRACE__
end

defp check_cell_count(cells, headers, row, context) do
if length(cells) != length(headers) do
raise """
The number of cells in each row must exactly match the
number of headers on your example table.
Problem row#{file_meta(context)}:
#{row}
Expected headers:
#{inspect(headers)}
"""
end
end

defp split_cells(row) do
row
|> String.split("|", trim: true)
|> Enum.map(&String.trim/1)
end

# A regex to match rows consisting of pipes separated by hyphens, like |------|-----|
@separator_regex ~r/^\|( ?-+ ?\|)+$/

defp separator_or_comment_row?("#" <> _), do: true

defp separator_or_comment_row?(row) do
Regex.match?(@separator_regex, row)
end

defp file_meta(%{file: file, line: line}) when is_binary(file) and is_integer(line) do
" (#{file}:#{line})"
end

defp file_meta(_), do: ""
end
166 changes: 166 additions & 0 deletions lib/parameterized_test/parser.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
defmodule ParameterizedTest.Parser do
@moduledoc false

@typep context :: [{:line, integer} | {:file, String.t()}]

@spec parse_examples(String.t() | list, context()) :: [map()]
def parse_examples(table, context \\ [])

def parse_examples(table, context) when is_binary(table) do
table
|> String.split("\n", trim: true)
|> Enum.map(&String.trim/1)
|> parse_md_rows(context)
end

# This function head handles a list of already-parsed examples, like:
# param_test "accepts a list of maps or keyword lists",
# [
# [int_1: 99, int_2: 100],
# %{int_1: 101, int_2: 102}
# ], %{int_1: int_1, int_2: int_2} do
def parse_examples(table, context) when is_list(table) do
{evaled_table, _, _} = Code.eval_quoted_with_env(table, [], __ENV__)

case evaled_table do
str when is_binary(str) -> parse_file_path_examples(str, context)
list when is_list(list) -> parse_hand_rolled_table(list, context)
end
end

defp parse_hand_rolled_table(evaled_table, context) do
parsed_table = Enum.map(evaled_table, &Map.new/1)

keys = MapSet.new(parsed_table, &Map.keys/1)

if MapSet.size(keys) > 1 do
raise """
The keys in each row must be the same across all rows in your example table.
Found differing key sets#{file_meta(context)}:
#{for key_set <- Enum.sort(keys), do: inspect(key_set)}
"""
end

parsed_table
end

@spec parse_file_path_examples(String.t(), context()) :: [map()]
def parse_file_path_examples(path, context) do
file = File.read!(path)

case path |> Path.extname() |> String.downcase() do
md when md in [".md", ".markdown"] -> parse_examples(file, context)
".csv" -> parse_csv_file(file, context)
".tsv" -> parse_tsv_file(file, context)
_ -> raise "Unsupported file extension for parameterized tests #{path} #{file_meta(context)}"
end
end

defp parse_csv_file(file, context) do
file
|> ParameterizedTest.CsvParser.parse_string(skip_headers: false)
|> parse_csv_rows(context)
end

defp parse_tsv_file(file, context) do
file
|> ParameterizedTest.TsvParser.parse_string()
|> Enum.map(&String.trim/1)
|> parse_csv_rows(context)
end

defp parse_md_rows(rows, context)
defp parse_md_rows([], _context), do: []

defp parse_md_rows([header | rows], context) do
headers =
header
|> split_cells()
|> Enum.map(&String.to_atom/1)

rows
|> Enum.reject(&separator_or_comment_row?/1)
|> Enum.map(fn row ->
cells =
row
|> split_cells()
|> Enum.map(&eval_cell(&1, row, context))

check_cell_count(cells, headers, row, context)

headers
|> Enum.zip(cells)
|> Map.new()
end)
|> Enum.reject(&(&1 == %{}))
end

defp parse_csv_rows(rows, context)
defp parse_csv_rows([], _context), do: []

defp parse_csv_rows([header | rows], context) do
headers = Enum.map(header, &String.to_atom/1)

rows
|> Enum.map(fn row ->
cells = Enum.map(row, &eval_cell(&1, row, context))

check_cell_count(cells, headers, row, context)

headers
|> Enum.zip(cells)
|> Map.new()
end)
|> Enum.reject(&(&1 == %{}))
end

defp eval_cell(cell, row, _context) do
case Code.eval_string(cell, [], log: false) do
{val, []} -> val
_ -> raise "Failed to evaluate example cell `#{cell}` in row `#{row}`}"
end
rescue
_e in [SyntaxError, CompileError, TokenMissingError] ->
String.trim(cell)

e ->
reraise "Failed to evaluate example cell `#{cell}` in row `#{row}`. #{inspect(e)}", __STACKTRACE__
end

defp check_cell_count(cells, headers, row, context) do
if length(cells) != length(headers) do
raise """
The number of cells in each row must exactly match the
number of headers on your example table.
Problem row#{file_meta(context)}:
#{row}
Expected headers:
#{inspect(headers)}
"""
end
end

defp split_cells(row) do
row
|> String.split("|", trim: true)
|> Enum.map(&String.trim/1)
end

# A regex to match rows consisting of pipes separated by hyphens, like |------|-----|
@separator_regex ~r/^\|( ?-+ ?\|)+$/

defp separator_or_comment_row?("#" <> _), do: true

defp separator_or_comment_row?(row) do
Regex.match?(@separator_regex, row)
end

defp file_meta(%{file: file, line: line}) when is_binary(file) and is_integer(line) do
" (#{file}:#{line})"
end

defp file_meta(_), do: ""
end
2 changes: 1 addition & 1 deletion lib/parameterized_test/sigil_v114.exs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,6 @@ defmodule ParameterizedTest.Sigil do
@spec sigil_x(String.t(), Keyword.t()) :: [map()]
# credo:disable-for-next-line Credo.Check.Readability.FunctionNames
def sigil_x(table, _opts \\ []) do
ParameterizedTest.parse_examples(table)
ParameterizedTest.Parser.parse_examples(table)
end
end
2 changes: 1 addition & 1 deletion lib/parameterized_test/sigil_v115.exs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,6 @@ defmodule ParameterizedTest.Sigil do
@spec sigil_PARAMS(String.t(), Keyword.t()) :: [map()]
# credo:disable-for-next-line Credo.Check.Readability.FunctionNames
def sigil_PARAMS(table, _opts \\ []) do
ParameterizedTest.parse_examples(table)
ParameterizedTest.Parser.parse_examples(table)
end
end
11 changes: 11 additions & 0 deletions test/parameterized_test/parser_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
defmodule ParameterizedTest.ParserTest do
use ExUnit.Case, async: true

describe "parse_examples/1" do
test "accepts strings that parse as empty" do
for empty <- ["", " ", "\n", "\t", "\r\n", "\n\n \r\n \t \r"] do
assert ParameterizedTest.Parser.parse_examples(empty) == []
end
end
end
end
Loading

0 comments on commit 8a7a431

Please sign in to comment.