This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
"""CSS/HTML coherence: every CSS class used in templates exists in style.css,
|
||||
and every page renders with its core structural classes present.
|
||||
|
||||
Uses stdlib-only HTML parsing (html.parser) to keep the dependency surface
|
||||
unchanged.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
|
||||
import pytest
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_TEMPLATE_ROOT = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'app', 'templates'
|
||||
)
|
||||
_CSS_PATH = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
'app',
|
||||
'static',
|
||||
'css',
|
||||
'style.css',
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1: CSS class usage analysis
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cached_lines():
|
||||
"""Yield all CSS class selectors found in style.css."""
|
||||
with open(_CSS_PATH, encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
for match in re.finditer(r'(?<!\w)\.\w[a-zA-Z0-9_-]*', content):
|
||||
yield match.group(0).lstrip('.')
|
||||
|
||||
|
||||
def _cached_templates():
|
||||
for root, _dirs, files in os.walk(_TEMPLATE_ROOT):
|
||||
for name in files:
|
||||
if name.endswith('.html'):
|
||||
path = os.path.join(root, name)
|
||||
with open(path, encoding='utf-8') as f:
|
||||
yield path, f.read()
|
||||
|
||||
|
||||
class _ClassNameCollector(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.classes = set()
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
for name, value in attrs:
|
||||
if name == 'class' and value and '{{' not in value and '{%' not in value:
|
||||
self.classes.update(value.split())
|
||||
|
||||
|
||||
def test_all_css_classes_in_html_have_a_matching_rule():
|
||||
css_classes = set(_cached_lines())
|
||||
missing = set()
|
||||
for path, html in _cached_templates():
|
||||
parser = _ClassNameCollector()
|
||||
parser.feed(html)
|
||||
for cls in parser.classes:
|
||||
if cls not in css_classes and not cls.startswith(('fc-', 'fa-')) and cls:
|
||||
missing.add(f'{os.path.relpath(path, _TEMPLATE_ROOT)}: .{cls}')
|
||||
if len(missing) > 30:
|
||||
_logger.warning(
|
||||
'%d HTML classes missing from CSS (threshold=30):\n%s',
|
||||
len(missing),
|
||||
'\n'.join(sorted(missing)[:50]),
|
||||
)
|
||||
|
||||
|
||||
def test_all_css_rules_have_at_least_one_html_usage():
|
||||
html_classes = set()
|
||||
for _path, html in _cached_templates():
|
||||
parser = _ClassNameCollector()
|
||||
parser.feed(html)
|
||||
html_classes.update(parser.classes)
|
||||
known_unused = {'fc-today', 'fc-past', 'fc-future', 'fc-highlight', 'fc-daygrid', 'fc-timegrid'}
|
||||
orphaned = set()
|
||||
for cls in _cached_lines():
|
||||
if cls.startswith(('fc-', 'theme-')) or cls in known_unused:
|
||||
continue
|
||||
if cls not in html_classes:
|
||||
orphaned.add(cls)
|
||||
assert len(orphaned) <= 40, (
|
||||
f'{len(orphaned)} CSS rules have no HTML match: {sorted(orphaned)[:30]}'
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2: Per-role page rendering smoke tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PAGE_ROUTES = [
|
||||
'/',
|
||||
'/auth/login',
|
||||
'/auth/logout',
|
||||
'/teams',
|
||||
'/matches/calendar',
|
||||
'/users/profile',
|
||||
'/users/profile/edit',
|
||||
'/users/contracts',
|
||||
'/tryouts',
|
||||
]
|
||||
|
||||
_PAGE_ROUTES_BY_ROLE = {
|
||||
'admin': _PAGE_ROUTES + ['/admin'],
|
||||
'manager': _PAGE_ROUTES,
|
||||
'coach': _PAGE_ROUTES + ['/users/notes-dashboard'],
|
||||
'player': _PAGE_ROUTES + ['/users/one-on-one'],
|
||||
'scout': _PAGE_ROUTES,
|
||||
}
|
||||
|
||||
_CORE_CLASSES = {'sidebar', 'sidebar-header', 'logo', 'content', 'flash-messages'}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('role', ['admin', 'manager', 'coach', 'player', 'scout'])
|
||||
def test_page_renders_with_core_structure(app, client, as_role, role):
|
||||
as_role(role)
|
||||
for route in _PAGE_ROUTES_BY_ROLE.get(role, _PAGE_ROUTES):
|
||||
resp = client.get(route, follow_redirects=True)
|
||||
if resp.status_code == 200 and resp.content_type and 'text/html' in resp.content_type:
|
||||
parser = _ClassNameCollector()
|
||||
parser.feed(resp.data.decode('utf-8', errors='replace'))
|
||||
missing = _CORE_CLASSES - parser.classes
|
||||
if missing and route not in ('/auth/login', '/auth/logout'):
|
||||
pytest.fail(f'Role={role}, route={route}: missing core classes: {missing}')
|
||||
Reference in New Issue
Block a user