feat: Factory DSL and param building.

This commit is contained in:
James Harton 2023-08-10 21:01:45 +12:00
parent 52e3457d4f
commit 8a466a106b
Signed by: james
GPG key ID: 90E82DAA13F624F4
31 changed files with 1036 additions and 42 deletions

View file

@ -17,7 +17,7 @@
# {:compiler, false},
## ...or have command & args adjusted (e.g. enable skip comments for sobelow)
# {:sobelow, "mix sobelow --config"},
{:sobelow, false},
## ...or reordered (e.g. to see output from dialyzer before others)
# {:dialyzer, order: -1},
@ -28,6 +28,6 @@
## custom new tools may be added (Mix tasks or arbitrary commands)
# {:my_task, "mix my_task", env: %{"MIX_ENV" => "prod"}},
# {:my_tool, ["my_tool", "arg with spaces"]}
# {:spark_formatter, "mix spark.formatter --check"}
{:spark_formatter, "mix spark.formatter --check"}
]
]

17
.doctor.exs Normal file
View file

@ -0,0 +1,17 @@
%Doctor.Config{
ignore_modules: [
~r/^Inspect\./,
~r/^Support\./
],
ignore_paths: [],
min_module_doc_coverage: 40,
min_module_spec_coverage: 0,
min_overall_doc_coverage: 50,
min_overall_spec_coverage: 0,
min_overall_moduledoc_coverage: 100,
exception_moduledoc_required: true,
raise: false,
reporter: Doctor.Reporters.Full,
struct_type_spec_required: true,
umbrella: false
}

View file

@ -506,8 +506,8 @@ steps:
- mc alias set store ${S3_ENDPOINT} ${ACCESS_KEY} ${SECRET_KEY}
- mc mb -p store/docs.harton.nz
- mc anonymous set download store/docs.harton.nz
- mc mirror store/docs.harton.nz/$${DRONE_REPO}/$${DRONE_TAG}
- mc mirror store/docs.harton.nz/$${DRONE_REPO}
- mc mirror doc/ store/docs.harton.nz/$${DRONE_REPO}/$${DRONE_TAG}
- mc mirror doc/ store/docs.harton.nz/$${DRONE_REPO}
# - name: hex release
# image: code.harton.nz/james/asdf_container:latest

View file

