tests/test_buttons.py
Rama main · réplica de lectura del repositorio original.
"""Buttons that destroy something look like it, everywhere.
`button.danger` only coloured the label, so a destructive button was the
default purple with red text on it: the look of a primary action. Test users
read it as one. It is now solid red with white text, and every button that
deletes, removes, revokes or unlinks carries the class.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
import factories as fac
from test_routes import bind_to_only_portfolio, make_login
ROOT = Path(__file__).resolve().parent.parent
TEMPLATES = ROOT / "app" / "templates"
CSS = (ROOT / "app" / "static" / "style.css").read_text(encoding="utf-8")
HTML = {"accept": "text/html"}
DESTROYS = re.compile(r"/(delete|remove|revoke|unlink)\b")
def _luminance(hex_colour: str) -> float:
channels = [int(hex_colour[i:i + 2], 16) / 255 for i in (1, 3, 5)]
linear = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
for c in channels]
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
def _contrast(a: str, b: str) -> float:
high, low = sorted((_luminance(a), _luminance(b)), reverse=True)
return (high + 0.05) / (low + 0.05)
def _theme_blocks() -> dict[str, str]:
"""The light tokens, and the two ways of asking for dark."""
light = CSS[CSS.index(":root {"):]
dark_auto = CSS[CSS.index('body[data-theme="auto"] {'):]
dark = CSS[CSS.index('body[data-theme="dark"] {'):]
return {name: block[:block.index("}")]
for name, block in (("light", light), ("auto dark", dark_auto),
("dark", dark))}
@pytest.mark.parametrize("theme", ["light", "auto dark", "dark"])
def test_white_reads_on_the_danger_red_in_every_theme(theme):
"""WCAG AA for body text, 4.5:1. The dark theme's `--down` (#ef4444) is
only 3.8:1 behind white, which is why the button has its own token."""
found = re.search(r"--danger:\s*(#[0-9a-fA-F]{6})", _theme_blocks()[theme])
assert found, f"no --danger colour in the {theme} theme"
assert _contrast(found.group(1), "#ffffff") >= 4.5, found.group(1)
def test_the_danger_button_is_solid_red_with_white_text():
rule = re.search(r"\nbutton\.danger, a\.button\.danger\s*\{([^}]*)\}", CSS)
assert rule, "no button.danger rule"
assert re.search(r"background:\s*var\(--danger\)", rule.group(1)), rule.group(1)
assert re.search(r"color:\s*#fff(fff)?\b", rule.group(1)), rule.group(1)
def _forms_that_destroy() -> list[tuple[str, str, str]]:
"""(template, form id or "", the form's markup) for every POST form whose
action deletes, removes, revokes or unlinks.
Signing a session out is not on the list though its route says "revoke":
nothing is lost, and a red button there would be alarm for nothing."""
found = []
for path in sorted(TEMPLATES.glob("*.html")):
text = path.read_text(encoding="utf-8")
for match in re.finditer(r"<form\b[^>]*>.*?</form>", text, re.S):
tag = match.group(0)[:match.group(0).index(">") + 1]
action = re.search(r'action="([^"]*)"', tag)
if (action and DESTROYS.search(action.group(1))
and not action.group(1).startswith("/profile/sessions/")):
form_id = re.search(r'\bid="([^"]+)"', tag)
found.append((path.name, form_id.group(1) if form_id else "",
match.group(0)))
return found
def test_there_are_destructive_forms_to_check():
"""An empty sweep proves nothing."""
assert len(_forms_that_destroy()) >= 8
def test_every_button_that_destroys_something_is_a_danger_button():
everything = "\n".join(p.read_text(encoding="utf-8") for p in TEMPLATES.glob("*.html"))
plain = []
for name, form_id, markup in _forms_that_destroy():
buttons = re.findall(r"<button\b[^>]*>", markup)
if form_id:
# A button outside the form that submits it with `form=`.
buttons += re.findall(rf'<button\b[^>]*\bform="{form_id}"[^>]*>', everything)
submits = [b for b in buttons if 'type="button"' not in b]
assert submits, f"{name}: a destructive form with no button submitting it"
plain += [(name, b) for b in submits if not re.search(r'class="[^"]*\bdanger\b', b)]
assert not plain, plain
def test_a_button_is_quiet_or_destructive_not_both():
"""`secondary danger` was a grey button with red text: neither style."""
both = [(p.name, b) for p in TEMPLATES.glob("*.html")
for b in re.findall(r'<button\b[^>]*class="[^"]*"[^>]*>', p.read_text(encoding="utf-8"))
if re.search(r'class="[^"]*\bsecondary\b', b)
and re.search(r'class="[^"]*\bdanger\b', b)]
assert not both, both
@pytest.fixture
def edit_page(client, session_factory):
make_login(client, session_factory)
with session_factory() as s:
bind_to_only_portfolio(s)
acme = fac.make_instrument(s, "ACME")
buy = fac.add_trade(s, acme, "2026-03-02", "buy", 100, "5.00")
s.commit()
trade_id = buy.id
page = client.get(f"/trade/{trade_id}/edit", headers=HTML)
assert page.status_code == 200
return trade_id, page.text
def _trade_form(page: str, trade_id: int) -> str:
match = re.search(rf'<form\b[^>]*action="/trade/{trade_id}/edit".*?</form>', page, re.S)
assert match, "no edit form on the page"
return match.group(0)
def test_editing_a_trade_puts_delete_with_save_and_cancel(edit_page):
"""Delete sat in the page header, apart from the card holding the trade.
It belongs with the form's other buttons; a form cannot hold another
form, so it submits the delete form from where it sits."""
trade_id, page = edit_page
form = _trade_form(page, trade_id)
delete = re.search(r'<button\b[^>]*\bform="([^"]+)"[^>]*>\s*Delete\s*</button>', form)
assert delete, "no Delete button inside the trade's form"
target = re.search(rf'<form\b[^>]*\bid="{delete.group(1)}"[^>]*>', page)
assert target and f'action="/trade/{trade_id}/delete"' in target.group(0)
def test_cancel_on_the_edit_page_is_a_button(edit_page):
trade_id, page = edit_page
form = _trade_form(page, trade_id)
assert re.search(r'<a class="button secondary" href="[^"]+">\s*Cancel</a>', form), (
"Cancel should look like the dialog's button, not a bare link")