Skip to main content

lib/bloccs/web/coverage.ex

defmodule Bloccs.Web.Coverage do
  @moduledoc """
  Structural coverage for the dashboard, computed from the runtime
  `Bloccs.Introspect.Network` (so it needs no parsed manifest). Mirrors
  `Bloccs.Coverage`'s obligation model — every in-port, out-port, and edge — and
  compares it against a *reached* set derived from `Bloccs.Trace` events
  (recorded live or loaded from a `.bloccs-trace` file).
  """

  alias Bloccs.Introspect.Network

  @type obligation ::
          {:port_in, atom(), atom()}
          | {:port_out, atom(), atom()}
          | {:edge, {atom(), atom()}, {atom(), atom()}}

  @type report :: %{
          obligations: [obligation()],
          reached: [obligation()],
          unreached: [obligation()],
          total: non_neg_integer(),
          reached_count: non_neg_integer(),
          percent: integer()
        }

  @doc "Every coverage obligation in a running network."
  @spec obligations(Network.t()) :: [obligation()]
  def obligations(%Network{nodes: nodes, edges: edges}) do
    port_obs =
      Enum.flat_map(nodes, fn n ->
        ins = Enum.map(n.ports_in, &{:port_in, n.id, &1.name})
        outs = Enum.map(n.ports_out, &{:port_out, n.id, &1.name})
        ins ++ outs
      end)

    edge_obs = Enum.map(edges, fn %{from: from, to: to} -> {:edge, from, to} end)

    port_obs ++ edge_obs
  end

  @doc "Compare a network's obligations against a reached obligation set."
  @spec report(Network.t(), [obligation()]) :: report()
  def report(%Network{} = network, reached) do
    all = obligations(network)
    reached_set = MapSet.new(reached)
    reached_present = Enum.filter(all, &MapSet.member?(reached_set, &1))
    unreached = Enum.reject(all, &MapSet.member?(reached_set, &1))

    %{
      obligations: all,
      reached: reached_present,
      unreached: unreached,
      total: length(all),
      reached_count: length(reached_present),
      percent: percent(length(reached_present), length(all))
    }
  end

  @doc "The set of node ids touched by any reached port obligation."
  @spec reached_nodes(report()) :: MapSet.t(atom())
  def reached_nodes(%{reached: reached}) do
    reached
    |> Enum.flat_map(fn
      {:port_in, node, _} -> [node]
      {:port_out, node, _} -> [node]
      {:edge, _, _} -> []
    end)
    |> MapSet.new()
  end

  @doc "The set of `{from_node, to_node}` pairs for reached edges (for the overlay)."
  @spec reached_edges(report()) :: MapSet.t({atom(), atom()})
  def reached_edges(%{reached: reached}) do
    reached
    |> Enum.flat_map(fn
      {:edge, {fnode, _}, {tnode, _}} -> [{fnode, tnode}]
      _ -> []
    end)
    |> MapSet.new()
  end

  @doc "Per-node glyph state for the overlay: reached → `:ok`, otherwise `:idle`."
  @spec node_states(Network.t(), report()) :: %{atom() => :ok | :idle}
  def node_states(%Network{nodes: nodes} = _network, report) do
    reached = reached_nodes(report)
    Map.new(nodes, fn n -> {n.id, if(MapSet.member?(reached, n.id), do: :ok, else: :idle)} end)
  end

  defp percent(_n, 0), do: 0
  defp percent(n, total), do: round(n / total * 100)
end