@ -1,7 +1,7 @@
spark_locals_without_parens = []
spark_locals_without_parens = [attribute: 2, attribute: 3, factory: 1, factory: 2, factory: 3]
[
import_deps: [:spark],
import_deps: [:ash, :spark],
inputs: [
"*.{ex,exs}",
"{config,lib,test}/**/*.{ex,exs}"

View file

@ -6,25 +6,26 @@
Smokestack provides a way to define test factories for your [Ash Resources](https://ash-hq.org/docs/module/ash/latest/ash-resource) using a convenient DSL:
```elixir
defmodule Character do
use Ash.Resource, extension: [Smokestack.Resource]
defmodule MyApp.Factory do
use Smokestack
attributes do
uuid_primary_key :id
attribute :name, :string
attribute :affiliation, :string
factory Character do
attribute :name, &Faker.StarWars.character/0
attribute :affiliation, choose(["Galactic Empire", "Rebel Alliance"])
end
factory do
default do
attribute :name, &Faker.StarWars.character/0
attribute :affiliation, choose(["Galactic Empire", "Rebel Alliance"])
end
factory Character, :trek do
attribute :name, choose(["J.L. Pipes", "Severn", "Slickback"])
attribute :affiliation, choose(["Entrepreneur", "Voyager"])
end
end
variant :trek do
attribute :name, choose(["J.L. Pipes", "Severn", "Slickback"])
attribute :affiliation, choose(["Entrepreneur", "Voyager"])
end
defmodule MyApp.CharacterTest do
use MyApp.DataCase
use MyApp.Factory
test "it can build a character" do
assert character = MyApp.Factory.build!(Character)
end
end
```

View file

@ -1,18 +1,24 @@
defmodule Smokestack do
alias Spark.{Dsl, Dsl.Extension}
@moduledoc """
Documentation for `Smokestack`.
<!--- ash-hq-hide-start --> <!--- -->
## DSL Documentation
### Index
#{Extension.doc_index(Smokestack.Dsl.sections())}
### Docs
#{Extension.doc(Smokestack.Dsl.sections())}
<!--- ash-hq-hide-stop --> <!--- -->
"""
@doc """
Hello world.
use Dsl, default_extensions: [extensions: [Smokestack.Dsl]]
## Examples
iex> Smokestack.hello()
:world
"""
def hello do
:world
end
@type t :: module
end

109
lib/smokestack/builder.ex Normal file
View file

@ -0,0 +1,109 @@
defmodule Smokestack.Builder do
@moduledoc """
Handles the building of parameters and records.
"""
alias Ash.Resource
alias Smokestack.{Dsl.Attribute, Dsl.Info, Template}
@param_option_defaults [keys: :atom, as: :map]
@typedoc "Options that can be passed to `params/4`."
@type param_options :: [param_keys_option | param_as_option]
@typedoc "Key type in the result. Defaults to `#{inspect(@param_option_defaults[:keys])}`."
@type param_keys_option :: {:keys, :atom | :string | :dasherise}
@typedoc "Result type. Defaults to `#{inspect(@param_option_defaults[:as])}`"
@type param_as_option :: {:as, :map | :list}
@type param_result ::
%{required(String.t()) => any}
| %{required(atom) => any}
| [{String.t(), any}]
| [{atom, any}]
@doc """
Build parameters for a resource with a factory.
"""
@spec params(Smokestack.t(), Resource.t(), atom, param_options) ::
{:ok, param_result} | {:error, any}
def params(factory_module, resource, variant \\ :default, overrides \\ %{}, options \\ [])
when is_atom(factory_module) and is_atom(resource) and is_atom(variant) and is_list(options) do
with {:ok, factory} <- get_factory(factory_module, resource, variant),
{:ok, params} <- build_params(factory, overrides, options) do
params =
params
|> maybe_stringify_keys(options)
|> maybe_dasherise_keys(options)
|> maybe_listify_result(options)
{:ok, params}
end
end
@doc "Raising version of `params/2..5`."
@spec params!(Smokestack.t(), Resource.t(), atom, param_options) :: param_result | no_return
def params!(factory_module, resource, variant \\ :default, overrides \\ %{}, options \\ []) do
case params(factory_module, resource, variant, overrides, options) do
{:ok, params} -> params
{:error, reason} -> raise reason
end
end
defp get_factory(factory_module, resource, variant) do
with :error <- Info.factory(factory_module, resource, variant) do
{:error,
ArgumentError.exception(
message: "Factory for `#{inspect(resource)}` variant `#{inspect(variant)}` not found."
)}
end
end
defp build_params(factory, overrides, options) do
factory
|> Map.get(:attributes, [])
|> Enum.filter(&is_struct(&1, Attribute))
|> Enum.reduce({:ok, %{}}, fn attr, {:ok, attrs} ->
case Map.fetch(overrides, attr.name) do
{:ok, override} ->
{:ok, Map.put(attrs, attr.name, override)}
:error ->
value = Template.generate(attr.generator, attrs, options)
{:ok, Map.put(attrs, attr.name, value)}
end
end)
end
defp maybe_stringify_keys(attrs, options) do
if Keyword.get(options, :keys, @param_option_defaults[:keys]) == :string do
Map.new(fn {key, value} -> {Atom.to_string(key), value} end)
else
attrs
end
end
defp maybe_dasherise_keys(attrs, options) do
if Keyword.get(options, :keys, @param_option_defaults[:keys]) == :dasherise do
Map.new(fn {key, value} ->
key =
key
|> Atom.to_string()
|> String.replace("_", "-")
{key, value}
end)
else
attrs
end
end
defp maybe_listify_result(attrs, options) do
if Keyword.get(options, :as, @param_option_defaults[:as]) == :list do
Enum.to_list(attrs)
else
attrs
end
end
end

33
lib/smokestack/dsl.ex Normal file
View file

@ -0,0 +1,33 @@
defmodule Smokestack.Dsl do
alias Spark.Dsl.{Extension, Section}
alias Smokestack.Dsl.{Factory, Transformer, Verifier}
@section %Section{
name: :smokestack,
top_level?: true,
entities: Factory.__entities__()
}
@moduledoc """
The DSL definition for the Smokestack DSL.
<!--- ash-hq-hide-start --> <!--- -->
## DSL Documentation
### Index
#{Extension.doc_index([@section])}
### Docs
#{Extension.doc([@section])}
<!--- ash-hq-hide-stop --> <!--- -->
"""
use Extension,
sections: [@section],
transformers: [Transformer],
verifiers: [Verifier]
end

View file

@ -0,0 +1,43 @@
defmodule Smokestack.Dsl.Attribute do
@moduledoc """
The `attribute ` DSL entity.
See `d:Smokestack.factory.default.attribute` for more information.
"""
defstruct generator: nil, name: nil
alias Ash.Resource
alias Spark.Dsl.Entity
@type t :: %__MODULE__{
generator:
mfa | (-> any) | (Resource.record() -> any) | (Resource.record(), keyword -> any),
name: atom
}
@doc false
@spec __entities__ :: [Entity.t()]
def __entities__,
do: [
%Entity{
name: :attribute,
target: __MODULE__,
args: [:name, :generator],
schema: [
name: [
type: :atom,
required: true,
doc: "The name of the target attribute"
],
generator: [
type: {:or, [{:mfa_or_fun, 0}, {:mfa_or_fun, 1}, {:mfa_or_fun, 2}]},
required: true,
doc: """
A function which can generate an appropriate value for the attribute.œ
"""
]
]
}
]
end

View file

@ -0,0 +1,46 @@
defmodule Smokestack.Dsl.Factory do
@moduledoc """
The `factory` DSL entity.
See `d:Smokestack.factory` for more information.
"""
defstruct attributes: [], resource: nil, variant: :default
alias Ash.Resource
alias Smokestack.Dsl.{Attribute, Template}
alias Spark.Dsl.Entity
@type t :: %__MODULE__{
attributes: [Attribute.t()],
resource: Resource.t(),
variant: atom
}
@doc false
@spec __entities__ :: [Entity.t()]
def __entities__,
do: [
%Entity{
name: :factory,
describe: "Define factories for a resource",
target: __MODULE__,
args: [:resource, {:optional, :variant, :default}],
imports: [Template],
schema: [
resource: [
type: {:behaviour, Ash.Resource},
required: true,
doc: "An Ash Resource"
],
variant: [
type: :atom,
required: false,
doc: "The name of a factory variant",
default: :default
]
],
entities: [attributes: Attribute.__entities__()]
}
]
end

View file

@ -0,0 +1,23 @@
defmodule Smokestack.Dsl.Info do
@moduledoc """
Introspection of Smokestack DSLs.
"""
alias Ash.Resource
alias Smokestack.Dsl.Factory
alias Spark.Dsl.Extension
@doc """
Retrieve a variant for a specific resource.
"""
@spec factory(Smokestack.t(), Resource.t(), atom) :: {:ok, Factory.t()} | :error
def factory(factory, resource, variant) do
factory
|> Extension.get_entities([:smokestack])
|> Enum.find(&(is_struct(&1, Factory) && &1.resource == resource && &1.variant == variant))
|> case do
nil -> :error
factory -> {:ok, factory}
end
end
end

View file

@ -0,0 +1,53 @@
defmodule Smokestack.Dsl.Template do
@moduledoc """
Templates which assist in the generation of values.œ
"""
alias Smokestack.Template
@type mapper :: nil | (any -> any)
@type element :: any
defguardp is_mapper(fun) when is_nil(fun) or is_function(fun, 1)
@doc """
Randomly select between a list of options.
"""
@spec choose(Enumerable.t(element), mapper) :: Template.t()
def choose(options, mapper \\ nil) when is_mapper(mapper),
do: %Template.Choose{options: options, mapper: mapper}
@doc """
Cycle sequentially between a list of options.
"""
@spec cycle(Enumerable.t(element), mapper) :: Template.t()
def cycle(options, mapper \\ nil) when is_mapper(mapper),
do: %Template.Cycle{options: options, mapper: mapper}
@doc """
Generate sequential values.
"""
@spec sequence(mapper, [{:start, number} | {:step, number}]) :: Template.t()
def sequence(mapper \\ nil, sequence_options \\ []) when is_mapper(mapper) do
sequence_options
|> Map.new()
|> Map.put(:mapper, mapper)
|> Enum.reject(&is_nil(elem(&1, 1)))
|> then(&struct(Template.Sequence, &1))
end
@doc """
Call a generator a number of times.
"""
@spec n_times(pos_integer | Range.t(pos_integer, pos_integer), Template.t(), mapper) ::
Template.t()
def n_times(n, generator, mapper \\ nil)
def n_times(n, generator, mapper) when is_integer(n) and n > 0 and is_mapper(mapper),
do: %Template.NTimes{n: n, generator: generator, mapper: mapper}
def n_times(range, generator, mapper)
when is_struct(range, Range) and is_integer(range.first) and is_integer(range.last) and
is_integer(range.step),
do: %Template.NTimes{n: range, generator: generator, mapper: mapper}
end

View file

@ -0,0 +1,12 @@
defmodule Smokestack.Dsl.Transformer do
@moduledoc false
alias Spark.{Dsl, Dsl.Transformer, Error.DslError}
use Transformer
@doc false
@spec transform(Dsl.t()) :: {:ok, Dsl.t()} | {:error, DslError.t()}
def transform(dsl_state) do
{:ok, dsl_state}
end
end

View file

@ -0,0 +1,189 @@
defmodule Smokestack.Dsl.Verifier do
@moduledoc false
alias Ash.Resource.Info
alias Smokestack.{Dsl.Attribute, Dsl.Factory, Template}
alias Spark.{Dsl, Dsl.Verifier, Error.DslError}
use Verifier
@doc false
@impl true
@spec verify(Dsl.t()) :: :ok | {:error, DslError.t()}
def verify(dsl_state) do
error_info = %{module: Verifier.get_persisted(dsl_state, :module), path: [:smokestack]}
factories =
dsl_state
|> Verifier.get_entities([:smokestack])
|> Enum.filter(&is_struct(&1, Factory))
with :ok <- verify_unique_factories(factories, error_info) do
Enum.reduce_while(factories, :ok, fn factory, :ok ->
case verify_factory(factory, error_info) do
:ok -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
end
defp verify_unique_factories(factories, error_info) do
factories
|> Enum.map(&{&1.resource, &1.variant})
|> Enum.frequencies()
|> Enum.reject(&(elem(&1, 1) == 1))
|> Enum.map(&elem(&1, 0))
|> case do
[] ->
:ok
duplicates ->
message =
duplicates
|> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
|> Enum.map(fn {resource, variants} -> {resource, Enum.uniq(variants)} end)
|> Enum.sort_by(&elem(&1, 0))
|> Enum.reduce(
"Multiple factories defined for the following:",
fn {resource, variants}, message ->
variants =
variants
|> Enum.sort()
|> Enum.map_join(", ", &"`#{&1}`")
message <>
"\n - `#{inspect(resource)}`: #{variants}"
end
)
{:error,
DslError.exception(
module: error_info.module,
path: Enum.reverse(error_info.path),
message: message
)}
end
end
defp verify_factory(factory, error_info) do
error_info =
Map.merge(error_info, %{resource: factory.resource, path: [:factory | error_info.path]})
with :ok <- verify_unique_attributes(factory, error_info) do
factory
|> Map.get(:attributes, [])
|> Enum.filter(&is_struct(&1, Attribute))
|> Enum.reduce_while(:ok, fn attribute, :ok ->
case verify_attribute(attribute, error_info) do
:ok -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
end
defp verify_unique_attributes(factory, error_info) do
factory
|> Map.get(:attributes, {})
|> Enum.filter(&is_struct(&1, Attribute))
|> Enum.map(& &1.name)
|> Enum.frequencies()
|> Enum.reject(&(elem(&1, 1) == 1))
|> Enum.map(&elem(&1, 0))
|> case do
[] ->
:ok
duplicates ->
duplicates =
duplicates
|> Enum.uniq()
|> Enum.sort()
|> Enum.map_join(", ", &"`#{&1}`")
{:error,
DslError.exception(
module: error_info.module,
path: Enum.reverse(error_info.path),
message:
"Duplicate attributes for factory `#{inspect(factory.resource)}`/`#{factory.variant}`: " <>
duplicates
)}
end
end
defp verify_attribute(attribute, error_info) do
error_info = %{error_info | path: [:attribute | error_info.path]}
with :ok <- verify_attribute_in_resource(attribute, error_info) do
verify_attribute_generator(attribute, error_info)
end
end
defp verify_attribute_in_resource(attribute, error_info) do
case Info.attribute(error_info.resource, attribute.name) do
nil ->
{:error,
DslError.exception(
module: error_info.module,
path: Enum.reverse(error_info.path),
message:
"No attribute named `#{inspect(attribute.name)}` defined on the `#{inspect(error_info.resource)}` resource."
)}
_ ->
:ok
end
end
defp verify_attribute_generator(attribute, _error_info)
when is_function(attribute.generator, 0),
do: :ok
defp verify_attribute_generator(attribute, _error_info)
when is_function(attribute.generator, 1),
do: :ok
defp verify_attribute_generator(attribute, _error_info)
when is_function(attribute.generator, 2),
do: :ok
defp verify_attribute_generator(attribute, error_info) when is_struct(attribute.generator) do
case Template.impl_for(attribute.generator) do
nil ->
{:error,
DslError.exception(
module: error_info.module,
path: Enum.reverse(error_info.path),
message:
"Protocol `Smokestack.Template` not implemented for `#{inspect(attribute.generator.__struct__)}`."
)}
_ ->
:ok
end
end
defp verify_attribute_generator(%{generator: {m, f, a}}, error_info)
when {m, f, a}
when is_atom(m) and is_atom(f) and is_list(a) do
min_arity = length(a)
max_arity = min_arity + 2
m.info(:functions)
|> Enum.any?(fn {name, arity} ->
name == f && arity >= min_arity && arity <= max_arity
end)
|> case do
true ->
:ok
false ->
{:error,
DslError.exception(
module: error_info.module,
path: Enum.reverse(error_info.path),
message: "No exported function matching `#{inspect(m)}.#{f}/#{min_arity}..#{max_arity}"
)}
end
end
end

View file

@ -0,0 +1,27 @@
defprotocol Smokestack.Template do
@moduledoc """
A protocol for generating values from templates.
"""
@type mapper :: nil | (any -> any)
@doc """
Initialise the template, if required.
"""
@spec init(t) :: t
def init(template)
@doc """
Generate a value from the template.
"""
@spec generate(t, map, keyword) :: any
def generate(template, record, options)
end
defimpl Smokestack.Template, for: Function do
def init(template), do: template
def generate(fun, _record, _options) when is_function(fun, 0), do: fun.()
def generate(fun, record, _options) when is_function(fun, 1), do: fun.(record)
def generate(fun, record, options) when is_function(fun, 2), do: fun.(record, options)
end

View file

@ -0,0 +1,15 @@
defmodule Smokestack.Template.Choose do
@moduledoc false
defstruct options: [], mapper: nil
@type t :: %__MODULE__{options: Enumerable.t(any), mapper: Smokestack.Template.mapper()}
defimpl Smokestack.Template do
def init(choose), do: choose
def generate(choose, _, _) when is_function(choose.mapper, 1),
do: choose.options |> Enum.random() |> choose.mapper.()
def generate(choose, _, _), do: Enum.random(choose.options)
end
end

View file

@ -0,0 +1,41 @@
defmodule Smokestack.Template.Cycle do
@moduledoc false
defstruct options: [], count: 0, mapper: nil, agent: nil
@type t :: %__MODULE__{
options: Enumerable.t(any),
count: non_neg_integer,
mapper: Smokestack.Template.mapper(),
agent: nil | pid
}
defimpl Smokestack.Template do
def init(cycle) do
{:ok, pid} = Agent.start_link(fn -> 0 end)
{options, count} =
Enum.reduce(cycle.options, {[], 0}, fn option, {options, count} ->
{[option | options], count + 1}
end)
%{cycle | options: Enum.reverse(options), count: count, agent: pid}
end
def generate(cycle, _record, _options) when is_function(cycle.mapper, 1) do
count = Agent.get_and_update(cycle.agent, &{&1, &1 + 1})
index = rem(count, cycle.count)
cycle.options
|> Enum.at(index)
|> cycle.mapper.()
end
def generate(cycle, _record, _options) do
count = Agent.get_and_update(cycle.agent, &{&1, &1 + 1})
index = rem(count, cycle.count)
cycle.options
|> Enum.at(index)
end
end
end

View file

@ -0,0 +1,37 @@
defmodule Smokestack.Template.NTimes do
@moduledoc false
defstruct n: 1, generator: nil, mapper: nil
@type t :: %__MODULE__{
n: non_neg_integer,
generator: Smokestack.Template.t(),
mapper: Smokestack.Template.mapper()
}
defimpl Smokestack.Template do
def init(ntimes), do: ntimes
def generate(ntimes, record, options) when is_integer(ntimes.n) do
0..ntimes.n
|> Enum.map(fn _ ->
ntimes.generator
|> Smokestack.Template.generate(record, options)
|> maybe_map(ntimes.mapper)
end)
end
def generate(ntimes, record, options) when is_struct(ntimes.n, Range) do
ntimes.n
|> Enum.random()
|> then(&(0..&1))
|> Enum.map(fn _ ->
ntimes.generator
|> Smokestack.Template.generate(record, options)
|> maybe_map(ntimes.mapper)
end)
end
defp maybe_map(value, fun) when is_function(fun, 1), do: fun.(value)
defp maybe_map(value, _), do: value
end
end

View file

@ -0,0 +1,26 @@
defmodule Smokestack.Template.Sequence do
@moduledoc false
defstruct mapper: nil, start: 1, step: 1, agent: nil
@type t :: %__MODULE__{
mapper: Smokestack.Template.mapper(),
start: number,
step: number,
agent: nil | pid
}
defimpl Smokestack.Template do
def init(sequence) do
{:ok, pid} = Agent.start_link(fn -> sequence.start end)
%{sequence | agent: pid}
end
def generate(sequence, _record, _options) when is_function(sequence.mapper, 1) do
count = Agent.get_and_update(sequence.agent, &{&1, &1 + sequence.step})
sequence.mapper.(count)
end
def generate(sequence, _record, _options),
do: Agent.get_and_update(sequence.agent, &{&1, &1 + sequence.step})
end
end

14
mix.exs
View file

@ -13,12 +13,15 @@ defmodule Smokestack.MixProject do
version: @version,
elixir: "~> 1.15",
start_permanent: Mix.env() == :prod,
consolidate_protocols: Mix.env() != :test,
elixirc_paths: elixirc_paths(Mix.env()),
deps: deps(),
description: @moduledoc,
package: package(),
source_url: "https://code.harton.nz/james/smokestack",
homepage_url: "https://code.harton.nz/james/smokestack",
aliases: aliases()
aliases: aliases(),
dialyzer: [plt_add_apps: [:faker]]
]
end
@ -32,7 +35,6 @@ defmodule Smokestack.MixProject do
]
end
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger],
@ -40,8 +42,6 @@ defmodule Smokestack.MixProject do
]
end
# Run "mix help deps" to learn about dependencies.
# Run "mix help deps" to learn about dependencies.
defp deps do
opts = [only: ~w[dev test]a, runtime: false]
@ -54,6 +54,7 @@ defmodule Smokestack.MixProject do
{:earmark, ">= 0.0.0", opts},
{:ex_check, "~> 0.15", opts},
{:ex_doc, ">= 0.0.0", opts},
{:faker, "~> 0.17", opts},
{:git_ops, "~> 2.6", opts},
{:mix_audit, "~> 2.1", opts}
]
@ -61,7 +62,10 @@ defmodule Smokestack.MixProject do
defp aliases do
[
"spark.formatter": "spark.formatter --extensions=Smokestack.Resource,Smokestack.Factory"
"spark.formatter": "spark.formatter --extensions=Smokestack.Dsl"
]
end
defp elixirc_paths(env) when env in ~w[dev test]a, do: ~w[lib test/support]
defp elixirc_paths(_), do: ~w[lib]
end

