test/openagents_web/icon_affordances_test.exs

main at 58e6347eeb72 · 3 KB

defmodule OpenAgentsWeb.IconAffordancesTest do
  @moduledoc """
  Guards the one thing that can go wrong when a visible word is replaced by a
  glyph: the control keeps its name for anyone not looking at it.

  Removing the visible label is a visual decision. Removing the accessible name
  is a defect, and it is invisible in a screenshot, so it is asserted here
  rather than reviewed by eye.
  """

  use OpenAgentsWeb.ConnCase
  import Phoenix.LiveViewTest

  describe "icon-only controls" do
    test "the composer send control is named even though it shows only a glyph", %{conn: conn} do
      conn = log_in_github_user(conn, "icon-send-user")
      {:ok, view, html} = live(conn, ~p"/sarah")

      assert has_element?(view, ~s(#send-message[aria-label="Send"]))

      # The glyph itself must not also announce, or the control says it twice.
      refute has_element?(view, ~s(#send-message svg[aria-label]))
      assert has_element?(view, ~s(#send-message svg[aria-hidden="true"]))

      # The visible word is gone; the name is not.
      button = view |> element("#send-message") |> render()
      assert button =~ "<svg"
      refute button =~ "SEND"
      assert html =~ ~s(aria-label="Send")
    end

    test "notice dismissal keeps its name after losing the word CLOSE", %{conn: conn} do
      conn = log_in_github_user(conn, "icon-close-user")
      {:ok, view, _html} = live(conn, ~p"/sarah")

      assert has_element?(view, ~s(#client-error [aria-label="Dismiss notice"]))
    end
  end

  describe "glyphs beside words" do
    test "are decorative, so the control is announced once", %{conn: conn} do
      conn = log_in_github_user(conn, "icon-decorative-user")
      {:ok, view, _html} = live(conn, ~p"/sarah")

      for id <- ~w(load-older) do
        refute has_element?(view, "##{id} svg[aria-label]"),
               "the glyph in ##{id} announces itself alongside its text label"
      end
    end

    test "the memory surface pairs destructive actions with a glyph and keeps the words",
         %{conn: conn} do
      conn = log_in_github_user(conn, "icon-memory-user")
      {:ok, view, _html} = live(conn, ~p"/memory")

      html = render(view)

      assert html =~ "Export ALL DATA"
      assert html =~ "DELETE ALL DATA"
      assert has_element?(view, "#export-all-data svg")
      assert has_element?(view, "#delete-all-data svg")
    end
  end

  describe "icon sourcing" do
    test "no surface hand-writes an svg outside the vendored set" do
      # Every glyph must come from `priv/icons` through `icon/1`. A pasted
      # `<svg>` in a template is how a second, unmanaged icon set starts.
      #
      # `icons.ex` holds the embedded markup and `ui.ex` renders the one
      # governed root element, so both implement the vendored set rather than
      # bypassing it.
      #
      # `graph.ex` is exempt for a different reason: it draws data, not
      # affordances. Its circles, rects and paths are positioned from live SCV
      # geometry and cannot come from a fixed glyph set — there is no icon for
      # "a link terminating on this node's surface at this angle". The rule this
      # test defends is "one icon set", not "no vector output", and a graph node
      # is not an icon. Any glyph *inside* a graph surface still goes through
      # `icon/1`.
      #
      # `og/templates.ex` is exempt for the same class of reason: it emits
      # whole Open Graph card *images* (1200x630 SVG documents rasterized to
      # PNG for crawlers), never in-page affordances. Nothing it draws appears
      # in the product UI.
      exempt = ["ui.ex", "icons.ex", "graph.ex", "templates.ex"]

      offenders =
        "lib/openagents_web/**/*.{ex,heex}"
        |> Path.wildcard()
        |> Enum.reject(&String.ends_with?(&1, exempt))
        |> Enum.filter(fn path -> path |> File.read!() |> String.contains?("<svg") end)

      assert offenders == [],
             "inline SVG found in #{Enum.join(offenders, ", ")}; use <.icon name=\"...\" />"
    end
  end
end