test(web): extend three-terminal gate and add breakpoint guardrail

This commit is contained in:
pnoker
2026-08-25 21:02:02 +08:00
parent a1d9e2a86b
commit 0727708f25
2 changed files with 108 additions and 2 deletions
+46 -2
View File
@@ -47,6 +47,29 @@ test.describe('three-terminal gate', () => {
await expectNoHorizontalOverflow(page, 'settings');
});
// L4 template sweep: one representative route per page template family
// (monitor / list / detail / history), gated on every terminal viewport.
const TEMPLATE_ROUTES: Array<{template: string; hash: string}> = [
{template: 'monitor', hash: '/#/settings/alarm/overview'},
{template: 'list', hash: '/#/device'},
{template: 'list', hash: '/#/driver'},
{template: 'list', hash: '/#/profile'},
{template: 'list', hash: '/#/settings/label'},
{template: 'detail', hash: '/#/point_value'},
{template: 'detail', hash: '/#/settings/alarm/point'},
{template: 'history', hash: '/#/settings/event/history'},
{template: 'history', hash: '/#/settings/command/history'}
];
for (const {template, hash} of TEMPLATE_ROUTES) {
test(`template ${template} ${hash} keeps zero page-level overflow`, async ({page}) => {
await login(page);
await page.goto(hash, {waitUntil: 'domcontentloaded'});
await waitForAppSettled(page);
await expectNoHorizontalOverflow(page, `${template} ${hash}`);
});
}
test('shell adapts to the viewport (menu strip vs drawer, aside vs drawer)', async ({page}) => {
await login(page);
const mobile = isMobileViewport(page);
@@ -80,8 +103,7 @@ test.describe('three-terminal gate', () => {
}
});
test('a11y smoke: visible interactive controls expose accessible names', async ({page}) => {
await login(page);
test('a11y smoke: visible interactive controls expose accessible names', async ({page}) => { await login(page);
await page.goto('/#/settings/user', {waitUntil: 'domcontentloaded'});
await waitForAppSettled(page);
@@ -100,4 +122,26 @@ test.describe('three-terminal gate', () => {
expect(unlabeled, 'visible controls without an accessible name').toEqual([]);
});
// A3 acceptance: dialogs go (near) full-screen below 768px, so a fixed
// 640px dialog width never overflows a mobile viewport. Gated on mobile
// only — desktop/tablet keep the centered width contract.
test('dialogs fit the mobile viewport below 768px', async ({page}) => {
test.skip(!isMobileViewport(page), 'mobile-only dialog shape contract');
await login(page);
await page.goto('/#/settings/label', {waitUntil: 'domcontentloaded'});
await waitForAppSettled(page);
await page.getByRole('button', {name: /Add|新增/}).first().click();
const dialog = page.locator('.el-dialog:visible').last();
await expect(dialog).toBeVisible();
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
);
expect(overflow, 'open dialog causes page-level horizontal overflow').toBeLessThanOrEqual(0);
const dialogWidth = await dialog.boundingBox();
const viewportWidth = page.viewportSize()?.width ?? 393;
expect(dialogWidth?.width, 'dialog wider than viewport').toBeLessThanOrEqual(viewportWidth);
});
});
@@ -0,0 +1,62 @@
/*
* Copyright 2016-present the IoT DC3 original author or authors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Axiom A5 guardrail: every @media width breakpoint must reference the
// $breakpoint-* token from src/styles/tokens.scss — the single breakpoint
// contract of docs/design/frontend-three-terminal-ux.md. Hand-rolled px
// thresholds reintroduce desktop-first drift one patch at a time.
import {readdirSync, readFileSync, statSync} from 'node:fs';
import {join, relative} from 'node:path';
import {describe, expect, it} from 'vitest';
const root = process.cwd();
const srcDir = join(root, 'src');
// Media features that are width thresholds (allowed: prefers-*,
// prefers-reduced-motion, print, and any non-width query).
const WIDTH_MEDIA = /@media[^{]*\((?:max|min)-width\s*:\s*([^)]+)\)/g;
function walk(dir: string): string[] {
return readdirSync(dir).flatMap((entry) => {
const path = join(dir, entry);
if (statSync(path).isDirectory()) return walk(path);
return /\.(vue|scss)$/.test(path) ? [path] : [];
});
}
function findViolations(): string[] {
const violations: string[] = [];
for (const path of walk(srcDir)) {
const content = readFileSync(path, 'utf8');
for (const match of content.matchAll(WIDTH_MEDIA)) {
const value = match[1].trim();
if (!value.startsWith('$breakpoint-')) {
const line = content.slice(0, match.index).split('\n').length;
violations.push(`${relative(root, path)}:${line} — @media width "${value}" is not a $breakpoint-* token`);
}
}
}
return violations;
}
describe('three-terminal breakpoint contract (A5)', () => {
it('all @media width breakpoints use $breakpoint-* tokens', () => {
const violations = findViolations();
expect(violations, violations.join('\n')).toEqual([]);
});
});