yak(behavior-tests-web-core-components-uyju): behavior tests: web core components

This commit is contained in:
Willem van den Ende
2026-10-29 10:14:27 +00:00
parent ef8407b272
commit b2bd6bc3aa
@@ -0,0 +1,226 @@
defmodule FirehoseWeb.CoreComponentsTest do
# Pure render tests: no conn, no database, so plain ExUnit.Case (the same
# choice layouts_test.exs makes) instead of the ConnCase sandbox.
use ExUnit.Case, async: true
import Phoenix.LiveViewTest
alias FirehoseWeb.CoreComponents
@checkbox_assigns %{
type: "checkbox",
name: "prefs[news]",
id: "prefs_news",
label: "News",
value: "true",
rest: %{},
errors: [],
field: nil
}
@select_assigns %{
type: "select",
name: "role",
id: "role",
label: "Role",
prompt: "Pick one",
options: [{"Admin", "admin"}, {"Editor", "editor"}],
value: "admin",
multiple: false,
rest: %{},
errors: [],
field: nil
}
@textarea_assigns %{
type: "textarea",
name: "body",
id: "body",
label: "Body",
value: "hello world",
rest: %{},
errors: [],
field: nil
}
describe "input/1 checkbox" do
test "a checked box renders checked when the value is true" do
html = render_checkbox(%{})
assert attr(html, "input[type='checkbox']", "name") == "prefs[news]"
assert attr(html, "input[type='checkbox']", "value") == "true"
assert attr(html, "input[type='checkbox']", "checked") == ""
assert text(html) =~ "News"
end
test "an unchecked box still posts false for the field" do
html = render_checkbox(%{value: "false"})
assert attr(html, "input[type='checkbox']", "checked") == nil
# Markup is the behaviour here: the hidden field carrying the same name is
# what makes an unchecked box post "false" instead of posting nothing.
assert attr(html, "input[type='hidden']", "name") == "prefs[news]"
assert attr(html, "input[type='hidden']", "value") == "false"
end
test "shows the error message when the field has errors" do
html = render_checkbox(%{errors: ["can't be blank"]})
assert text(html) =~ "can't be blank"
end
end
describe "input/1 select" do
test "renders the prompt, every option, and preselects the current value" do
html = render_select(%{})
assert text(html) =~ "Pick one"
assert text(html) =~ "Admin"
assert text(html) =~ "Editor"
# The outcome, not the serialisation: the option carrying the current
# value is selected, the other one is not.
assert attr(html, "option[value='admin']", "selected") == ""
assert attr(html, "option[value='editor']", "selected") == nil
end
test "marks the select erroneous and shows the message when given errors" do
html = render_select(%{errors: ["is invalid"]})
assert text(html) =~ "is invalid"
assert "select-error" in classes(html, "select")
end
end
describe "input/1 textarea" do
test "keeps the value inside the textarea" do
html = render_textarea(%{value: "hello world"})
# normalize_value/2 prefixes the content with a newline, so compare the
# trimmed text: what matters is the value, not the surrounding whitespace.
assert String.trim(text(html, "textarea")) == "hello world"
end
test "renders an empty textarea for a nil value instead of the text nil" do
html = render_textarea(%{value: nil})
assert String.trim(text(html, "textarea")) == ""
refute html =~ "nil"
end
test "keeps a leading newline in the value" do
# HTML drops one leading newline of a <textarea> before the browser sees
# it, so normalize_value/2 prefixes the content with one: a value that
# starts with a newline survives, and one that does not gain one.
html = render_textarea(%{value: "\nsecond line"})
assert text(html, "textarea") == "\nsecond line"
end
test "marks the textarea erroneous and shows the message when given errors" do
html = render_textarea(%{errors: ["is too short"]})
assert text(html) =~ "is too short"
assert "textarea-error" in classes(html, "textarea")
end
end
describe "button/1" do
test "renders a button element when given no destination" do
html = render_button(%{})
assert count(html, "button") == 1
assert count(html, "a") == 0
assert String.trim(text(html)) == "Save"
end
test "renders a link to the destination when given an href, keeping the label" do
html = render_button(%{rest: %{href: "/somewhere"}})
assert attr(html, "a", "href") == "/somewhere"
assert String.trim(text(html)) == "Save"
assert count(html, "button") == 0
end
test "renders a live-navigation link when given navigate" do
html = render_button(%{rest: %{navigate: "/elsewhere"}})
# data-phx-link is the only thing distinguishing a live-navigation link
# from a full-page one, so it is the behaviour to pin here.
assert attr(html, "a", "data-phx-link") == "redirect"
assert attr(html, "a", "href") == "/elsewhere"
end
end
defp render_checkbox(overrides) do
render_component(&CoreComponents.input/1, Map.merge(@checkbox_assigns, overrides))
end
defp render_select(overrides) do
render_component(&CoreComponents.input/1, Map.merge(@select_assigns, overrides))
end
defp render_textarea(overrides) do
render_component(&CoreComponents.input/1, Map.merge(@textarea_assigns, overrides))
end
defp render_button(overrides) do
assigns =
Map.merge(
%{
variant: nil,
class: "",
rest: %{},
# A slot, as HEEx builds it: a list of entries whose inner_block takes
# (argument, changed).
inner_block: [%{inner_block: fn _, _ -> "Save" end}]
},
overrides
)
render_component(&CoreComponents.button/1, assigns)
end
# Every attribute assertion goes through here, so the LazyHTML mechanism lives
# in one place: one value per matched element, nil when the element (or the
# attribute) is absent, "" for a valueless attribute such as `checked`.
defp attr(html, selector, name) do
html
|> nodes(selector)
|> LazyHTML.attribute(name)
|> List.first()
end
# The class tokens of every matched element, for the "this field is marked
# erroneous" assertions.
defp classes(html, selector) do
html
|> nodes(selector)
|> LazyHTML.attribute("class")
|> Enum.flat_map(&String.split/1)
end
defp nodes(html, selector) do
html
|> LazyHTML.from_fragment()
|> LazyHTML.query(selector)
end
defp count(html, selector) do
html
|> nodes(selector)
|> Enum.count()
end
defp text(html) do
html
|> LazyHTML.from_fragment()
|> LazyHTML.text()
end
defp text(html, selector) do
html
|> nodes(selector)
|> LazyHTML.text()
end
end