Unify Settings Layouts and Add Coverage
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
"""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 ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary'):
|
||||
assert ids[ident]['tag'] == 'dl'
|
||||
assert 'settings-values' in classes(ids[ident])
|
||||
for ident in ('serial-settings-content', 'account-settings', 'network-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'):
|
||||
assert ids[ident]['text'] == 'Refresh'
|
||||
for ident in ('serial-result', 'account-result', 'network-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 three 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']) {
|
||||
const frame = document.createElement('iframe'); frame.style.width = width + 'px'; frame.style.height = '900px';
|
||||
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']) 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 = [];
|
||||
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 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, view, 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) == 9
|
||||
assert all(not case['errors'] for case in results), results
|
||||
print('PASS Chromium layout: all three settings views at 320/600/1200px; bounded controls, summaries, inline checkboxes and rendered consecutive-space distinction (fixture data, not live app)')
|
||||
Reference in New Issue
Block a user