View file

@ -14,6 +14,7 @@
"ets": {:hex, :ets, "0.8.1", "8ff9bcda5682b98493f8878fc9dbd990e48d566cba8cce59f7c2a78130da29ea", [:mix], [], "hexpm", "6be41b50adb5bc5c43626f25ea2d0af1f4a242fb3fad8d53f0c67c20b78915cc"},
"ex_check": {:hex, :ex_check, "0.15.0", "074b94c02de11c37bba1ca82ae5cc4926e6ccee862e57a485b6ba60fca2d8dc1", [:mix], [], "hexpm", "33848031a0c7e4209c3b4369ce154019788b5219956220c35ca5474299fb6a0e"},
"ex_doc": {:hex, :ex_doc, "0.30.4", "e8395c8e3c007321abb30a334f9f7c0858d80949af298302daf77553468c0c39", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "9a19f0c50ffaa02435668f5242f2b2a61d46b541ebf326884505dfd3dd7af5e4"},
"faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"},
"git_ops": {:hex, :git_ops, "2.6.0", "e0791ee1cf5db03f2c61b7ebd70e2e95cba2bb9b9793011f26609f22c0900087", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "b98fca849b18aaf490f4ac7d1dd8c6c469b0cc3e6632562d366cab095e666ffe"},

