Current section
Files
Jump to
Current section
Files
lib/cmdc_test/workflow/assertions.ex
defmodule CMDCTest.Workflow.Assertions do
@moduledoc """
Workflow Runtime contract 断言 helper。
接收 `CMDCOrchestrator.status/2` 返回的 `%{run, node_runs, events}` 快照,
或 `CMDCTest.Workflow.Fixtures.status_snapshot/1` 生成的离线 fixture。
"""
import ExUnit.Assertions
@doc "断言 run 已成功完成。"
@spec assert_workflow_completed(map()) :: map()
def assert_workflow_completed(snapshot) do
run = run!(snapshot)
assert normalize_status(field(run, :status)) == "succeeded"
run
end
@doc "断言指定节点状态。"
@spec assert_node_status(map(), String.t(), atom() | String.t()) :: map()
def assert_node_status(snapshot, node_id, expected_status) do
node = find_node!(snapshot, node_id)
assert normalize_status(field(node, :status)) == normalize_status(expected_status)
node
end
@doc "断言节点 signal 路径。"
@spec assert_signal_path(map(), [{String.t(), String.t()}]) :: map()
def assert_signal_path(snapshot, expected) when is_list(expected) do
actual =
snapshot
|> node_runs()
|> Map.new(fn node -> {field(node, :node_id), field(node, :signal)} end)
Enum.each(expected, fn {node_id, signal} ->
assert Map.get(actual, node_id) == signal
end)
snapshot
end
@doc "断言存在指定类型事件。"
@spec assert_workflow_event(map(), String.t(), keyword()) :: map()
def assert_workflow_event(snapshot, type, opts \\ []) do
node_id = Keyword.get(opts, :node_id)
payload = Keyword.get(opts, :payload, %{})
event =
snapshot
|> events()
|> Enum.find(fn event ->
field(event, :type) == type and matches_node?(event, node_id) and
payload_subset?(field(event, :payload) || %{}, payload)
end)
assert event != nil, "expected workflow event #{inspect(type)} node_id=#{inspect(node_id)}"
event
end
@doc "断言 human_task 已进入等待或产生创建事件。"
@spec assert_human_task_created(map(), keyword()) :: map()
def assert_human_task_created(snapshot, opts \\ []) do
node_id = Keyword.get(opts, :node_id, "legal_review")
assert_workflow_event(snapshot, "human_task.created", node_id: node_id)
end
@doc "断言同一批节点没有重复执行。"
@spec assert_no_duplicate_node_attempts(map(), [String.t()]) :: map()
def assert_no_duplicate_node_attempts(snapshot, node_ids) do
attempts =
snapshot
|> node_runs()
|> Enum.filter(&(field(&1, :node_id) in node_ids))
|> Map.new(fn node -> {field(node, :node_id), field(node, :attempts) || 0} end)
Enum.each(node_ids, fn node_id ->
assert Map.get(attempts, node_id, 0) <= 1,
"expected #{node_id} to run at most once, got #{inspect(Map.get(attempts, node_id))}"
end)
snapshot
end
@doc "断言两个返回值命中同一 run id,用于 HTTP 重试幂等测试。"
@spec assert_idempotent_run(term(), term()) :: String.t()
def assert_idempotent_run(first, second) do
first_id = normalize_run_id(first)
second_id = normalize_run_id(second)
assert first_id == second_id
first_id
end
@doc "断言 Gateway/SSE 事件具备 Trace Viewer 必需字段。"
@spec assert_gateway_event_shape(map(), keyword()) :: map()
def assert_gateway_event_shape(event, opts \\ []) do
expected_event = Keyword.get(opts, :event)
assert is_binary(field(event, :event))
assert is_binary(field(event, :run_id))
assert is_binary(field(event, :trace_id))
assert is_binary(field(event, :span_id))
assert is_binary(field(event, :timestamp))
assert is_map(field(event, :payload))
if expected_event do
assert field(event, :event) == expected_event
end
event
end
defp run!(%{run: run}), do: run
defp run!(%{"run" => run}), do: run
defp run!(snapshot) do
flunk("expected workflow status snapshot with :run, got: #{inspect(snapshot)}")
end
defp node_runs(%{node_runs: nodes}) when is_list(nodes), do: nodes
defp node_runs(%{"node_runs" => nodes}) when is_list(nodes), do: nodes
defp node_runs(_snapshot), do: []
defp events(%{events: events}) when is_list(events), do: events
defp events(%{"events" => events}) when is_list(events), do: events
defp events(_snapshot), do: []
defp find_node!(snapshot, node_id) do
node = Enum.find(node_runs(snapshot), &(field(&1, :node_id) == node_id))
assert node != nil, "expected node_run #{inspect(node_id)}"
node
end
defp matches_node?(_event, nil), do: true
defp matches_node?(event, node_id), do: field(event, :node_id) == node_id
defp payload_subset?(actual, expected) when is_map(actual) and is_map(expected) do
Enum.all?(expected, fn {key, value} -> field(actual, key) == value end)
end
defp field(%_{} = struct, key), do: Map.get(struct, key)
defp field(map, key) when is_map(map),
do: Map.get(map, key) || Map.get(map, Atom.to_string(key))
defp field(_value, _key), do: nil
defp normalize_status(status) when is_atom(status), do: Atom.to_string(status)
defp normalize_status(status) when is_binary(status), do: status
defp normalize_run_id({:ok, run_id}) when is_binary(run_id), do: run_id
defp normalize_run_id({:ok, %{id: run_id}}), do: run_id
defp normalize_run_id(%{id: run_id}), do: run_id
defp normalize_run_id(run_id) when is_binary(run_id), do: run_id
end