// 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, '"'); 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(`]*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(''.length, ); const templateBlock = HTML.slice( HTML.indexOf('', HTML.indexOf('