View file

@ -0,0 +1,17 @@
defmodule Smokestack.BuilderTest do
@moduledoc false
use ExUnit.Case, async: true
alias Smokestack.Builder
alias Support.{Factory, Post}
describe "params/2..5" do
test "it builds params" do
assert {:ok, params} = Builder.params(Factory, Post)
assert params |> Map.keys() |> Enum.sort() == ~w[body tags title]a
assert is_binary(params.body)
assert Enum.all?(params.tags, &is_binary/1)
assert is_binary(params.title)
end
end
end

View file

@ -0,0 +1,86 @@
defmodule Smokestack.DslTest do
use ExUnit.Case, async: true
alias Spark.Error.DslError
defmodule Post do
@moduledoc false
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
validate_api_inclusion?: false
ets do
private? true
end
attributes do
uuid_primary_key :id
attribute :title, :string
end
end
defmodule Factory do
@moduledoc false
use Smokestack
factory Post do
attribute :title, &Faker.Company.catch_phrase/0
end
factory Post, :lorem do
attribute :title, &Faker.Lorem.sentence/0
end
end
test "it compiles" do
assert Factory.spark_is() == Smokestack
end
test "files with multiple default factories for the same resource fail" do
assert_raise DslError, fn ->
defmodule MultiDefaultFactory do
@moduledoc false
use Smokestack
factory Post do
attribute :title, &Faker.Company.catch_phrase/0
end
factory Post do
attribute :title, &Faker.Company.catch_phrase/0
end
end
end
end
test "files with multiple named factories for the same resource fail" do
assert_raise DslError, fn ->
defmodule MultiNamedFactory do
@moduledoc false
use Smokestack
factory Post, :with_title do
attribute :title, &Faker.Company.catch_phrase/0
end
factory Post, :with_title do
attribute :title, &Faker.Company.catch_phrase/0
end
end
end
end
test "factories with duplicate attributes fail" do
assert_raise DslError, fn ->
defmodule MultiAttributeFactory do
@moduledoc false
use Smokestack
factory Post do
attribute :title, &Faker.Company.catch_phrase/0
attribute :title, &Faker.Company.catch_phrase/0
end
end
end
end
end

