test/dashboard-p1-fixes.test.js
Rama master · réplica de lectura del repositorio original.
// Dashboard P1 fixes — colour pairs, destructive confirm safety, response-gated
// mutations, truthful settings writes, overview lifecycle, and mobile layout.
//
// The colour/typography assertions read the shipped HTML; the behaviour tests
// execute the shipped App methods (brace-sliced) with small DOM/API stubs, the
// same convention dashboard-ui-p2.test.js uses.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
readStyleBlock, customProperties, resolveVar, parseColor, contrastRatio,
} from '../scripts/dashboard-css.mjs';
import {
undefinedCustomProperties, TEXT_FLOOR,
} from '../scripts/check-dashboard-contrast.mjs';
const HTML = readFileSync(new URL('../src/dashboard/index.html', import.meta.url), 'utf8');
const CSS = readStyleBlock(HTML);
const ZH = JSON.parse(readFileSync(new URL('../src/dashboard/i18n/zh-CN.json', import.meta.url), 'utf8'));
const EN = JSON.parse(readFileSync(new URL('../src/dashboard/i18n/en.json', import.meta.url), 'utf8'));
function methodParts(name) {
let at = HTML.indexOf(`\n ${name}(`);
if (at === -1) at = HTML.indexOf(`\n async ${name}(`);
assert.notEqual(at, -1, `${name} must exist as a method in the dashboard App object`);
const nameAt = HTML.indexOf(name, at);
const parenOpen = HTML.indexOf('(', nameAt);
let depth = 0;
let parenClose = -1;
for (let i = parenOpen; i < HTML.length; i++) {
if (HTML[i] === '(') depth++;
else if (HTML[i] === ')' && --depth === 0) { parenClose = i; break; }
}
assert.notEqual(parenClose, -1, `could not delimit ${name} parameters`);
const params = HTML.slice(parenOpen, parenClose + 1);
const isAsync = /^async\s/.test(HTML.slice(at + 3, parenOpen));
const open = HTML.indexOf('{', parenClose);
let braces = 0;
let end = -1;
for (let i = open; i < HTML.length; i++) {
if (HTML[i] === '{') braces++;
else if (HTML[i] === '}' && --braces === 0) { end = i + 1; break; }
}
assert.notEqual(end, -1, `could not delimit ${name} body`);
return { params, body: HTML.slice(open, end), isAsync };
}
function method(name) {
const { params, body, isAsync } = methodParts(name);
// eslint-disable-next-line no-new-func -- executing the shipped source is the point
const fn = new Function('I18n', '...args', `return (${isAsync ? 'async ' : ''}function ${name}${params} ${body}).apply(this, args);`);
return function (...args) { return fn.call(this, I18n, ...args); };
}
const I18n = { locale: 'zh-CN', t: (key, vars) => `i18n:${key}${vars ? ':' + JSON.stringify(vars) : ''}` };
function themes() {
const light = customProperties(CSS, ':root');
const dark = { ...light, ...customProperties(CSS, '[data-theme="dark"]') };
return { light, dark };
}
function color(vars, name) {
const parsed = parseColor(resolveVar(vars[name], vars));
assert.ok(parsed, `${name} must resolve to a colour`);
return parsed;
}
describe('P1 colour: measured hover states and on-fill labels', () => {
const { light, dark } = themes();
it('defines every status on-* label and explicit hover fill', () => {
for (const [theme, vars] of Object.entries({ light, dark })) {
for (const token of ['--on-success', '--on-warn', '--on-error', '--on-info']) {
assert.ok(vars[token], `${theme}: ${token} is defined`);
}
for (const token of ['--success-hover', '--warn-hover', '--error-hover', '--info-hover']) {
assert.ok(vars[token], `${theme}: ${token} is defined`);
}
}
});
it('keeps every on-* label above AA on both the base and hover fill', () => {
const pairs = [
['--on-success', '--success', '--success-hover'],
['--on-warn', '--warn', '--warn-hover'],
['--on-error', '--error', '--error-hover'],
['--on-info', '--info', '--info-hover'],
];
for (const [theme, vars] of Object.entries({ light, dark })) {
for (const [label, fill, hover] of pairs) {
for (const bg of [fill, hover]) {
const ratio = contrastRatio(color(vars, label), color(vars, bg));
assert.ok(ratio >= TEXT_FLOOR, `${theme}: ${label} on ${bg} is ${ratio}:1`);
}
}
}
});
it('removed the brightness() hover filters from danger/success buttons', () => {
assert.doesNotMatch(HTML, /\.btn-danger:hover[^{]*\{[^}]*filter:\s*brightness/);
assert.doesNotMatch(HTML, /\.btn-success:hover[^{]*\{[^}]*filter:\s*brightness/);
assert.match(CSS, /\.btn-danger:hover:not\(:disabled\)\s*\{[^}]*background:\s*var\(--error-hover\)/s);
assert.match(CSS, /\.btn-success:hover:not\(:disabled\)\s*\{[^}]*background:\s*var\(--success-hover\)/s);
});
it('has no unresolved custom properties', () => {
assert.deepEqual(undefinedCustomProperties(HTML), []);
assert.doesNotMatch(HTML, /var\(--muted\b/);
assert.doesNotMatch(HTML, /var\(--info-bg\b/);
});
});
describe('P1 typography and badge matrix', () => {
it('keeps literal font sizes at or above 12px', () => {
const small = [...HTML.matchAll(/font-size:\s*(8|9|10(?:\.5)?|11(?:\.5)?)px/g)].map((m) => m[0]);
assert.deepEqual(small, []);
});
it('does not route meaningful text through the 9/10/11px tokens', () => {
assert.doesNotMatch(HTML, /var\(--fs-(?:3xs|2xs|xs)\)/);
});
it('maps the legacy badge-success markup to the real success variant', () => {
assert.match(CSS, /\.badge\.active,\s*\.badge\.running,\s*\.badge\.success,\s*\.badge-success\s*\{/);
const badge = CSS.match(/\.badge\s*\{([^}]*)\}/s);
assert.ok(badge, '.badge base exists');
assert.match(badge[1], /font-size:\s*12px/);
});
it('gives the tier/chip/capability pills the same readable geometry', () => {
for (const selector of ['.tier', '.chip-badge', '.cap-item']) {
const re = new RegExp(`\\${selector}\\s*\\{([^}]*)\\}`, 's');
const m = CSS.match(re);
assert.ok(m, `${selector} exists`);
assert.match(m[1], /font-size:\s*12px/, `${selector} is 12px`);
}
});
it('uses solid accent focus outlines instead of wash-only rings', () => {
assert.match(CSS, /\.sidebar nav a:focus-visible,\s*\n?\.seg-btn:focus-visible,\s*\n?\[role="button"\]:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--accent-focus\)/s);
});
});
describe('P1 #1 destructive confirmation defaults', () => {
it('never approves a danger dialog on a bare Enter', () => {
const src = methodParts('confirm').body;
assert.match(src, /const isDanger = !!opts\.danger/);
assert.match(src, /e\.key === 'Enter' && !isDanger/);
assert.doesNotMatch(src, /e\.key === 'Enter' && e\.target\.tagName !== 'BUTTON'\) \{ e\.preventDefault\(\); close\(true\); \}/);
});
it('focuses Cancel first for danger and supports a typed confirmation', () => {
const src = methodParts('confirm').body;
assert.match(src, /const target = isDanger[\s\S]{0,120}\[data-act=cancel\]/);
assert.match(src, /opts\.requireText/);
assert.match(src, /okBtn\.disabled = !confirmMatches\(\)/);
});
it('requires the exact count when bulk delete exceeds the visible page', () => {
const src = methodParts('batchAccountOp').body;
assert.match(src, /const visible = Number\(this\.accountsPageSize\) \|\| 50/);
assert.match(src, /requireText: requireCount \|\| undefined/);
});
});
describe('P1 #3 delete is response-gated', () => {
function self(overrides = {}) {
return {
_accountDetails: new Map([['a1', {}]]),
_cardsByAccount: new Map([['a1', {}]]),
_requestsByAccount: new Map([['a1', []]]),
_selectedAccounts: new Set(['a1']),
_batchResults: new Map([['a1', {}]]),
_drawerAccountId: 'a1',
toasts: [],
confirm: async () => true,
translateError: (_e, fallback) => `i18n:${fallback}`,
_showAccountActionError(message, retry) { this.inlineError = { message, retry }; },
loadAccounts() { this.loaded = true; },
closeAccountDrawer() { this.closed = true; },
toast(message, type = 'success') { this.toasts.push({ message, type }); },
...overrides,
};
}
it('keeps the row, drawer and selection when DELETE returns success:false', async () => {
const s = self({ api: async () => ({ success: false, error: 'denied' }) });
await method('deleteAccount').call(s, 'a1');
assert.ok(s._accountDetails.has('a1'), 'detail cache retained');
assert.ok(s._cardsByAccount.has('a1'), 'card cache retained');
assert.ok(s._selectedAccounts.has('a1'), 'selection retained');
assert.equal(s.loaded, undefined, 'list not reloaded');
assert.equal(s.closed, undefined, 'drawer not closed');
assert.ok(s.inlineError, 'inline error exposed');
assert.equal(s.toasts.filter((t) => t.type === 'success').length, 0, 'no false success toast');
});
it('clears local state only after a confirmed success envelope', async () => {
const s = self({ api: async () => ({ success: true }) });
await method('deleteAccount').call(s, 'a1');
assert.equal(s._accountDetails.has('a1'), false);
assert.equal(s._cardsByAccount.has('a1'), false);
assert.equal(s._selectedAccounts.has('a1'), false);
assert.equal(s.loaded, true);
assert.equal(s.closed, true);
assert.equal(s.toasts.filter((t) => t.type === 'success').length, 1);
});
});
describe('P1 #8 settings writes are truthful and serialized', () => {
it('rolls a preference checkbox back and never says saved on success:false', async () => {
const checkbox = { type: 'checkbox', checked: true, disabled: false, parentNode: null, setAttribute() {}, removeAttribute() {}, getAttribute: () => "App.savePref('oauthSkipChooser', this.checked)" };
const oldDocument = globalThis.document;
globalThis.document = {
querySelectorAll: () => [checkbox],
querySelector: () => null,
createElement: () => ({ dataset: {}, className: '', textContent: '', appendChild() {}, querySelector: () => null }),
};
try {
const s = {
_prefs: {}, _pendingWrites: new Map(), toasts: [],
api: async () => ({ success: false, error: 'nope' }),
translateError: () => 'i18n:error.saveFailed',
toast(message, type = 'success') { this.toasts.push({ message, type }); },
};
s._beginInlineSave = method('_beginInlineSave');
s._endInlineSave = method('_endInlineSave');
await method('savePref').call(s, 'oauthSkipChooser', true);
assert.equal(checkbox.checked, false, 'previous checkbox state restored');
assert.equal(checkbox.disabled, false, 'control re-enabled');
assert.equal(s.toasts.filter((t) => t.type === 'success').length, 0);
} finally {
globalThis.document = oldDocument;
}
});
it('_putBackendSwitch throws on success:false instead of toasting success', async () => {
const s = {
_pendingWrites: new Map(), toasts: [],
api: async () => ({ success: false, error: 'denied' }),
translateError: () => 'i18n:error.saveFailed',
toast(message, type = 'success') { this.toasts.push({ message, type }); },
};
await assert.rejects(() => method('_putBackendSwitch').call(s, { devinConnect: true }));
assert.equal(s.toasts.filter((t) => t.type === 'success').length, 0);
});
it('serializes a second write for the same key while the first is pending', async () => {
let release;
const gate = new Promise((resolve) => { release = resolve; });
let calls = 0;
const s = {
_pendingWrites: new Map(), toasts: [],
api: async () => { calls++; await gate; return { success: true }; },
translateError: () => 'x',
toast() {},
};
const p1 = method('_putBackendSwitch').call(s, { devinConnect: true });
const p2 = method('_putBackendSwitch').call(s, { devinConnect: false });
release();
await Promise.all([p1, p2]);
assert.equal(calls, 1, 'only one in-flight write per key');
});
});
describe('P1 #2 overview lifecycle', () => {
it('has an inline error and a persistent stale banner, not an endless skeleton', () => {
assert.match(HTML, /id="overview-stale-banner"[^>]*role="status"/);
assert.match(methodParts('_renderOverviewError').body, /overview\.errorTitle/);
assert.match(methodParts('_renderOverviewError').body, /action\.retry/);
const load = methodParts('loadOverview').body;
assert.match(load, /if \(!d \|\| d\.success === false\)/);
assert.match(load, /this\._showOverviewStale\(reason\)/);
assert.match(load, /this\._renderOverviewError\(reason\)/);
assert.match(load, /this\._overviewPollDelay\(\)/);
});
it('backs off 15s → 30s → 60s and resets on success', () => {
const fn = method('_overviewPollDelay');
assert.equal(fn.call({ _overviewFailures: 1 }), 15000);
assert.equal(fn.call({ _overviewFailures: 2 }), 30000);
assert.equal(fn.call({ _overviewFailures: 3 }), 60000);
assert.equal(fn.call({ _overviewFailures: 9 }), 60000);
});
});
describe('P1 #5/#9 mobile navigation and table access', () => {
it('adds a mobile app bar and a labelled off-canvas drawer', () => {
assert.match(HTML, /class="mobile-appbar"/);
assert.match(HTML, /id="mobile-menu-btn"[\s\S]{0,160}aria-controls="app-sidebar"/);
assert.match(CSS, /@media \(max-width: 720px\)\s*\{[\s\S]*?\.mobile-appbar\s*\{[^}]*display:\s*flex/s);
assert.match(CSS, /\.sidebar\.open\s*\{\s*transform:\s*none;\s*\}/);
assert.match(CSS, /#settings-search\s*\{\s*display:\s*none;\s*\}/);
assert.ok(ZH.aria.openNav && EN.aria.openNav);
});
it('keeps mobile row/menu and pagination targets at least 44px', () => {
assert.match(CSS, /\.row-menu-btn,\s*\.table-pagination \.btn,\s*\.btn-xs\s*\{\s*min-height:\s*44px;\s*min-width:\s*44px;\s*\}/);
});
it('scopes overflow-x:visible to the accounts card transformation only', () => {
assert.match(CSS, /\.accounts-table-wrap\s*\{\s*overflow-x:\s*visible;\s*\}/);
assert.doesNotMatch(CSS, /\.table-wrap\s*\{\s*overflow-x:\s*visible;\s*\}/);
assert.match(CSS, /\.table-wrap:not\(\.accounts-table-wrap\)\s*\{[^}]*overflow-x:\s*auto/);
assert.match(HTML, /class="table-wrap accounts-table-wrap"/);
});
it('makes non-account table regions keyboard-scrollable', () => {
const wraps = [...HTML.matchAll(/<div class="table-wrap"([^>]*)>/g)];
assert.ok(wraps.length > 0);
for (const m of wraps) {
assert.match(m[1], /tabindex="0"/);
assert.match(m[1], /role="region"/);
assert.match(m[1], /data-i18n-aria-label="aria\.scrollableTable"/);
}
});
});