Files
FabledSteward/tests/core/test_macros.py
T
bvandeusenandClaude Opus 5.5 477b05b9d0 feat(ui): status_badge, status_dot, stat_tile, empty_state, page_header, icon_button and form classes (#5094)
#5094 "Status and layout macros, and form classes".

- formatting.STATES: one state -> (tone, label) map for up, down, stale,
  firing, pending, acknowledged, no_data, ok, warn, crit, resolved and
  inactive; status_info() reads it (an unmapped state is drawn dim under its
  own name). required() lets a macro refuse a missing argument.
- _macros.html: status_badge and status_dot (a dot always carries its label,
  sr-only unless shown), stat_tile, empty_state, page_header (actions via a
  call block) and icon_button, which raises without a label.
- steward.css: tone classes (badge-/dot-/stat- ok, warn, crit, info, dim),
  .sr-only, .page-header, .stat-tile, .empty-state, .btn-icon and the form
  classes .field, .field-label, .field-hint and .input. Info text is a 40%
  mix (--blue): the house --fs-info-fg falls to 4.18:1 on its tint over a
  hovered row, 40% holds 5.09:1.
- First users: the Alerts list, history and maintenance pages (headers,
  state badges, empty states) and the alert forms' hints; the inline inp/lbl
  style sets on the monitor and host forms become .input / .field-label.
- tests/core/test_macros.py renders each macro through the app environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:34:21 -04:00

81 lines
4.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The status and layout macros in _macros.html (#5094), rendered through the
app's own Jinja environment so its globals (status_info, required) are the
real ones."""
import enum
import pytest
from steward.core.formatting import STATES, status_info
async def _render(app, source: str, **ctx) -> str:
async with app.app_context():
tpl = app.jinja_env.from_string('{% from "_macros.html" import status_badge, status_dot, '
'stat_tile, empty_state, page_header, icon_button %}' + source)
out = await tpl.render_async(**ctx) if app.jinja_env.is_async else tpl.render(**ctx)
return " ".join(out.split())
def test_every_state_a_page_shows_has_a_tone_and_a_label():
named = ["up", "down", "stale", "firing", "pending", "acknowledged", "no_data", "ok", "warn", "crit"]
assert set(named) <= set(STATES)
assert {tone for tone, _ in STATES.values()} == {"ok", "warn", "crit", "info", "dim"}
assert status_info("down")[0] == status_info("firing")[0] == "crit"
class _State(enum.Enum):
firing = "firing"
@pytest.mark.parametrize("state, tone, label", [
("firing", "crit", "Firing"), (_State.firing, "crit", "Firing"), ("UP", "ok", "Up"),
("no_data", "warn", "No data"), ("acknowledged", "info", "Acknowledged"),
(None, "dim", "Unknown"), ("half_open", "dim", "Half open"), # unmapped: dim, its own name
])
def test_status_info(state, tone, label):
assert status_info(state) == (tone, label)
async def test_status_badge(app):
assert await _render(app, "{{ status_badge('firing') }}") == '<span class="badge badge-crit">Firing</span>'
out = await _render(app, "{{ status_badge(s, 'Muted', title=t) }}", s=_State.firing, t='a "b"')
assert out == '<span class="badge badge-crit" title="a &#34;b&#34;">Muted</span>'
async def test_a_status_dot_always_carries_its_label(app):
out = await _render(app, "{{ status_dot('down') }}")
assert 'class="dot dot-crit" aria-hidden="true"' in out and '<span class="sr-only">Down</span>' in out
out = await _render(app, "{{ status_dot('up', show_label=true) }}")
assert '<span class="dot-label">Up</span>' in out and "sr-only" not in out
async def test_stat_tile(app):
out = await _render(app, "{{ stat_tile('Firing', 3, state='crit', href='/alerts/?a=1&b=2') }}")
assert out.startswith('<a href="/alerts/?a=1&amp;b=2" class="stat-tile stat-crit">')
assert '<span class="stat-tile-value">3</span>' in out and out.endswith("</a>")
out = await _render(app, "{{ stat_tile('<Hosts>', 12, hint='2 stale') }}")
assert out.startswith('<div class="stat-tile">') and "&lt;Hosts&gt;" in out and "2 stale" in out
async def test_empty_state(app):
out = await _render(app, "{{ empty_state('No rules yet.', 'New rule', '/alerts/rules/new') }}")
assert "<p>No rules yet.</p>" in out and 'href="/alerts/rules/new">New rule</a>' in out
assert "<a" not in await _render(app, "{{ empty_state('Nothing.') }}")
async def test_page_header_with_and_without_actions(app):
out = await _render(app, "{% call page_header('Alerts', 'All rules') %}<a class=\"btn\">New</a>{% endcall %}")
assert '<h1 class="page-title">Alerts</h1>' in out and '<p class="page-subtitle">All rules</p>' in out
assert '<div class="page-actions"><a class="btn">New</a></div>' in out
assert "page-actions" not in await _render(app, "{{ page_header('Alerts') }}")
async def test_icon_button_names_itself_and_refuses_to_render_without_a_label(app):
out = await _render(app, "{{ icon_button('×', 'Delete rule web01', type='submit', cls='btn-danger', confirm='Delete?') }}")
assert 'type="submit"' in out and 'aria-label="Delete rule web01"' in out and 'data-confirm="Delete?"' in out
out = await _render(app, "{{ icon_button('✎', 'Edit', href='/x') }}")
assert out.startswith('<a class="btn btn-sm btn-icon btn-ghost" href="/x" aria-label="Edit"')
for source in ("{{ icon_button('×') }}", "{{ icon_button('×', '') }}", "{{ icon_button('×', ' ') }}"):
with pytest.raises(ValueError, match="icon_button label is required"):
await _render(app, source)