View file

@ -0,0 +1,31 @@
defmodule Smokestack.Template.ChooseTest do
@moduledoc false
use ExUnit.Case, async: true
alias Smokestack.{Template, Template.Choose}
describe "Smokestack.Template.init/1" do
test "it doesn't do anything" do
choose = %Choose{options: [1, 2, 3], mapper: &Function.identity/1}
assert ^choose = Template.init(choose)
end
end
describe "Smokestack.Template.generate/3" do
test "it chooses a random option" do
value =
%Choose{options: [1, 2, 3]}
|> Template.generate(nil, nil)
assert value in [1, 2, 3]
end
test "it can map the chosen value" do
value =
%Choose{options: [1, 2, 3], mapper: &(&1 * 3)}
|> Template.generate(nil, nil)
assert value in [3, 6, 9]
end
end
end

View file

@ -0,0 +1,36 @@
defmodule Smokestack.Template.CycleTest do
@moduledoc false
use ExUnit.Case, async: true
alias Smokestack.{Template, Template.Cycle}
describe "Smokestack.Template.init/1" do
test "it starts an agent" do
cycle = Template.init(%Cycle{options: [:a, :b, :c]})
assert is_pid(cycle.agent)
end
end
describe "Smokestack.Template.generate/3" do
test "it cycles through it's options" do
cycle = Template.init(%Cycle{options: [:a, :b, :c]})
assert :a = Template.generate(cycle, nil, nil)
assert :b = Template.generate(cycle, nil, nil)
assert :c = Template.generate(cycle, nil, nil)
assert :a = Template.generate(cycle, nil, nil)
assert :b = Template.generate(cycle, nil, nil)
assert :c = Template.generate(cycle, nil, nil)
end
test "it can map it's options" do
cycle = Template.init(%Cycle{options: ~w[a b c], mapper: &String.upcase/1})
assert "A" = Template.generate(cycle, nil, nil)
assert "B" = Template.generate(cycle, nil, nil)
assert "C" = Template.generate(cycle, nil, nil)
assert "A" = Template.generate(cycle, nil, nil)
assert "B" = Template.generate(cycle, nil, nil)
assert "C" = Template.generate(cycle, nil, nil)
end
end
end

