Files
ESP32_Serial_Swiss_Army_Knife/tests/web_ui_session/layout.py
T
Commander1024 9f6ebf2053 Add Serial and Wi-Fi quick settings
Reuse the existing settings view and typed controllers for accessible
quick editing while keeping network credentials out of quick mode.
Expand browser and layout coverage for focus, dismissal, bounds, and
expiry.
2026-09-13 14:22:31 +02:00

195 lines
12 KiB
Python

"""Structural checks of production HTML; not a browser layout/visual test."""
from html.parser import HTMLParser
class Document(HTMLParser):
def __init__(self, html):
super().__init__(convert_charrefs=True)
self.root = {'tag': 'root', 'attrs': {}, 'children': [], 'text': ''}
self.stack = [self.root]
self.ids = {}
self.feed(html)
assert self.stack == [self.root]
def handle_starttag(self, tag, attrs):
node = {'tag': tag, 'attrs': dict(attrs), 'children': [], 'text': '', 'parent': self.stack[-1]}
self.stack[-1]['children'].append(node)
if 'id' in node['attrs']:
ident = node['attrs']['id']
assert ident not in self.ids, ident
self.ids[ident] = node
if tag not in {'meta', 'link', 'img', 'input', 'br', 'hr'}:
self.stack.append(node)
def handle_endtag(self, tag):
assert self.stack[-1]['tag'] == tag, (tag, self.stack[-1]['tag'])
self.stack.pop()
def handle_data(self, data):
for node in self.stack:
node['text'] += data
def check_layout(html):
doc = Document(html)
ids = doc.ids
def classes(node):
return node['attrs'].get('class', '').split()
def descendants(node):
for child in node['children']:
yield child
yield from descendants(child)
def ancestor(node, cls):
while 'parent' in node:
node = node['parent']
if cls in classes(node):
return node
raise AssertionError(cls)
for domain in ('serial', 'network'):
trigger = ids['quick-' + domain]
assert trigger['tag'] == 'button' and trigger['attrs']['type'] == 'button'
assert trigger['attrs']['aria-controls'] == 'serial-settings'
assert trigger['attrs']['aria-expanded'] == 'false'
assert trigger['attrs']['aria-haspopup'] == 'dialog'
assert trigger['attrs']['aria-label'] and 'disabled' in trigger['attrs']
assert ids['quick-full']['tag'] == 'a' and ids['quick-full']['attrs']['href'] == '#serial-settings'
assert 'hidden' in ids['quick-header']['attrs']
assert ids['network-password']['parent'] is ids['network-password-label']
assert ids['network-password-mode']['parent'] is ids['network-password-mode-label']
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values', 'broker-values'):
assert ids[ident]['tag'] == 'dl'
assert 'settings-values' in classes(ids[ident])
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings', 'broker-settings'):
nodes = list(descendants(ids[ident]))
assert not any(n['tag'] == 'pre' for n in nodes)
assert all('connection-detail' in classes(n) for n in nodes if n['tag'] == 'p')
for n in nodes:
if n['tag'] in ('input', 'select', 'textarea'):
assert n['parent']['tag'] == 'label'
try:
ancestor(n, 'settings-edit')
except AssertionError:
ancestor(n, 'serial-edit')
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh', 'display-refresh', 'broker-refresh'):
assert ids[ident]['text'] == 'Refresh'
for ident in ('serial-result', 'account-result', 'network-result', 'display-result', 'broker-result'):
assert ids[ident]['text'] == 'Check Operation Result'
for ident in ('network-boot', 'network-enabled', 'account-password-saved'):
assert 'settings-check' in classes(ids[ident]['parent'])
for ident in ('account-public-key', 'account-generated'):
assert 'settings-wide' in classes(ids[ident]['parent'])
assert 'readonly' in ids['account-generated']['attrs']
assert 'hidden' in ids['account-generated-panel']['attrs']
assert ids['network-apply']['parent'] is ids['network-wifi-save']['parent']
assert ids['network-wifi-save']['parent'] is ids['network-wifi-load']['parent']
assert ids['network-start']['parent'] is not ids['network-wifi-save']['parent']
assert ids['network-start']['parent'] is ids['network-next-profile']['parent']
assert html.index('id="account-delete"') < html.index('id="account-result"') < html.index('id="account-key-add"') < html.index('id="account-submit-password"')
css = next(n['text'] for n in descendants(doc.root) if n['tag'] == 'style')
for rule in (
'.settings-values dd{margin:0;white-space:pre-wrap;overflow-wrap:anywhere}',
'.settings-values{display:grid;grid-template-columns:minmax(110px,1fr) minmax(0,2fr);gap:8px 16px;max-width:600px}',
'.serial-edit,.settings-edit{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;max-width:600px}',
'.settings-edit .settings-wide{grid-column:1/-1}',
'.settings-edit input[type=checkbox]{width:auto;flex:none}',
'.settings-edit textarea{min-height:96px;resize:vertical}',
'.settings-edit input[readonly]{font-family:monospace}',
'.settings-page h3{font-size:1.17em;margin:1em 0}',
'.settings-page .connection-detail{max-width:600px;overflow-wrap:anywhere}',
'@media(max-width:360px)',
'.settings-edit{grid-template-columns:minmax(0,1fr)}',
):
assert rule in css, rule
assert '.settings-edit textarea{font:inherit;width:100%;min-width:0;' in css
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all five settings views')
def check_browser_layout(html, tmp, executable):
"""Optional real CSS layout check; fixture data, no application/network execution."""
import json
import re
import subprocess
# Keep production HTML/CSS, but do not run its authenticated loader or assets.
fixture = re.sub(r'<script\b[^>]*>.*?</script>', '', html, flags=re.S)
fixture = re.sub(r'<link\b[^>]*>|<img\b[^>]*>', '', fixture)
probe = r'''
const cases = [];
for (const width of [320, 600, 1200]) for (const view of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) for (const quick of [false, true]) {
if (quick && !['serial-settings-content', 'network-settings'].includes(view)) continue;
const height = quick ? 360 : 900;
const frame = document.createElement('iframe'); frame.style.width = width + 'px'; frame.style.height = height + 'px';
cases.push(new Promise(resolve => {
frame.onload = () => {
const d = frame.contentDocument, win = frame.contentWindow;
d.getElementById('serial-settings').hidden = false;
for (const id of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) d.getElementById(id).hidden = id !== view;
const section = d.getElementById(view);
section.querySelectorAll('[hidden]').forEach(n => n.hidden = false);
section.querySelectorAll('dl').forEach(dl => {
dl.textContent = '';
for (let i = 0; i < 8; i++) {
const dt = d.createElement('dt'), dd = d.createElement('dd');
dt.textContent = 'Password configured'; dd.textContent = 'SHA256:' + 'x'.repeat(96);
dl.append(dt, dd);
}
});
section.querySelectorAll('input:not([type=checkbox]),textarea').forEach(n => n.value = 'x'.repeat(96));
const errors = [];
if (quick) {
const host = d.getElementById('serial-settings'); host.dataset.quick = 'true';
d.getElementById('quick-header').hidden = false;
const rect = host.getBoundingClientRect();
if (rect.left < 0 || rect.top < 0 || rect.right > width || rect.bottom > height) errors.push('popover viewport overflow');
if (host.scrollWidth > host.clientWidth + 1) errors.push('popover horizontal overflow');
if (win.getComputedStyle(host).overflowY !== 'auto') errors.push('popover not scrollable');
for (const id of ['settings-navigation', 'network-password-mode-label', 'network-password-label', 'network-password-status']) {
if (d.getElementById(id).getBoundingClientRect().width) errors.push('quick hidden content:' + id);
}
}
for (const node of section.querySelectorAll('dl,input,select,textarea,.settings-edit,.serial-edit,.serial-actions')) {
const rect = node.getBoundingClientRect();
if (!rect.width) continue;
if (rect.left < 0 || rect.right > width + 1) errors.push('overflow:' + (node.id || node.className));
if (node.matches('dl') && node.scrollWidth > node.clientWidth + 1) errors.push('summary overflow');
if (node.matches('input[type=checkbox]') && rect.width > 30) errors.push('checkbox width');
if (node.matches('dl,.settings-edit,.serial-edit') && rect.width > 601) errors.push('max width');
}
// Measure glyph ranges, not the full-width grid cell or textContent.
// The normal-whitespace control proves the pair detects collapsing.
for (const dd of quick ? [] : section.querySelectorAll('dl dd:first-of-type')) {
const original = dd.textContent;
const measureSSID = spaces => {
dd.textContent = 'SSID: ' + JSON.stringify('office' + ' '.repeat(spaces) + 'wifi');
const range = d.createRange();
range.setStart(dd.firstChild, 7); range.setEnd(dd.firstChild, dd.textContent.length - 1);
if (range.getClientRects().length !== 1) errors.push('SSID probe unexpectedly wrapped');
return range.getBoundingClientRect().width;
};
const single = measureSSID(1), double = measureSSID(2);
if (!(single > 0 && double > single + 1)) errors.push('consecutive SSID spaces collapsed');
dd.style.whiteSpace = 'normal';
if (Math.abs(measureSSID(2) - measureSSID(1)) > 0.1) errors.push('invalid whitespace control');
dd.style.removeProperty('white-space'); dd.textContent = original;
}
resolve({width, height, view, quick, errors});
};
}));
frame.srcdoc = FIXTURE; document.body.append(frame);
}
Promise.all(cases).then(results => {
const out = document.createElement('pre'); out.id = 'layout-results'; out.textContent = JSON.stringify(results); document.body.append(out);
});
'''.replace('FIXTURE', json.dumps(fixture))
page = tmp / 'layout-probe.html'
page.write_text('<!doctype html><html><body><script>' + probe + '</script></body></html>')
result = subprocess.run([executable, '--headless', '--no-sandbox', '--disable-gpu',
'--no-first-run', '--disable-background-networking',
'--user-data-dir=' + str(tmp / 'chromium-profile'),
'--virtual-time-budget=3000', '--dump-dom', page.as_uri()],
capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
parsed = Document(result.stdout)
results = json.loads(parsed.ids['layout-results']['text'])
assert len(results) == 18
assert all(not case['errors'] for case in results), results
print('PASS Chromium layout: four full views plus Serial/Wi-Fi popovers at 320/600/1200px (quick height360); viewport/overflow/secret exclusion and full-page whitespace checks (fixtures, not live app)')