Skip to main content

lib/phoenix_live_gantt/test_helpers.ex

defmodule PhoenixLiveGantt.TestHelpers do
  @moduledoc """
  Render, inspect, and assert helpers for the PhoenixLiveGantt view. Replaces
  ad-hoc probe scripts with a one-line call. Used from tests, IEx, and
  the `mix phoenix_live_gantt.dump` task.

      events = [%PhoenixLiveGantt.Task{id: "a", start: ~D[2026-04-01], end: ~D[2026-04-05]}]
      html = render_waterfall(events)
      geom = inspect_waterfall(events)
      dump_waterfall(events)        # pretty-prints to stdout

  Pass any waterfall attr as an option:

      render_waterfall(events,
        connectors: [%{from: "a", to: "b"}],
        zoom: :day,
        bus_stagger_outgoing_px: 4
      )

  `:date_range` defaults to a tight range derived from the events.

  Also provides geometry assertions for tests:
    * `assert_lanes_evenly_spaced/3` — catches lane-stagger rounding bugs
    * `assert_source_attaches_inside_bar/2` — catches corner-bleed bugs
    * `assert_arrow_tips_clear_target_bars/2` — catches refX/gap bugs

  Lives in `lib/` (not `test/support/`) because the mix task uses it
  at dev runtime.
  """

  use Phoenix.Component
  import Phoenix.LiveViewTest, only: [rendered_to_string: 1]

  alias PhoenixLiveGantt.Inspector
  alias PhoenixLiveGantt.PathFormat

  @doc """
  Render the PhoenixLiveGantt component with the given events and options.
  All component attrs default to their declared defaults; opts override.
  Returns the rendered HTML string.
  """
  @spec render_waterfall([PhoenixLiveGantt.Task.t()], keyword()) :: String.t()
  def render_waterfall(events, opts \\ []) do
    range = Keyword.get(opts, :date_range) || derive_range(events)

    attrs =
      [events: events, date_range: range]
      |> Keyword.merge(Keyword.delete(opts, :date_range))
      |> Map.new()

    assigns = %{attrs: attrs}

    rendered_to_string(~H"<PhoenixLiveGantt.gantt {@attrs} />")
  end

  @doc "Render then immediately inspect into a structured geometry map."
  @spec inspect_waterfall([PhoenixLiveGantt.Task.t()], keyword()) :: map()
  def inspect_waterfall(events, opts \\ []) do
    events |> render_waterfall(opts) |> Inspector.inspect_html()
  end

  @doc """
  Render, inspect, and pretty-print to stdout. Returns the geometry map
  for further inspection.
  """
  @spec dump_waterfall([PhoenixLiveGantt.Task.t()], keyword()) :: map()
  def dump_waterfall(events, opts \\ []) do
    geom = inspect_waterfall(events, opts)
    print_geometry(geom)
    geom
  end

  # -- Range derivation --

  defp derive_range(events) do
    dates =
      events
      |> Enum.flat_map(fn e ->
        [to_date(e.start), to_date(PhoenixLiveGantt.Task.effective_end(e))]
      end)
      |> Enum.reject(&is_nil/1)

    case dates do
      [] ->
        today = Date.utc_today()
        Date.range(today, Date.add(today, 30))

      _ ->
        first = Enum.min(dates, Date)
        last = Enum.max(dates, Date)
        # 1-day padding on each side so bars don't sit on the chart's edge
        Date.range(Date.add(first, -1), Date.add(last, 1))
    end
  end

  defp to_date(%Date{} = d), do: d
  defp to_date(%DateTime{} = dt), do: DateTime.to_date(dt)
  defp to_date(%NaiveDateTime{} = ndt), do: NaiveDateTime.to_date(ndt)
  defp to_date(_), do: nil

  # -- Pretty printing --

  defp print_geometry(geom) do
    IO.puts("=== Rows (top → bottom) ===")

    Enum.each(Enum.with_index(geom.rows), fn {id, i} ->
      bar = Map.get(geom.bars, id, %{})
      IO.puts("  #{String.pad_leading("#{i}", 2)}: #{id}#{format_bar(bar)}")
    end)

    IO.puts("\n=== Connectors (#{length(geom.connectors)}) ===")

    Enum.each(geom.connectors, fn c ->
      flags =
        [{c.critical, "critical"}, {c.invalid, "INVALID"}]
        |> Enum.filter(&elem(&1, 0))
        |> Enum.map(&elem(&1, 1))

      flag_str = if flags == [], do: "", else: " [#{Enum.join(flags, ", ")}]"
      IO.puts("  #{c.from}#{c.to} (#{c.type})#{flag_str}")
      IO.puts("    #{format_segments(c.segments)}")
    end)

    edges = geom.edges

    if edges.earlier > 0 or edges.later > 0 do
      IO.puts("\n=== Edge indicators ===")
      IO.puts("  ← #{edges.earlier} earlier   #{edges.later} later →")
    end
  end

  defp format_bar(%{kind: :bar, left: l, width: w}),
    do: "  bar @ x=#{l}..#{l + w} (#{w}px wide)"

  defp format_bar(%{kind: :milestone, left: l}),
    do: "  ◆ milestone @ x=#{l}"

  defp format_bar(_), do: ""

  defp format_segments(%{kind: :forward, x1: x1, y1: y1, mid: mid, y2: y2, arrow_stop: stop}),
    do: "forward: src=(#{x1},#{y1}) → mid=#{mid} → tgt=(#{stop},#{y2})"

  defp format_segments(%{
         kind: :detour,
         x1: x1,
         y1: y1,
         stem_out: so,
         detour_y: dy,
         stem_in: si,
         y2: y2,
         arrow_stop: stop
       }),
       do:
         "detour:  src=(#{x1},#{y1}) → stem_out=#{so} → detour_y=#{dy} → stem_in=#{si} → tgt=(#{stop},#{y2})"

  defp format_segments(%{kind: :unknown, raw: r}), do: "unknown: #{r}"

  # ============================================================
  # Geometry property assertions — codify visual quality criteria
  # so future regressions get caught automatically.
  # ============================================================

  @doc """
  Assert that all SOURCE attach y values for connectors emerging from
  `source_id` are evenly spaced. Catches lane-stagger rounding bugs.

  Pass `:tolerance_px` (default 0) to allow off-by-N differences in
  spacings (useful for sub-pixel rendering).
  """
  def assert_lanes_evenly_spaced(html, source_id, opts \\ []) do
    tolerance = Keyword.get(opts, :tolerance_px, 0)
    geom = Inspector.inspect_html(html)

    ys =
      geom
      |> Inspector.connectors_from(source_id)
      |> Enum.map(&Inspector.source_attach_y/1)
      |> Enum.reject(&is_nil/1)
      |> Enum.sort()

    case ys do
      [] ->
        raise "assert_lanes_evenly_spaced: no connectors found from #{inspect(source_id)}"

      [_] ->
        :ok

      _ ->
        spacings = Enum.zip(ys, tl(ys)) |> Enum.map(fn {a, b} -> b - a end)
        min_s = Enum.min(spacings)
        max_s = Enum.max(spacings)

        if max_s - min_s > tolerance do
          raise """
          assert_lanes_evenly_spaced: lanes from #{source_id} not evenly spaced.
            Y values: #{inspect(ys)}
            Spacings: #{inspect(spacings)}
            min=#{min_s}, max=#{max_s}, tolerance=#{tolerance}
          """
        end

        :ok
    end
  end

  @doc """
  Assert that every connector's SOURCE attach y falls inside the source
  bar's actual vertical extent (with optional inset for rounded corners).

  Uses Inspector's per-bar `top`/`bottom` (derived from real row
  positions, including group headers) — accurate even when group
  headers shift the row stride.

  `:corner_inset_px` (default 4) — px to inset from bar's top/bottom
  for the rounded-corner area. Defaults match PhoenixLiveGantt's
  `bus_stagger_corner_clearance_px`.
  """
  def assert_source_attaches_inside_bar(html, opts \\ []) do
    corner_inset = Keyword.get(opts, :corner_inset_px, 4)
    geom = Inspector.inspect_html(html)

    violations =
      Enum.flat_map(geom.connectors, fn c ->
        with %{} = bar <- Map.get(geom.bars, c.from),
             y when is_integer(y) <- Inspector.source_attach_y(c) do
          if y < bar.top + corner_inset or y > bar.bottom - corner_inset do
            [{c.from, c.to, y, bar.top, bar.bottom}]
          else
            []
          end
        else
          _ -> []
        end
      end)

    case violations do
      [] ->
        :ok

      _ ->
        raise """
        assert_source_attaches_inside_bar: #{length(violations)} connector(s)
        attach outside the bar's flat region (corner_inset=#{corner_inset}):
        #{Enum.map_join(violations, "\n", fn {f, t, y, top, bot} -> "  #{f}#{t}: y=#{y} (bar y=#{top}..#{bot})" end)}
        """
    end
  end

  @doc """
  Assert that no connector's trunk visually pierces an unrelated bar.
  Walks each path's vertical trunk segment and checks that no other
  task's bar rectangle (excluding the connector's own endpoints) sits
  in the trunk's x-column AND overlaps the trunk's y-span.

  Catches the "arrow visibly cuts through a task bar" class of bug
  that `avoid_collisions` is supposed to prevent.

  Pass `:tolerance_px` (default 0) to allow slight overlaps (useful
  for cases where a bar shares an x-edge with the trunk by 1px).
  """
  def assert_no_unrelated_bar_pierced(html, opts \\ []) do
    tolerance = Keyword.get(opts, :tolerance_px, 0)
    geom = Inspector.inspect_html(html)

    violations =
      Enum.flat_map(geom.connectors, fn c ->
        trunks = trunk_segments(c)
        check_trunks(trunks, c, geom.bars, tolerance)
      end)

    case violations do
      [] ->
        :ok

      _ ->
        raise """
        assert_no_unrelated_bar_pierced: #{length(violations)} bar piercing(s) detected.
        #{Enum.map_join(violations, "\n", &format_pierce/1)}
        """
    end
  end

  # Returns the path's segments to check for piercing as tagged tuples:
  #   {:v, x, y_top, y_bottom}      — vertical segment at column x
  #   {:h, y, x_left, x_right}      — horizontal segment at row y
  # Forward (3-seg): only one vertical (the trunk). Forward's horizontal
  # segments are at y1 (source row) and y2 (target row), which only
  # touch the source/target bars (excluded from the check).
  # Detour (5-seg): two verticals (stem_out, stem_in) plus the horizontal
  # leg at detour_y. The horizontal leg matters because push_detour can
  # land detour_y inside an unrelated row's bar y-range.
  defp trunk_segments(%{segments: %{kind: :forward, mid: x, y1: y1, y2: y2}}) do
    [{:v, x, min(y1, y2), max(y1, y2)}]
  end

  defp trunk_segments(%{
         segments: %{
           kind: :detour,
           y1: y1,
           stem_out: out,
           detour_y: dy,
           stem_in: in_x,
           y2: y2
         }
       }) do
    [
      {:v, out, min(y1, dy), max(y1, dy)},
      {:v, in_x, min(dy, y2), max(dy, y2)},
      {:h, dy, min(out, in_x), max(out, in_x)}
    ]
  end

  defp trunk_segments(_), do: []

  defp check_trunks(trunks, conn, bars, tolerance) do
    excluded = MapSet.new([conn.from, conn.to])

    Enum.flat_map(trunks, fn segment ->
      Enum.flat_map(bars, fn {bar_id, bar} ->
        if MapSet.member?(excluded, bar_id) do
          []
        else
          if pierces?(segment, bar, tolerance) do
            [
              %{
                from: conn.from,
                to: conn.to,
                segment: segment,
                bar_id: bar_id,
                bar: bar
              }
            ]
          else
            []
          end
        end
      end)
    end)
  end

  # Vertical segment piercing: trunk_x inside bar's x range AND trunk's
  # y span overlaps bar's y range.
  defp pierces?({:v, tx, y_top, y_bot}, bar, tolerance) do
    bar_left = Map.get(bar, :hit_box, bar)[:left] || bar.left
    bar_right = Map.get(bar, :hit_box, bar)[:right] || bar.right

    x_inside? = tx > bar_left + tolerance and tx < bar_right - tolerance
    y_overlap? = y_top < bar.bottom - tolerance and y_bot > bar.top + tolerance

    x_inside? and y_overlap?
  end

  # Horizontal segment piercing: trunk y inside bar's y range AND trunk's
  # x span overlaps bar's x range. Catches the case where push_detour
  # lands `detour_y` inside an unrelated row's bar.
  defp pierces?({:h, ty, x_left, x_right}, bar, tolerance) do
    bar_left = Map.get(bar, :hit_box, bar)[:left] || bar.left
    bar_right = Map.get(bar, :hit_box, bar)[:right] || bar.right

    y_inside? = ty > bar.top + tolerance and ty < bar.bottom - tolerance
    x_overlap? = x_left < bar_right - tolerance and x_right > bar_left + tolerance

    y_inside? and x_overlap?
  end

  defp format_pierce(%{from: f, to: t, segment: {:v, tx, _, _}, bar_id: bid, bar: bar}) do
    "  #{f}#{t}: vertical x=#{tx} pierces bar '#{bid}' (x=#{bar.left}..#{bar.right}, y=#{bar.top}..#{bar.bottom})"
  end

  defp format_pierce(%{from: f, to: t, segment: {:h, ty, xl, xr}, bar_id: bid, bar: bar}) do
    "  #{f}#{t}: horizontal y=#{ty} (x=#{xl}..#{xr}) pierces bar '#{bid}' (x=#{bar.left}..#{bar.right}, y=#{bar.top}..#{bar.bottom})"
  end

  @doc """
  Assert every connector's path consists only of axis-aligned segments
  (pure horizontal `H` or vertical `V` moves from the initial M point).
  Catches malformed paths or unexpected shape families.
  """
  def assert_paths_axis_aligned(html) do
    geom = Inspector.inspect_html(html)

    violations =
      Enum.flat_map(geom.connectors, fn c ->
        case c.segments.kind do
          :forward -> []
          :detour -> []
          :unknown -> [{c.from, c.to, c.segments.raw}]
        end
      end)

    case violations do
      [] ->
        :ok

      _ ->
        raise """
        assert_paths_axis_aligned: #{length(violations)} non-axis-aligned path(s).
        #{Enum.map_join(violations, "\n", fn {f, t, raw} -> "  #{f}#{t}: #{raw}" end)}
        """
    end
  end

  @doc """
  Assert all numeric path coordinates are non-negative. Negative coords
  mean a path went off the chart's left/top edge — usually a bug.

  Pass `:allow_negative` to skip (some edge cases legitimately do go
  negative, e.g., :ss arrows near x=0).
  """
  def assert_paths_have_valid_coords(html, opts \\ []) do
    if Keyword.get(opts, :allow_negative, false) do
      :ok
    else
      geom = Inspector.inspect_html(html)

      violations =
        Enum.flat_map(geom.connectors, fn c ->
          coords = path_coords(c.segments)
          negatives = Enum.filter(coords, &(is_number(&1) and &1 < 0))
          if negatives == [], do: [], else: [{c.from, c.to, negatives}]
        end)

      case violations do
        [] ->
          :ok

        _ ->
          raise """
          assert_paths_have_valid_coords: #{length(violations)} path(s) with negative coords.
          #{Enum.map_join(violations, "\n", fn {f, t, ns} -> "  #{f}#{t}: #{inspect(ns)}" end)}
          """
      end
    end
  end

  defp path_coords(%{kind: :forward, x1: a, y1: b, mid: c, y2: d, arrow_stop: e}),
    do: [a, b, c, d, e]

  defp path_coords(%{
         kind: :detour,
         x1: a,
         y1: b,
         stem_out: c,
         detour_y: d,
         stem_in: e,
         y2: f,
         arrow_stop: g
       }),
       do: [a, b, c, d, e, f, g]

  defp path_coords(_), do: []

  @doc """
  Assert that every detour path satisfies the geometric invariants
  PhoenixLiveGantt's stem-shifting logic relies on:

    * `stem_out > x1` — source-side stem must be strictly east of the
      source bar's reference x (FS shape requirement).
    * `stem_in < arrow_stop` — target-side stem must be strictly west of
      the arrow tip (FS approach requirement).

  Catches regressions in `maybe_shift_stem_out` / `maybe_shift_stem_in`
  where a stem could be shifted to an x that breaks the shape's
  geometric validity.
  """
  def assert_detour_invariants_hold(html) do
    geom = Inspector.inspect_html(html)

    violations =
      Enum.flat_map(geom.connectors, fn c ->
        case c.segments do
          %{kind: :detour, x1: x1, stem_out: so, stem_in: si, arrow_stop: stop} ->
            issues = []

            issues =
              if so > x1,
                do: issues,
                else: [{c.from, c.to, "stem_out=#{so} must be > x1=#{x1}"} | issues]

            issues =
              if si < stop,
                do: issues,
                else: [{c.from, c.to, "stem_in=#{si} must be < arrow_stop=#{stop}"} | issues]

            issues

          _ ->
            []
        end
      end)

    case violations do
      [] ->
        :ok

      _ ->
        raise """
        assert_detour_invariants_hold: #{length(violations)} detour shape violation(s).
        #{Enum.map_join(violations, "\n", fn {f, t, msg} -> "  #{f}#{t}: #{msg}" end)}
        """
    end
  end

  @doc """
  Run every geometry assertion against the given html and return a list
  of issues found. Each issue is `{name, exception_message}`. Useful as
  a one-stop "is this render sane?" check.

  Pass `:opts_for` to override per-assertion options:

      find_geometry_issues(html,
        opts_for: %{
          assert_arrow_tips_clear_target_bars: [min_gap_px: 2]
        }
      )
  """
  def find_geometry_issues(html, opts \\ []) do
    overrides = Keyword.get(opts, :opts_for, %{})

    [
      {:paths_axis_aligned, fn -> assert_paths_axis_aligned(html) end},
      {:paths_valid_coords,
       fn ->
         assert_paths_have_valid_coords(
           html,
           Map.get(overrides, :assert_paths_have_valid_coords, [])
         )
       end},
      {:no_pierced_bars,
       fn ->
         assert_no_unrelated_bar_pierced(
           html,
           Map.get(overrides, :assert_no_unrelated_bar_pierced, [])
         )
       end},
      {:source_attaches_inside_bar,
       fn ->
         assert_source_attaches_inside_bar(
           html,
           Map.get(overrides, :assert_source_attaches_inside_bar, [])
         )
       end},
      {:arrow_tips_clear_targets,
       fn ->
         assert_arrow_tips_clear_target_bars(
           html,
           Map.get(overrides, :assert_arrow_tips_clear_target_bars, [])
         )
       end},
      {:detour_invariants, fn -> assert_detour_invariants_hold(html) end},
      {:arrowheads_at_path_ends,
       fn ->
         assert_arrowheads_at_path_ends(
           html,
           Map.get(overrides, :assert_arrowheads_at_path_ends, [])
         )
       end}
    ]
    |> Enum.flat_map(fn {name, fun} ->
      try do
        fun.()
        []
      rescue
        e in RuntimeError -> [{name, Exception.message(e)}]
      end
    end)
  end

  @doc """
  Compare two geometry maps and return a structured diff describing
  what changed. Useful for "I changed X — what else moved?" workflows.

      before = inspect_waterfall(events)
      # ... change something ...
      after = inspect_waterfall(events)
      diff_waterfalls(before, after)
      # %{
      #   row_order: %{changed: false} | %{from: [...], to: [...]},
      #   connectors: %{added: [...], removed: [...], changed: [...]},
      #   edges: %{earlier_delta: int, later_delta: int}
      # }
  """
  def diff_waterfalls(before_geom, after_geom) do
    %{
      row_order: row_order_diff(before_geom.rows, after_geom.rows),
      connectors: connector_diffs(before_geom.connectors, after_geom.connectors),
      edges: edge_diffs(before_geom.edges, after_geom.edges)
    }
  end

  defp row_order_diff(before, after_rows) when before == after_rows, do: %{changed: false}
  defp row_order_diff(before, after_rows), do: %{changed: true, from: before, to: after_rows}

  defp connector_diffs(before, after_conns) do
    by_key = fn list ->
      Map.new(list, fn c -> {{c.from, c.to, c.type}, c} end)
    end

    bm = by_key.(before)
    am = by_key.(after_conns)

    added = Enum.filter(Map.keys(am), &(not Map.has_key?(bm, &1)))
    removed = Enum.filter(Map.keys(bm), &(not Map.has_key?(am, &1)))

    changed =
      Enum.flat_map(bm, fn {key, b} ->
        case Map.get(am, key) do
          nil ->
            []

          a ->
            if a.segments == b.segments,
              do: [],
              else: [{key, segments_delta(b.segments, a.segments)}]
        end
      end)

    %{added: added, removed: removed, changed: changed}
  end

  # Compute per-field delta between two segment maps. Only includes
  # fields that actually changed.
  defp segments_delta(before, after_seg) when before.kind != after_seg.kind do
    %{kind_changed: %{from: before.kind, to: after_seg.kind}}
  end

  defp segments_delta(before, after_seg) do
    before
    |> Enum.flat_map(fn {k, v_before} ->
      v_after = Map.get(after_seg, k)

      if v_before != v_after do
        [{k, %{from: v_before, to: v_after, delta: maybe_delta(v_before, v_after)}}]
      else
        []
      end
    end)
    |> Map.new()
  end

  defp maybe_delta(a, b) when is_number(a) and is_number(b), do: b - a
  defp maybe_delta(_, _), do: nil

  defp edge_diffs(before, after_edges) do
    %{
      earlier_delta: after_edges.earlier - before.earlier,
      later_delta: after_edges.later - before.later
    }
  end

  @doc """
  Assert that no connector's arrow tip pierces meaningfully INTO the target bar.

  Arrow tips intentionally land ON the target bar's edge (gap 0) so they read as
  connected at any responsive fill factor — visual separation is the fixed-px
  arrowhead overlay's job, not a natural-px gap that would stretch. So this
  guards against tips landing INSIDE the bar (a refX/offset bug): a tip is a
  violation when it sits more than `:tol_px` (default 2, absorbing the
  percent↔pixel round-trip) to the bar-interior side of the near edge. Only FS
  arrows (`target_entry=:west`) are checked, since their geometry is the most
  predictable.
  """
  def assert_arrow_tips_clear_target_bars(html, opts \\ []) do
    # `min_gap_px` is the minimum allowed `bar.left - tip`. Default `-tol`: a tip
    # may sit up to `tol` px inside the edge (rounding) but no further.
    tol = Keyword.get(opts, :tol_px, 2)
    min_gap = Keyword.get(opts, :min_gap_px, -tol)
    geom = Inspector.inspect_html(html)

    violations =
      geom.connectors
      |> Enum.filter(&(&1.type == :fs))
      |> Enum.flat_map(fn c ->
        with %{} = bar <- Map.get(geom.bars, c.to),
             tip when is_integer(tip) <- Inspector.arrow_tip_x(c) do
          gap = bar.left - tip

          if gap < min_gap do
            [{c.from, c.to, tip, bar.left, gap}]
          else
            []
          end
        else
          _ -> []
        end
      end)

    case violations do
      [] ->
        :ok

      _ ->
        raise """
        assert_arrow_tips_clear_target_bars: #{length(violations)} arrow tip(s)
        too close to / inside the target bar (min_gap=#{min_gap}):
        #{Enum.map_join(violations, "\n", fn {f, t, tip, edge, g} -> "  #{f}#{t}: tip=#{tip}, target.left=#{edge}, gap=#{g}" end)}
        """
    end
  end

  @doc """
  Assert that every arrowhead sits on its connector's shaft END — the head is
  drawn in a separate, non-stretched overlay layer (so it stays a crisp px
  triangle while the shaft SVG stretches with the responsive fill), and it must
  track the shaft's TRUE terminal point even after path rewrites
  (`consolidate_piercing_trunks` can re-route a forward path so it ends at a
  different y). `:tol_px` (default 2) absorbs the percent↔pixel round-trip.
  """
  def assert_arrowheads_at_path_ends(html, opts \\ []) do
    tol = Keyword.get(opts, :tol_px, 2)
    geom = Inspector.inspect_html(html)
    by_key = Map.new(geom.arrowheads, fn h -> {{h.from, h.to}, h} end)

    violations =
      Enum.flat_map(geom.connectors, fn c ->
        with %{} = head <- Map.get(by_key, {c.from, c.to}),
             %{x: ex, y: ey} <- PathFormat.terminal(c.raw_path) do
          dx = abs(head.tip_x - ex)
          dy = abs(head.tip_y - ey)

          if dx > tol or dy > tol do
            [{c.from, c.to, {head.tip_x, head.tip_y}, {ex, ey}, {dx, dy}}]
          else
            []
          end
        else
          _ -> []
        end
      end)

    case violations do
      [] ->
        :ok

      _ ->
        raise """
        assert_arrowheads_at_path_ends: #{length(violations)} arrowhead(s) off
        the shaft end (tol=#{tol}px):
        #{Enum.map_join(violations, "\n", fn {f, t, head, term, {dx, dy}} -> "  #{f}#{t}: head=#{inspect(head)}, shaft_end=#{inspect(term)}, Δ=(#{dx},#{dy})" end)}
        """
    end
  end
end