View file

@ -0,0 +1,28 @@
defmodule Smokestack.Template.SequenceTest do
@moduledoc false
use ExUnit.Case, async: true
alias Smokestack.{Template, Template.Sequence}
describe "Smokestack.Template.init/1" do
test "it starts an agent" do
sequence =
%Sequence{}
|> Template.init()
assert is_pid(sequence.agent)
end
end
describe "Smokestack.Template.generate/3" do
test "it generates sequential values" do
sequence =
%Sequence{}
|> Template.init()
assert 1 = Template.generate(sequence, nil, nil)
assert 2 = Template.generate(sequence, nil, nil)
assert 3 = Template.generate(sequence, nil, nil)
assert 4 = Template.generate(sequence, nil, nil)
end
end
end

View file

@ -1,8 +1,4 @@
defmodule SmokestackTest do
use ExUnit.Case
doctest Smokestack
test "greets the world" do
assert Smokestack.hello() == :world
end
end

8
test/support/api.ex Normal file
View file

@ -0,0 +1,8 @@
defmodule Support.Api do
@moduledoc false
use Ash.Api, validate_config_inclusion?: false
resources do
allow_unregistered? true
end
end

28
test/support/author.ex Normal file
View file

@ -0,0 +1,28 @@
defmodule Support.Author do
@moduledoc false
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
validate_api_inclusion?: false
ets do
private? true
end
attributes do
uuid_primary_key :id
attribute :name, :string
attribute :email, :ci_string
timestamps()
end
relationships do
has_many :posts, Support.Post
end
actions do
defaults [:create, :read, :update, :destroy]
end
end

