134 lines
4.3 KiB
Python
134 lines
4.3 KiB
Python
"""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}')
|