test/dashboard-ui-polish.test.js
Rama master · réplica de lectura del repositorio original.
// UI polish (UISpec P0-4, P1-5..P1-9).
//
// Executes the SHIPPED App methods (brace-sliced out of index.html) with a tiny
// DOM stub, the same technique the reauth/selection suites use, so these tests
// pin the real template strings rather than a mirror implementation.
//
// P0-4 one action, one verb: the entry button names the tab it opens, the
// modal title follows the tab, probe/refresh/term vocabulary is singular
// P1-5 the status filter is three human groups + ten explained details
// P1-6 the drawer shows four answers first, six technical cards collapsed
// P1-7 each row shows its last check; fleet results list every account
// P1-8 usage answers "what is left" first; unmapped reasons stay human
// P1-9 the login tab is a method chooser, advanced options collapsed
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { lintDashboardCopy, TERMS, RETIRED } from '../scripts/check-dashboard-copy.mjs';
const HTML = readFileSync(new URL('../src/dashboard/index.html', import.meta.url), 'utf8');
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 flatten(obj, prefix = '', out = {}) {
for (const [key, value] of Object.entries(obj)) {
const full = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) flatten(value, full, out);
else out[full] = value;
}
return out;
}
const zh = flatten(ZH);
const en = flatten(EN);
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 parenDepth = 0;
let parenClose = -1;
for (let i = parenOpen; i < HTML.length; i++) {
if (HTML[i] === '(') parenDepth++;
else if (HTML[i] === ')' && --parenDepth === 0) { parenClose = i; break; }
}
assert.notEqual(parenClose, -1, `could not delimit the parameter list of ${name}`);
const params = HTML.slice(parenOpen, parenClose + 1);
const decl = HTML.slice(at + 3, parenOpen);
const isAsync = /^async\s/.test(decl);
const open = HTML.indexOf('{', parenClose);
let depth = 0;
let end = -1;
for (let i = open; i < HTML.length; i++) {
if (HTML[i] === '{') depth++;
else if (HTML[i] === '}' && --depth === 0) { end = i + 1; break; }
}
assert.notEqual(end, -1, `could not delimit the body of ${name}`);
return { params, body: HTML.slice(open, end), isAsync };
}
const I18n = {
locale: 'zh-CN',
t(key, vars = {}) {
const suffix = Object.keys(vars).length
? '|' + Object.entries(vars).map(([k, v]) => `${k}=${v}`).join(',')
: '';
return `i18n:${key}${suffix}`;
},
};
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 elements = new Map();
function element(id) {
if (elements.has(id)) return elements.get(id);
const classes = new Set();
const el = {
id, textContent: '', innerHTML: '', value: '', hidden: false, disabled: false,
dataset: {}, title: '', open: false,
classList: {
add: (c) => classes.add(c),
remove: (c) => classes.delete(c),
contains: (c) => classes.has(c),
toggle: (c, on) => {
if (on === undefined) { if (classes.has(c)) classes.delete(c); else classes.add(c); }
else if (on) classes.add(c); else classes.delete(c);
},
},
setAttribute(k, v) { this[k] = v; },
removeAttribute(k) { delete this[k]; },
focus() {}, appendChild() {}, querySelector: () => null, querySelectorAll: () => [],
};
elements.set(id, el);
return el;
}
globalThis.document = {
getElementById: (id) => element(id),
querySelector: () => null,
querySelectorAll: () => [],
createElement: (tag) => element(`<${tag}>`),
addEventListener() {}, removeEventListener() {},
body: { appendChild() {} },
};
globalThis.sessionStorage = {
_v: new Map(),
getItem(k) { return this._v.has(k) ? this._v.get(k) : null; },
setItem(k, v) { this._v.set(k, String(v)); },
};
const esc = (v) => String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const escJsAttr = (v) => String(v ?? '').replace(/[\\'"<>&\r\n]/g, (c) => ({ '\\': '\\\\', "'": "\\'", '"': '\\"', '<': '\\x3c', '>': '\\x3e', '&': '\\x26', '\r': '\\r', '\n': '\\n' }[c]));
function baseSelf(overrides = {}) {
return {
esc, escJsAttr,
fmtAgoShort: () => '3m',
skipReasonText: (code) => (code === 'ls_not_idle_resident' ? '语言服务没在运行,先启动它再检查' : ''),
translateError: (code) => `i18n:error.genericAction|code=${code}`,
poolBalanceText: () => '$0.00',
clearAccountSelection() {},
loadAccounts() {},
...overrides,
};
}
function methodWithI18n(name, i18n) {
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); };
}
// ─── P0-4: one action, one verb ───────────────────────────────────────────
describe('entry verbs name the tab they open (AC-VERB-1)', () => {
it('the header buttons and the tab labels are the same words', () => {
const buttons = [
['accounts-login-btn', 'login', 'accounts.add.login'],
['accounts-import-btn', 'import', 'accounts.add.import'],
['accounts-add-btn', 'paste', 'accounts.add.paste'],
];
for (const [id, tab, key] of buttons) {
const tag = (HTML.match(new RegExp(`<button[^>]*id="${id}"[^>]*>`)) || [])[0];
assert.ok(tag, `${id} exists`);
assert.match(tag, new RegExp(`openAddAccountModal\\('${tab}'\\)`), `${id} opens the ${tab} tab`);
assert.match(tag, new RegExp(`data-i18n="${key.replace(/\./g, '\\.')}"`));
}
// The old label said 添加 while the tab said 粘贴凭证.
assert.equal(zh['accounts.add.paste'], zh['accounts.add.tab.paste']);
assert.equal(en['accounts.add.paste'], en['accounts.add.tab.paste']);
assert.notEqual(zh['accounts.add.paste'], '添加');
});
it('every tab has a title of its own and reauth names the account', () => {
for (const tab of ['login', 'paste', 'import', 'local']) {
assert.ok(zh[`accounts.add.modalTitle.${tab}`], `zh title for ${tab}`);
assert.ok(en[`accounts.add.modalTitle.${tab}`], `en title for ${tab}`);
}
assert.match(zh['accounts.add.modalTitle.reauth'], /\{\{email\}\}/);
assert.match(en['accounts.add.modalTitle.reauth'], /\{\{email\}\}/);
assert.match(methodParts('switchAddTab').body, /this\.setAddModalTitle\(\)/);
});
it('setAddModalTitle follows the active tab and the reauth binding', () => {
const title = element('add-modal-title');
const self = baseSelf({ _addTab: 'paste', _reauthAccountId: null });
const setTitle = method('setAddModalTitle');
setTitle.call(self);
assert.equal(title.textContent, 'i18n:accounts.add.modalTitle.paste');
self._addTab = 'import';
setTitle.call(self);
assert.equal(title.textContent, 'i18n:accounts.add.modalTitle.import');
self._reauthAccountId = 'acct-1';
self._accountsById = new Map([['acct-1', { email: 'dead@example.test' }]]);
setTitle.call(self);
assert.match(title.textContent, /accounts\.add\.modalTitle\.reauth\|email=dead@example\.test/);
});
it('probe and balance actions use one verb each', () => {
const pairs = [
['batchOps.probe', 'batchOps.refresh'],
['accounts.toolbar.probeAll', 'accounts.toolbar.refreshAll'],
['accounts.row.probe', 'accounts.row.refreshBalance'],
['action.probe', null],
];
for (const [probeKey, refreshKey] of pairs) {
assert.ok(zh[probeKey], `zh has ${probeKey}`);
assert.ok(!/探测/.test(zh[probeKey]), `${probeKey} must not say 探测`);
if (refreshKey) {
assert.ok(zh[refreshKey].includes('余额'), `${refreshKey} must say 余额`);
assert.ok(!/额度/.test(zh[refreshKey]), `${refreshKey} must not say 额度`);
}
}
for (const term of TERMS) {
const table = term.locale === 'zh-CN' ? zh : en;
assert.match(table[term.key], term.must, `${term.locale} ${term.key}: ${term.why}`);
}
for (const retired of RETIRED) {
const table = retired.locale === 'zh-CN' ? zh : en;
for (const [key, value] of Object.entries(table)) {
if (typeof value !== 'string' || !/^(accounts|batchOps|usage|account\.detail|table\.header)\./.test(key)) continue;
if (/tech|tooltip\.help/i.test(key)) continue;
assert.ok(!retired.re.test(value), `${retired.locale} ${key} still uses ${retired.label}`);
}
}
const { ok, problems } = lintDashboardCopy();
assert.deepEqual(problems, []);
assert.equal(ok, true);
});
});
// ─── P1-5: status vocabulary ───────────────────────────────────────────────
describe('status filter is human groups plus explained details (AC-STATUS-1..3)', () => {
const selectBlock = HTML.slice(
HTML.indexOf('<select id="accounts-status-filter"'),
HTML.indexOf('</select>', HTML.indexOf('<select id="accounts-status-filter"')) + '</select>'.length,
);
const templateBlock = HTML.slice(
HTML.indexOf('<template id="accounts-status-details">'),
HTML.indexOf('</template>', HTML.indexOf('<template id="accounts-status-details">')),
);
it('offers exactly four choices by default', () => {
const values = [...selectBlock.matchAll(/<option value="([^"]*)"/g)].map((m) => m[1]);
assert.deepEqual(values, ['', 'active', 'disabled', 'attention']);
assert.ok(!HTML.includes('id="accounts-flagged-toggle"'), 'the duplicate 仅异常 chip is gone');
});
it('keeps the ten raw states behind 更多状态, each with an explanation', () => {
const options = [...templateBlock.matchAll(/<option value="([^"]+)"[^>]*>/g)];
assert.equal(options.length, 10, 'all ten states are reachable');
for (const [tag, state] of options) {
assert.match(tag, /data-status-detail/, `${state} is tagged for injection/removal`);
assert.match(tag, new RegExp(`data-i18n-title="accounts\\.statusTip\\.${state}"`), `${state} has a tip key`);
const title = (tag.match(/\btitle="([^"]+)"/) || [])[1];
assert.ok(title && title.trim().length > 0, `${state} has a non-empty title`);
}
for (const state of ['active', 'disabled', 'banned', 'reauth', 'error', 'quota_cooled', 'rate_limited', 'capacity', 'near_limit', 'watch']) {
assert.ok(zh[`accounts.statusTip.${state}`], `zh explains ${state}`);
assert.ok(en[`accounts.statusTip.${state}`], `en explains ${state}`);
}
});
it('需处理 is the KPI predicate, not a server state', () => {
const calls = [];
const self = baseSelf({ accountsStatus: 'banned', accountsFlagged: false, accountsPage: 2, loadAccounts: () => calls.push('load') });
const setStatus = method('setAccountsStatus');
setStatus.call(self, 'attention');
assert.equal(self.accountsFlagged, true);
assert.equal(self.accountsStatus, '');
assert.equal(self.accountsPage, 1);
assert.equal(calls.length, 1);
setStatus.call(self, 'banned');
assert.equal(self.accountsFlagged, false);
assert.equal(self.accountsStatus, 'banned');
});
it('the select shows 需处理 when the flagged predicate is active', () => {
const sel = element('accounts-status-filter');
sel.value = '';
const self = baseSelf({
accountsFlagged: true, accountsStatus: '', accountsTier: '', accountsQuery: '',
accountsPage: 1, accountsPageSize: 50,
});
method('renderAccountsFilterState').call(self, { accounts: [], stats: {}, total: 0 });
assert.equal(sel.value, 'attention');
});
});
// ─── P1-6: drawer hierarchy ────────────────────────────────────────────────
describe('drawer answers four questions before the technical cards (AC-DRAWER-1..2)', () => {
const body = methodParts('renderAccountDetail').body;
it('puts plan → quota → models → requests first', () => {
const order = [
body.indexOf('this.renderPlanCard('),
body.indexOf("T('quota.title')"),
body.indexOf("T('models.title')"),
body.indexOf('this.renderRequestHistoryCard('),
];
assert.ok(order.every((i) => i >= 0), 'all four primary cards are rendered');
for (let i = 1; i < order.length; i++) {
assert.ok(order[i] > order[i - 1], `primary card ${i} must come after ${i - 1}`);
}
});
it('collapses the six technical cards into 高级信息, closed by default', () => {
const detailsAt = body.indexOf('<details class="detail-advanced"');
assert.ok(detailsAt > body.indexOf('this.renderRequestHistoryCard('), 'the disclosure follows the primary cards');
assert.match(body.slice(detailsAt, detailsAt + 220), /id="acct-advanced"\$\{this\._advancedOpen \? ' open' : ''\}/);
const advanced = body.slice(detailsAt);
const order = ['renderSpendPolicyCard', 'renderLedgerCard', 'renderModelStatusCard', 'runtime.title', 'renderKeyCard', 'renderPreflightCard'];
let last = -1;
for (const marker of order) {
const at = advanced.indexOf(marker);
assert.ok(at > last, `${marker} must be inside the advanced block, in order`);
last = at;
}
});
it('remembers the open state for the tab session', () => {
const self = baseSelf({});
const toggle = method('onDrawerAdvancedToggle');
toggle.call(self, true);
assert.equal(self._advancedOpen, true);
assert.equal(globalThis.sessionStorage.getItem('acct_advanced_open'), '1');
toggle.call(self, false);
assert.equal(self._advancedOpen, false);
assert.equal(globalThis.sessionStorage.getItem('acct_advanced_open'), '0');
});
});
// ─── P1-7: probe results readable ──────────────────────────────────────────
describe('last check is visible per row (AC-PROBE-1..2)', () => {
it('renders the server timestamp, not an in-memory one', () => {
const self = baseSelf({});
const html = method('renderLastCheck').call(self, { id: 'acct-1', lastProbed: Date.now() - 180_000 }, null);
assert.match(html, /accounts\.row\.lastCheck\|ago=3m/);
assert.match(html, /class="acct-last-check"/);
});
it('shows a skipped check as a human sentence plus a retry action', () => {
const self = baseSelf({});
const html = method('renderLastCheck').call(self, { id: 'acct-1' }, { skipped: true, error: 'ls_not_idle_resident' });
assert.match(html, /语言服务没在运行/);
assert.doesNotMatch(html, /ls_not_idle_resident/);
assert.match(html, /App\.probeAccount\('acct-1'\)/);
assert.match(html, /accounts\.row\.retryCheck/);
});
it('the row template uses that line and does not add a table row', () => {
const row = methodParts('renderAccountRow').body;
assert.match(row, /\$\{this\.renderLastCheck\(a, batch\)\}/);
assert.equal((row.match(/<tr /g) || []).length, 1, 'still exactly one row element');
});
it('the fleet panel lists every attempt with result, reason and time', () => {
const panel = element('accounts-fleet-panel');
const summary = element('accounts-fleet-summary');
const list = element('accounts-fleet-list');
const exList = element('accounts-fleet-excluded-list');
const exBtn = element('accounts-fleet-excluded');
const reopen = element('accounts-fleet-reopen');
panel.hidden = true;
const self = baseSelf({
_fleetRun: {
op: 'probe', state: 'done', total: 3, done: 3, ok: 1, fail: 1, skipped: 1,
excluded: 1, excludedAccounts: [{ id: 'off', email: 'off@example.test' }],
items: [
{ id: 'a', email: 'ok@example.test', ok: true, at: 1_700_000_000_000 },
{ id: 'b', email: 'bad@example.test', ok: false, error: 'ERR_PROBE_FAILED', at: 1_700_000_001_000 },
{ id: 'c', email: 'skip@example.test', ok: false, skipped: true, reason: 'ls_not_idle_resident', at: 1_700_000_002_000 },
],
},
});
self._setFleetReopenVisible = method('_setFleetReopenVisible');
self.renderFleetPanel = method('renderFleetPanel');
self.reopenFleetPanel = method('reopenFleetPanel');
self.dismissFleetPanel = method('dismissFleetPanel');
self.renderFleetPanel();
assert.equal(panel.hidden, false);
assert.match(summary.textContent, /accounts\.fleet\.summary/);
assert.match(list.innerHTML, /ok@example\.test/);
assert.match(list.innerHTML, /bad@example\.test/);
assert.match(list.innerHTML, /skip@example\.test/);
assert.match(list.innerHTML, /accounts\.fleet\.resultOk/);
assert.match(list.innerHTML, /accounts\.fleet\.resultFail/);
assert.match(list.innerHTML, /accounts\.fleet\.resultSkipped/);
assert.match(list.innerHTML, /语言服务没在运行/);
const visible = list.innerHTML.replace(/<[^>]+>/g, '');
assert.doesNotMatch(visible, /ls_not_idle_resident/, 'the enum stays out of visible text');
assert.match(list.innerHTML, /techDetail\|code=ls_not_idle_resident/, 'the raw code is still in the title');
assert.match(list.innerHTML, /3m/, 'each item carries its time');
assert.equal(exBtn.hidden, false);
assert.match(exBtn.textContent, /excludedToggle\|count=1/);
assert.match(exList.innerHTML, /off@example\.test/);
// Dismiss hides the panel but keeps the run reachable from the toolbar.
self.dismissFleetPanel();
assert.equal(panel.hidden, true);
assert.equal(reopen.hidden, false);
self.reopenFleetPanel();
assert.equal(panel.hidden, false);
assert.equal(reopen.hidden, true);
});
});
// ─── P1-8: usage page ──────────────────────────────────────────────────────
describe('usage answers "what is left" first (AC-USAGE-1..2)', () => {
it('orders the cards remaining → used → limit → today', () => {
const body = methodParts('renderAcuOrg').body;
const order = ['cards.remaining', 'cards.used', 'cards.limit', 'cards.today'].map((k) => body.indexOf(`T('${k}')`));
assert.ok(order.every((i) => i >= 0));
for (let i = 1; i < order.length; i++) assert.ok(order[i] > order[i - 1], 'remaining must be first');
});
it('defines ACU and names workspaces, not orgs', () => {
for (const [key, value] of [['page.usage', zh['page.usage']], ['nav.usage', zh['nav.usage']]]) {
assert.equal(value, '用量', `${key} is plain 用量`);
}
assert.match(zh['pageSubtitle.usage'], /ACU 是 Devin 的用量单位/);
assert.match(en['pageSubtitle.usage'], /ACU is Devin's usage unit/);
assert.equal(zh['usage.org'], '工作区');
assert.match(zh['usage.orgOption'], /工作区/);
assert.doesNotMatch(zh['usage.orgOption'], /组织/);
});
it('falls back to a human sentence for an unmapped reason', () => {
const self = baseSelf({});
// I18n returns the key itself for a missing key — mirror that so the
// fallback branch is the one under test.
const i18n = {
locale: 'zh-CN',
t: (key, vars = {}) => (key === 'usage.reason.unknown'
? '暂时读不到用量,请稍后重试。'
: (key === 'usage.rawTip'
? `技术详情:${vars.code}`
: key)),
};
const reason = methodWithI18n('usageReason', i18n).call(self, 'not_a_known_reason');
assert.equal(reason, '暂时读不到用量,请稍后重试。');
const tip = methodWithI18n('usageReasonTip', i18n).call(self, 'not_a_known_reason');
assert.match(tip, /技术详情:not_a_known_reason/);
});
});
// ─── P1-9: login modal structure ───────────────────────────────────────────
describe('login tab is a method chooser (AC-LOGIN-1..3)', () => {
const loginPanel = HTML.slice(
HTML.indexOf('<div class="add-panel active" id="add-panel-login"'),
HTML.indexOf('<div class="add-panel" id="add-panel-paste"'),
);
it('first screen is two method cards plus one secondary token link', () => {
const step = loginPanel.slice(
loginPanel.indexOf('id="wl-method-step"'),
loginPanel.indexOf('id="wl-panel-oauth"'),
);
assert.equal((step.match(/class="login-method"/g) || []).length, 2);
assert.equal((step.match(/class="btn btn-ghost btn-sm login-method-token"/g) || []).length, 1);
assert.ok(!/oauth-google-btn/.test(step), 'provider buttons stay behind the OAuth choice');
assert.ok(!/id="wl-email"/.test(step), 'email fields stay behind the email choice');
});
it('keeps the proxy, saved password and login history behind 高级选项', () => {
const advancedAt = loginPanel.indexOf('<details class="login-advanced"');
assert.ok(advancedAt > 0);
const advancedTag = loginPanel.slice(advancedAt, loginPanel.indexOf('>', advancedAt) + 1);
assert.doesNotMatch(advancedTag, /\bopen\b/, 'closed by default');
const advanced = loginPanel.slice(advancedAt);
for (const id of ['wl-proxy-section', 'wl-store-cred', 'wl-history-table']) {
assert.ok(advanced.includes(id), `${id} is inside the advanced disclosure`);
}
});
it('the token path has a button on every step', () => {
const step = loginPanel.slice(
loginPanel.indexOf('id="oauth-token-step"'),
loginPanel.indexOf('id="add-panel-paste"'),
);
const items = step.split(/<li>/).slice(1).map((s) => s.slice(0, s.indexOf('</li>')));
assert.equal(items.length, 3, 'three token steps');
for (const [i, item] of items.entries()) {
assert.match(item, /<button/, `step ${i + 1} has an action`);
}
assert.match(items[0], /App\.fetchWindsurfToken\(\)/);
assert.match(items[1], /App\.pasteOAuthTokenFromClipboard\(\)/);
assert.match(items[2], /App\.submitOAuthToken\(\)/);
});
it('selectLoginMethod reveals exactly one form and keeps the email value', () => {
const step = element('wl-method-step');
const oauth = element('wl-panel-oauth');
const email = element('wl-panel-email');
const tokenStep = element('oauth-token-step');
const emailInput = element('wl-email');
emailInput.value = 'kept@example.test';
step.classList.remove('hidden');
oauth.classList.add('hidden');
email.classList.add('hidden');
const select = method('selectLoginMethod');
const self = baseSelf({ showOAuthTokenStep: method('showOAuthTokenStep') });
select.call(self, 'email');
assert.equal(step.classList.contains('hidden'), true);
assert.equal(email.classList.contains('hidden'), false);
assert.equal(oauth.classList.contains('hidden'), true);
select.call(self, 'oauth');
assert.equal(email.classList.contains('hidden'), true);
assert.equal(oauth.classList.contains('hidden'), false);
assert.equal(emailInput.value, 'kept@example.test', 'switching methods does not clear the email');
select.call(self, 'token');
assert.equal(oauth.classList.contains('hidden'), false);
assert.equal(tokenStep.classList.contains('hidden'), false);
select.call(self, null);
assert.equal(step.classList.contains('hidden'), false);
assert.equal(oauth.classList.contains('hidden'), true);
assert.equal(email.classList.contains('hidden'), true);
});
it('a fresh open starts at the chooser; a bound email starts on the email form', () => {
const source = methodParts('openAddAccountModal').body;
assert.match(source, /if \(this\._addTab === 'login'\) this\.selectLoginMethod\(email \|\| accountId \? 'email' : null\)/);
});
});
// ─── No KPI regression ─────────────────────────────────────────────────────
describe('KPI strip stays a five-cell partition', () => {
it('renders total / healthy / switched off / attention / balance', () => {
const el = element('accounts-kpis');
const self = baseSelf({});
method('renderAccountsKpis').call(self, {
total: 8, stateCounts: { active: 4, disabled: 1, banned: 1, reauth: 1, error: 1 },
attention: 3, balanceAccounts: 2, balanceSharedAccounts: 0,
}, 8);
assert.equal((el.innerHTML.match(/class="acct-kpi"/g) || []).length, 5);
for (const key of ['total', 'active', 'disabled', 'attention', 'balance']) {
assert.match(el.innerHTML, new RegExp(`data-kpi="${key}"`));
}
assert.match(el.innerHTML, /data-kpi="attention" data-value="3"/);
assert.match(el.innerHTML, /data-kpi="disabled" data-value="1"/);
});
});