39
test/support/factory.ex Normal file
View file

@ -0,0 +1,39 @@
defmodule Support.Factory do
@moduledoc false
use Smokestack
factory Support.Author do
attribute :name, &Faker.Person.name/0
attribute :email, &Faker.Internet.email/0
end
factory Support.Author, :trek do
attribute :name, choose(["JL", "Doc Holoday", "BLT", "Cal Hudson"])
attribute :email, fn
%{name: "JL"} -> "captain@entrepreneur.starfleet"
%{name: "Doc Holoday"} -> "cheifmed@voyager.starfleet"
%{name: "BLT"} -> "cheifeng@voyager.starfleet"
%{name: "Cal Hudson"} -> "cal@maquis.stfu"
end
end
factory Support.Post do
attribute :title, &Faker.Commerce.product_name/0
attribute :tags, n_times(3..20, &Faker.Lorem.word/0)
attribute :body, &Faker.Markdown.markdown/0
end
factory Support.Post, :trek do
attribute :title,
choose([
"On the safety of conference attendance",
"Who would win? Q vs Kevin Uxbridge - an analysis",
"Improvised tools for warp core maintenance",
"Cardassia Prime: Hot or Not?"
])
attribute :body, &Faker.Markdown.markdown/0
end
end

42
test/support/post.ex Normal file
View file

@ -0,0 +1,42 @@
defmodule Support.Post do
@moduledoc false
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
validate_api_inclusion?: false
ets do
private? true
end
attributes do
uuid_primary_key :id
attribute :title, :string do
allow_nil? false
end
attribute :sub_title, :string
attribute :tags, {:array, :ci_string} do
constraints items: [
match: ~r/^[a-zA-Z]+$/,
casing: :lower
]
end
attribute :body, :string do
allow_nil? false
end
timestamps()
end
relationships do
belongs_to :author, Support.Author
end
actions do
defaults [:create, :read, :update, :destroy]
end
end