P7: tests/_behaviors.js新建(8动词+@input/@doc定位+同步pump+page-load
幂等缓存); _runtime聚合+重载清缓存; _template引入; 6演示页标注;
79测试页重生成。关键修:runner兜底重跑致行为二轮误报,加缓存解决。
P8: tools/migrate-rtl.mjs新建(白名单dry-run/--write); 28个CSS转逻辑
属性;物理清零(排除auto/拼接/居中14处人工例外);三页RTL零溢出。
回归:100%(79/79页,1009/1009断言,N/A 35)。
231 lines
11 KiB
JavaScript
231 lines
11 KiB
JavaScript
/* Aurora Admin 测试页 — 行为断言库(S2-P7)
|
||
*
|
||
* 目标:把测试从"元素存在"升级到"交互后发生什么",验证点击/输入/键盘的真实行为。
|
||
* 运行环境:tests/<slug>.html 的帧内(与 _runtime.js 同作用域),直接操作演示页 DOM。
|
||
* 协议:在演示页 HTML 里用 data-behavior 声明行为,格式:
|
||
* <button data-behavior="click-toggles-class:.aa-mask|is-open">打开</button>
|
||
* 支持的动作(动词:参数,参数用 | 分隔):
|
||
* click-toggles-class:<selector>|<class> — 点击后 selector 出现/消失该类(再点一次应还原)
|
||
* click-adds-node:<selector> — 点击后 selector 匹配节点数增加
|
||
* click-removes-node:<selector> — 点击后 selector 匹配节点数减少
|
||
* click-sets-attr:<selector>|<attr>|<val> — 点击后 selector 的属性变为期望值
|
||
* input-clears:<selector> — 点击后 selector(input)的 value 被清空
|
||
* input-filters:<selector>|<min> — 输入过滤后 selector 可见数减少(min=最少保留)
|
||
* keyboard-activates:<key> — 聚焦元素按 key 后有状态变化(is-active/is-open/aria 变化)
|
||
* tab-switches:<panel-attr> — 点击 tab 后对应面板切换(hidden 翻转)
|
||
* 无 data-behavior 声明的页面:返回 [](不产出噪音断言)。
|
||
* 每个断言 id 形如 matrix:behavior:<slug>:<index>-<verb>,失败时 err 写清期望与实际。
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
function q(root, sel) {
|
||
try { return root.querySelector(sel); } catch (e) { return null; }
|
||
}
|
||
function qa(root, sel) {
|
||
try { return Array.prototype.slice.call(root.querySelectorAll(sel)); } catch (e) { return []; }
|
||
}
|
||
/* @doc 前缀:在整个演示文档查(弹窗挂载点常在 .demo 作用域外,如 modal 的 #mount) */
|
||
function qaScope(ctx, sel) {
|
||
if (sel.charAt(0) === '@' && sel.slice(1, 4) === 'doc') {
|
||
var real = sel.slice(4);
|
||
try { return Array.prototype.slice.call(ctx.doc.querySelectorAll(real)); } catch (e) { return []; }
|
||
}
|
||
return qa(ctx.root, sel);
|
||
}
|
||
function click(win, el) {
|
||
var ev;
|
||
try {
|
||
ev = new win.MouseEvent('click', { bubbles: true, cancelable: true, view: win });
|
||
} catch (e) {
|
||
ev = win.document.createEvent('MouseEvents');
|
||
ev.initMouseEvent('click', true, true, win, 1, 0, 0, 0, 0, false, false, false, false, 0, null);
|
||
}
|
||
el.dispatchEvent(ev);
|
||
}
|
||
function keydown(win, el, key) {
|
||
var ev;
|
||
try {
|
||
ev = new win.KeyboardEvent('keydown', { key: key, bubbles: true, cancelable: true });
|
||
} catch (e) {
|
||
ev = win.document.createEvent('Events');
|
||
ev.initEvent('keydown', true, true);
|
||
ev.key = key;
|
||
}
|
||
el.dispatchEvent(ev);
|
||
}
|
||
function visCount(win, root, sel) {
|
||
return qa(root, sel).filter(function (n) {
|
||
try {
|
||
var cs = win.getComputedStyle(n);
|
||
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
|
||
var r = n.getBoundingClientRect();
|
||
return r.width > 0 || r.height > 0;
|
||
} catch (e) { return false; }
|
||
}).length;
|
||
}
|
||
|
||
var VERBS = {
|
||
'click-toggles-class': function (ctx, trig, args) {
|
||
var sel = args[0], cls = args[1];
|
||
if (!sel || !cls) return { status: 'fail', err: '参数缺失(需 selector|class)' };
|
||
var before = qa(ctx.root, sel).map(function (n) { return n.classList.contains(cls); }).join(',');
|
||
click(ctx.win, trig);
|
||
var after = null;
|
||
pump(ctx.doc, function () {
|
||
after = qa(ctx.root, sel).map(function (n) { return n.classList.contains(cls); }).join(',');
|
||
return after !== before;
|
||
});
|
||
if (before === after) return { status: 'fail', err: '点击后 ' + sel + ' 的 .' + cls + ' 无变化(前=' + before + ' 后=' + after + ')' };
|
||
return { status: 'pass', err: null };
|
||
},
|
||
'click-adds-node': function (ctx, trig, args) {
|
||
var sel = args[0];
|
||
if (!sel) return { status: 'fail', err: '参数缺失(需 selector)' };
|
||
var before = qaScope(ctx, sel).length;
|
||
click(ctx.win, trig);
|
||
var after = before;
|
||
pump(ctx.doc, function () {
|
||
after = qaScope(ctx, sel).length;
|
||
return after > before;
|
||
});
|
||
if (after <= before) return { status: 'fail', err: '点击后 ' + sel + ' 数量未增加(前=' + before + ' 后=' + after + ')' };
|
||
return { status: 'pass', err: null };
|
||
},
|
||
'click-removes-node': function (ctx, trig, args) {
|
||
var sel = args[0];
|
||
if (!sel) return { status: 'fail', err: '参数缺失(需 selector)' };
|
||
var before = qa(ctx.root, sel).length;
|
||
click(ctx.win, trig);
|
||
var after = qa(ctx.root, sel).length;
|
||
if (after >= before) return { status: 'fail', err: '点击后 ' + sel + ' 数量未减少(前=' + before + ' 后=' + after + ')' };
|
||
return { status: 'pass', err: null };
|
||
},
|
||
'click-sets-attr': function (ctx, trig, args) {
|
||
var sel = args[0], attr = args[1], val = args[2];
|
||
if (!sel || !attr || val === undefined) return { status: 'fail', err: '参数缺失(需 selector|attr|value)' };
|
||
click(ctx.win, trig);
|
||
var n = q(ctx.root, sel);
|
||
if (!n) return { status: 'fail', err: '点击后找不到 ' + sel };
|
||
var got = n.getAttribute(attr);
|
||
if (String(got) !== val) return { status: 'fail', err: sel + ' 的 ' + attr + ' 期望=' + val + ' 实际=' + got };
|
||
return { status: 'pass', err: null };
|
||
},
|
||
'input-clears': function (ctx, trig, args) {
|
||
var sel = args[0];
|
||
if (!sel) return { status: 'fail', err: '参数缺失(需 input selector)' };
|
||
// @input 形式:相对触发元素定位(closest .au-input 内找 input),避免命中页内第一个 input
|
||
var n;
|
||
if (sel.charAt(0) === '@') {
|
||
var host = trig.closest ? trig.closest('.au-input') : null;
|
||
n = host ? host.querySelector(sel.slice(1)) : null;
|
||
} else {
|
||
n = q(ctx.root, sel);
|
||
}
|
||
if (!n) return { status: 'fail', err: '找不到 ' + sel };
|
||
n.value = '待清空文本';
|
||
try { n.dispatchEvent(new ctx.win.Event('input', { bubbles: true })); } catch (e) {}
|
||
click(ctx.win, trig);
|
||
if (n.value !== '') return { status: 'fail', err: '点击清空后 value 仍为「' + n.value + '」' };
|
||
return { status: 'pass', err: null };
|
||
},
|
||
'input-filters': function (ctx, trig, args) {
|
||
var sel = args[0], min = parseInt(args[1] || '0', 10);
|
||
if (!sel) return { status: 'fail', err: '参数缺失(需 selector)' };
|
||
var before = visCount(ctx.win, ctx.root, sel);
|
||
trig.value = 'zzzz-no-match-zzz';
|
||
try { trig.dispatchEvent(new ctx.win.Event('input', { bubbles: true })); } catch (e) {}
|
||
var after = visCount(ctx.win, ctx.root, sel);
|
||
if (after >= before) return { status: 'fail', err: '输入过滤串后 ' + sel + ' 可见数未减少(前=' + before + ' 后=' + after + ')' };
|
||
if (after < min) return { status: 'fail', err: '过滤后可见数 ' + after + ' 低于最小保留 ' + min };
|
||
trig.value = '';
|
||
try { trig.dispatchEvent(new ctx.win.Event('input', { bubbles: true })); } catch (e) {}
|
||
return { status: 'pass', err: null };
|
||
},
|
||
'keyboard-activates': function (ctx, trig, args) {
|
||
var key = args[0] || 'Enter';
|
||
var sig = function () {
|
||
return qa(ctx.root, '[class]').map(function (n) { return n.className; }).join('|').length + '|' +
|
||
qa(ctx.root, '[aria-selected],[aria-expanded]').map(function (n) {
|
||
return n.getAttribute('aria-selected') + ',' + n.getAttribute('aria-expanded');
|
||
}).join('|');
|
||
};
|
||
try { trig.focus(); } catch (e) {}
|
||
var before = sig();
|
||
keydown(ctx.win, trig, key);
|
||
var after = sig();
|
||
if (before === after) return { status: 'fail', err: '按 ' + key + ' 后无状态变化(类/aria 快照一致)' };
|
||
return { status: 'pass', err: null };
|
||
},
|
||
'tab-switches': function (ctx, trig, args) {
|
||
var attr = args[0] || 'data-panel';
|
||
var tabs = qa(ctx.root, '.aa-tabs-tab');
|
||
var idx = tabs.indexOf(trig);
|
||
if (idx < 0) {
|
||
var all = qa(ctx.root, '[role="tab"],button');
|
||
idx = all.indexOf(trig);
|
||
if (idx < 0) return { status: 'fail', err: '触发元素不是 tab' };
|
||
}
|
||
var panels = qa(ctx.root, '[' + attr + ']');
|
||
var visBefore = panels.map(function (p) { return !p.hidden && p.style.display !== 'none'; });
|
||
click(ctx.win, trig);
|
||
var visAfter = visBefore.slice();
|
||
pump(ctx.doc, function () {
|
||
visAfter = panels.map(function (p) { return !p.hidden && p.style.display !== 'none'; });
|
||
return visBefore.join(',') !== visAfter.join(',');
|
||
});
|
||
if (visBefore.join(',') === visAfter.join(',')) {
|
||
return { status: 'fail', err: '点击 tab 后面板可见性无变化' };
|
||
}
|
||
return { status: 'pass', err: null };
|
||
}
|
||
};
|
||
|
||
/* sulg 白名单:只在 6 个试点组件跑行为断言,避免全量 79 页引入不稳定 */
|
||
var PILOTS = { button: 1, modal: 1, select: 1, input: 1, table: 1, tabs: 1 };
|
||
|
||
/* 点击类动作是同步 dispatch,但演示页的状态更新(innerHTML/render/ hidden 翻转)
|
||
可能落在下一帧;并发回归抢主线程时读到的就是中间态。统一 pump 一帧再读后态。
|
||
同步 pump:强制同步 reflow(读 offsetHeight)+ 最多 3 次同步重读(状态翻转即停)。 */
|
||
function pump(doc, check) {
|
||
for (var i = 0; i < 3; i++) {
|
||
try { void doc.documentElement.offsetHeight; } catch (e) {}
|
||
if (check()) return true;
|
||
}
|
||
return check();
|
||
}
|
||
|
||
function runBehaviors(ctx) {
|
||
if (!PILOTS[ctx.slug]) return [];
|
||
/* 幂等:行为断言是有状态的(点击会改变演示页),同一 page-load 内只执行一次,
|
||
后续 run()(回归 runner 的兜底重跑 / 人工点「运行断言」)复用缓存。
|
||
想要重测 → 重载演示页(页面左上「重载演示页」按钮)。 */
|
||
if (window.__aaBehCache && window.__aaBehCache.slug === ctx.slug) return window.__aaBehCache.out;
|
||
var trigs = qa(ctx.root, '[data-behavior]');
|
||
var out = [];
|
||
trigs.forEach(function (trig, i) {
|
||
var spec = (trig.getAttribute('data-behavior') || '').trim();
|
||
var verb = spec.split(':')[0];
|
||
var args = spec.slice(verb.length + 1).split('|');
|
||
var id = 'matrix:behavior:' + ctx.slug + ':' + i + '-' + verb;
|
||
var fn = VERBS[verb];
|
||
if (!fn) {
|
||
out.push({ id: id, status: 'fail', ms: 0, err: '未知行为动词: ' + verb });
|
||
return;
|
||
}
|
||
var t0 = performance.now();
|
||
var r;
|
||
try {
|
||
r = fn(ctx, trig, args);
|
||
} catch (e) {
|
||
r = { status: 'fail', err: '执行异常: ' + (e && e.message || e) };
|
||
}
|
||
out.push({ id: id, status: r.status, ms: Math.round(performance.now() - t0), err: r.err });
|
||
});
|
||
window.__aaBehCache = { slug: ctx.slug, out: out };
|
||
return out;
|
||
}
|
||
|
||
window.__aaBehaviors = { run: runBehaviors, verbs: Object.keys(VERBS), pilots: Object.keys(PILOTS) };
|
||
})();
|