Packages

A direct-mode Terminal UI framework for Elixir/BEAM

Current section

Files

Jump to
term_ui lib term_ui test component_harness.ex
Raw

lib/term_ui/test/component_harness.ex

defmodule TermUI.Test.ComponentHarness do
@moduledoc """
Test harness for isolated component testing.
Mounts a component in isolation with a test renderer, allowing
event simulation and state/render inspection.
## Usage
# Mount component
{:ok, harness} = ComponentHarness.mount_test(MyButton, label: "Click me")
# Render
harness = ComponentHarness.render(harness)
# Send events
harness = ComponentHarness.send_event(harness, Event.key(:enter))
# Inspect state and render
state = ComponentHarness.get_state(harness)
renderer = ComponentHarness.get_renderer(harness)
# Cleanup
ComponentHarness.unmount(harness)
## Component Interface
Components must implement these callbacks:
- `init/1` - Initialize state from props
- `render/1` - Render component to nodes
- `handle_event/2` (optional) - Handle events
## Example Component
defmodule Counter do
def init(props) do
%{count: Keyword.get(props, :initial, 0)}
end
def render(state) do
text("Count: \#{state.count}")
end
def handle_event(%Event.Key{key: :up}, state) do
{:noreply, %{state | count: state.count + 1}}
end
def handle_event(_event, state) do
{:noreply, state}
end
end
"""
alias TermUI.Test.TestRenderer
@type t :: %__MODULE__{
module: module(),
state: term(),
renderer: TestRenderer.t(),
props: keyword(),
events: [term()],
renders: [term()],
area: map()
}
defstruct module: nil,
state: nil,
renderer: nil,
props: [],
events: [],
renders: [],
area: %{width: 80, height: 24}
@doc """
Mounts a component in isolation for testing.
## Options
- `:width` - Renderer width (default: 80)
- `:height` - Renderer height (default: 24)
- `:props` - Initial props to pass to component
## Examples
{:ok, harness} = ComponentHarness.mount_test(MyButton, label: "Click")
{:ok, harness} = ComponentHarness.mount_test(MyWidget, width: 40, height: 10)
"""
@spec mount_test(module(), keyword()) :: {:ok, t()} | {:error, term()}
def mount_test(module, opts \\ []) do
width = Keyword.get(opts, :width, 80)
height = Keyword.get(opts, :height, 24)
props = Keyword.delete(opts, :width) |> Keyword.delete(:height)
with {:ok, renderer} <- TestRenderer.new(height, width),
{:ok, state} <- init_component(module, props) do
harness = %__MODULE__{
module: module,
state: state,
renderer: renderer,
props: props,
events: [],
renders: [],
area: %{width: width, height: height}
}
{:ok, harness}
end
end
defp init_component(module, props) do
if function_exported?(module, :init, 1) do
{:ok, module.init(props)}
else
{:ok, %{}}
end
end
@doc """
Unmounts the component and cleans up resources.
"""
@spec unmount(t()) :: :ok
def unmount(%__MODULE__{renderer: renderer}) do
TestRenderer.destroy(renderer)
end
@doc """
Renders the component to the test renderer.
Returns the updated harness with render result stored.
"""
@spec render(t()) :: t()
def render(%__MODULE__{} = harness) do
if function_exported?(harness.module, :render, 1) do
render_result = harness.module.render(harness.state)
# Store render result for inspection
harness = %{harness | renders: [render_result | harness.renders]}
# Render to buffer if result is renderable
harness = render_to_buffer(harness, render_result)
harness
else
harness
end
end
defp render_to_buffer(harness, render_result) do
# Clear buffer first
TestRenderer.clear(harness.renderer)
# Simple render - just handle basic text nodes for now
render_node(harness.renderer, render_result, 1, 1)
harness
end
defp render_node(renderer, %{type: :text, content: content}, row, col) do
TestRenderer.write_string(renderer, row, col, content)
end
defp render_node(renderer, %{type: :stack, direction: :vertical, children: children}, row, col) do
Enum.reduce(children, row, fn child, current_row ->
render_node(renderer, child, current_row, col)
current_row + 1
end)
end
defp render_node(
renderer,
%{type: :stack, direction: :horizontal, children: children},
row,
col
) do
Enum.reduce(children, col, fn child, current_col ->
width = render_node(renderer, child, row, current_col)
current_col + width
end)
end
defp render_node(renderer, content, row, col) when is_binary(content) do
TestRenderer.write_string(renderer, row, col, content)
end
defp render_node(_renderer, _node, _row, _col), do: 0
@doc """
Sends an event to the component.
Returns the updated harness with new state.
"""
@spec send_event(t(), term()) :: t()
def send_event(%__MODULE__{} = harness, event) do
harness = %{harness | events: [event | harness.events]}
if function_exported?(harness.module, :handle_event, 2) do
case harness.module.handle_event(event, harness.state) do
{:noreply, new_state} ->
%{harness | state: new_state}
{:noreply, new_state, _commands} ->
%{harness | state: new_state}
{:reply, _reply, new_state} ->
%{harness | state: new_state}
_ ->
harness
end
else
harness
end
end
@doc """
Sends multiple events in sequence.
"""
@spec send_events(t(), [term()]) :: t()
def send_events(%__MODULE__{} = harness, events) when is_list(events) do
Enum.reduce(events, harness, fn event, acc ->
send_event(acc, event)
end)
end
@doc """
Gets the current component state.
"""
@spec get_state(t()) :: term()
def get_state(%__MODULE__{state: state}), do: state
@doc """
Gets the test renderer for inspection.
"""
@spec get_renderer(t()) :: TestRenderer.t()
def get_renderer(%__MODULE__{renderer: renderer}), do: renderer
@doc """
Gets the most recent render result.
"""
@spec get_render(t()) :: term() | nil
def get_render(%__MODULE__{renders: []}), do: nil
def get_render(%__MODULE__{renders: [latest | _]}), do: latest
@doc """
Gets all render results (most recent first).
"""
@spec get_renders(t()) :: [term()]
def get_renders(%__MODULE__{renders: renders}), do: renders
@doc """
Gets all events sent (most recent first).
"""
@spec get_events(t()) :: [term()]
def get_events(%__MODULE__{events: events}), do: events
@doc """
Gets the render area dimensions.
"""
@spec get_area(t()) :: map()
def get_area(%__MODULE__{area: area}), do: area
@doc """
Updates component state directly (for testing edge cases).
Use sparingly - prefer sending events for realistic testing.
"""
@spec update_state(t(), (term() -> term())) :: t()
def update_state(%__MODULE__{} = harness, fun) when is_function(fun, 1) do
%{harness | state: fun.(harness.state)}
end
@doc """
Sets component state directly.
"""
@spec set_state(t(), term()) :: t()
def set_state(%__MODULE__{} = harness, new_state) do
%{harness | state: new_state}
end
@doc """
Gets state value at path.
"""
@spec get_state_at(t(), [atom() | String.t()]) :: term()
def get_state_at(%__MODULE__{state: state}, path) do
get_in(state, path)
end
@doc """
Checks if state has changed since last render.
"""
@spec state_changed?(t()) :: boolean()
def state_changed?(%__MODULE__{renders: [], state: _}), do: true
def state_changed?(%__MODULE__{} = _harness) do
# Would need to track previous state for this
true
end
@doc """
Simulates a render cycle: render -> wait -> check.
Renders the component and returns the harness for assertions.
"""
@spec render_cycle(t()) :: t()
def render_cycle(%__MODULE__{} = harness) do
harness
|> render()
end
@doc """
Simulates an event cycle: send event -> render -> check.
"""
@spec event_cycle(t(), term()) :: t()
def event_cycle(%__MODULE__{} = harness, event) do
harness
|> send_event(event)
|> render()
end
@doc """
Resets the harness to initial state.
"""
@spec reset(t()) :: {:ok, t()} | {:error, term()}
def reset(%__MODULE__{} = harness) do
with {:ok, state} <- init_component(harness.module, harness.props) do
TestRenderer.clear(harness.renderer)
{:ok,
%{
harness
| state: state,
events: [],
renders: []
}}
end
end
end