/* Kole UI 测试页 — 行为断言库(S2-P7) * * 目标:把测试从"元素存在"升级到"交互后发生什么",验证点击/输入/键盘的真实行为。 * 运行环境:tests/.html 的帧内(与 _runtime.js 同作用域),直接操作演示页 DOM。 * 协议:在演示页 HTML 里用 data-behavior 声明行为,格式: * * 支持的动作(动词:参数,参数用 | 分隔): * click-toggles-class:| — 点击后 selector 出现/消失该类(再点一次应还原) * click-adds-node: — 点击后 selector 匹配节点数增加 * click-removes-node: — 点击后 selector 匹配节点数减少 * click-sets-attr:|| — 点击后 selector 的属性变为期望值 * input-clears: — 点击后 selector(input)的 value 被清空 * input-filters:| — 输入过滤后 selector 可见数减少(min=最少保留) * keyboard-activates: — 聚焦元素按 key 后有状态变化(is-active/is-open/aria 变化) * tab-switches: — 点击 tab 后对应面板切换(hidden 翻转) * layout-menu-rows — 菜单布局守卫:可见行文本不叠字 / 可见子级不越出容 * 器横向边界 / 折叠态不露子级(nav-menu 族,S6-P43) * 无 data-behavior 声明的页面:返回 [](不产出噪音断言)。 * 每个断言 id 形如 matrix:behavior::-,失败时 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 .kole-input 内找 input),避免命中页内第一个 input var n; if (sel.charAt(0) === '@') { var host = trig.closest ? trig.closest('.kole-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, '.kole-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 }; }, /* nav-menu 族布局守卫(S6-P43,反例见 S6-P42):族重构曾把二级菜单渲染成横向 flex 子项 —— 行文本层层叠在一起、容器越出侧栏。三条不变量一次性查完: ①作用域内可见叶子文本两两不得大幅重叠;②可见子级容器不得越出菜单横向边界; ③折叠态(data-collapsed="true")不得露出子级。纯几何、无交互,不引入时序抖动。 */ 'layout-menu-rows': function (ctx, trig) { var win = ctx.win; var menu = trig; var fails = []; var name = function (n) { var t = (n.textContent || '').trim().slice(0, 12); return (n.className || n.tagName).toString().split(' ')[0] + (t ? '「' + t + '」' : ''); }; var visible = function (n) { var cs = win.getComputedStyle(n); return cs.display !== 'none' && cs.visibility !== 'hidden'; }; /* ① 可见叶子文本两两重叠(叶子=无元素子节点且有文字) */ var leaves = []; qa(menu, '*').forEach(function (n) { if (n.children.length) return; if (!(n.textContent || '').trim()) return; if (!visible(n)) return; var r = n.getBoundingClientRect(); if (r.width > 4 && r.height > 4) leaves.push({ n: n, r: r }); }); for (var i = 0; i < leaves.length; i++) { for (var j = i + 1; j < leaves.length; j++) { var a = leaves[i].r, b = leaves[j].r; var ox = Math.min(a.right, b.right) - Math.max(a.left, b.left); var oy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); if (ox > 3 && oy > 3 && ox * oy > Math.min(a.width * a.height, b.width * b.height) * 0.55) { fails.push('文本交叠 ' + name(leaves[i].n) + ' × ' + name(leaves[j].n) + ' 重叠 ' + Math.round(ox) + '×' + Math.round(oy) + 'px'); } } } /* ② 可见子级容器越出菜单横向边界 */ var mr = menu.getBoundingClientRect(); qa(menu, '.kole-menu-children').forEach(function (k) { if (!visible(k)) return; var kr = k.getBoundingClientRect(); if (kr.width === 0) return; if (kr.left < mr.left - 2 || kr.right > mr.right + 2) { fails.push('子级越出菜单横向边界:子级 [' + Math.round(kr.left) + ',' + Math.round(kr.right) + '] 越出菜单 [' + Math.round(mr.left) + ',' + Math.round(mr.right) + ']'); } }); /* ③ 折叠态不得露出子级(该走 .kole-menu-pop 浮层) */ if (menu.getAttribute('data-collapsed') === 'true') { qa(menu, '.kole-menu-children').forEach(function (k) { if (!visible(k)) return; var h = k.getBoundingClientRect().height; if (h > 0) fails.push('折叠态仍露出子级:' + name(k) + '(高 ' + Math.round(h) + 'px)'); }); } if (fails.length) { return { status: 'fail', err: fails.slice(0, 3).join(';') + (fails.length > 3 ? ' …共 ' + fails.length + ' 处' : '') }; } return { status: 'pass', err: null }; } }; /* sulg 白名单:只在试点组件跑行为断言,避免全量 79 页引入不稳定。 nav-menu 族三个成员(S6-P43):其布局错位曾是"断言全绿但肉眼花屏"的盲区 */ var PILOTS = { button: 1, modal: 1, select: 1, input: 1, table: 1, tabs: 1, sidemenu: 1, topmenu: 1, mixednavigation: 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.__koleBehCache && window.__koleBehCache.slug === ctx.slug) return window.__koleBehCache.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.__koleBehCache = { slug: ctx.slug, out: out }; return out; } window.__koleBehaviors = { run: runBehaviors, verbs: Object.keys(VERBS), pilots: Object.keys(PILOTS) }; })();