/* Kole UI Mobile 测试页 — 移动端行为断言库 * * 与 PC 侧 tests/_behaviors.js 的关系: * - 同一套协议([data-behavior="动词:参数|参数"]、window.__koleBehaviors、id 形如 * matrix:behavior::-)与同一套引擎(tests/_runtime.js 调用)。 * - **本文件是移动端专属**:新增触控动词(滑动 / 下拉),PC 端不认识这些动词; * 移动端也不加载 PC 的行为库,故两端互不影响(隔离规则见 PLATFORMS.md)。 * * 动词一览: * click-toggles-class:| 点击后该类出现/消失 * click-sets-attr:|| 点击后属性变为期望值 * tab-switches: 点击标签后对应面板 hidden 翻转 * swipe-sets-class:|[|left|right] 在该元素上滑动后该类出现(默认左滑) * swipe-sets-attr:||[|left|right] 同上,断言属性 * pull-triggers:|[|] 在该元素上向下拖动后该类出现 */ (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 []; } } function classesOf(root, sel, cls) { return qa(root, sel).map(function (n) { return n.classList.contains(cls); }).join(','); } function attrsOf(root, sel, attr) { return qa(root, sel).map(function (n) { return n.getAttribute(attr); }).join(','); } /* 同步 pump:点击/手势是同步 dispatch,但演示页可能在下一帧才写状态。 与 PC 侧同口径:强制 reflow + 最多 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 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 makeEvent(win, type, x, y, buttons) { var init = { bubbles: true, cancelable: true, view: win, clientX: x, clientY: y, buttons: buttons, button: type === 'pointerup' || type === 'pointercancel' ? 0 : 0, pointerId: 1, pointerType: 'touch', isPrimary: true }; var ev; if (typeof win.PointerEvent === 'function') { try { return new win.PointerEvent(type, init); } catch (e) { /* 落到 MouseEvent */ } } try { ev = new win.MouseEvent(type.replace('pointer', 'mouse'), init); } catch (e) { ev = win.document.createEvent('MouseEvents'); ev.initMouseEvent(type.replace('pointer', 'mouse'), true, true, win, 1, x, y, x, y, false, false, false, false, 0, null); } return ev; } function dispatch(win, el, type, x, y, buttons) { el.dispatchEvent(makeEvent(win, type, x, y, buttons)); } /* 手势基座:down → 若干 move → up。steps 分段以保证「判定为滑动」的逻辑能读到中间位移。 */ function gesture(ctx, el, dx, dy, steps) { var rect = el.getBoundingClientRect ? el.getBoundingClientRect() : { left: 0, top: 0, width: 320, height: 120 }; var x0 = Math.round(rect.left + Math.min(120, Math.max(20, (rect.width || 320) / 3))); var y0 = Math.round(rect.top + Math.min(40, Math.max(12, (rect.height || 120) / 3))); var n = steps || 4; dispatch(ctx.win, el, 'pointerdown', x0, y0, 1); for (var i = 1; i <= n; i++) { dispatch(ctx.win, el, 'pointermove', x0 + Math.round((dx * i) / n), y0 + Math.round((dy * i) / n), 1); } dispatch(ctx.win, el, 'pointerup', x0 + dx, y0 + dy, 0); } /* 滑动(SwipeCell):左滑 dx=-100,右滑 dx=+100 —— 均超过默认操作区宽度的一半 */ function swipe(ctx, sel, dir) { var el = q(ctx.root, sel); if (!el) return { el: null, err: '找不到元素 ' + sel }; var sign = dir === 'right' ? 1 : -1; gesture(ctx, el, sign * 100, 0, 4); return { el: el, err: null }; } 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 = classesOf(ctx.root, sel, cls); click(ctx.win, trig); var after = before; pump(ctx.doc, function () { after = classesOf(ctx.root, sel, cls); return after !== before; }); if (before === after) return { status: 'fail', err: '点击后 ' + sel + ' 的 .' + cls + ' 无变化(前=' + 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 got = null; pump(ctx.doc, function () { var n = q(ctx.root, sel); got = n ? n.getAttribute(attr) : null; return String(got) === val; }); if (String(got) !== val) return { status: 'fail', err: sel + ' 的 ' + attr + ' 期望=' + val + ' 实际=' + got }; return { status: 'pass', err: null }; }, 'tab-switches': function (ctx, trig, args) { var attr = args[0] || 'data-panel'; var items = qa(ctx.root, '.kole-m-tabbar__item'); if (items.indexOf(trig) < 0) items = qa(ctx.root, '[role="tab"],button'); if (items.indexOf(trig) < 0) return { status: 'fail', err: '触发元素不是标签项' }; var panels = qa(ctx.root, '[' + attr + ']'); if (!panels.length) return { status: 'fail', err: '未找到 [' + attr + '] 面板' }; var vis = function () { return panels.map(function (p) { return !p.hidden && p.style.display !== 'none'; }).join(','); }; var before = vis(); click(ctx.win, trig); var after = before; pump(ctx.doc, function () { after = vis(); return after !== before; }); if (before === after) return { status: 'fail', err: '点击标签后面板可见性无变化(' + before + ')' }; var sel = trig.getAttribute('aria-selected'); if (sel !== 'true') return { status: 'fail', err: '切换后 aria-selected 期望=true 实际=' + sel }; return { status: 'pass', err: null }; }, 'swipe-sets-class': function (ctx, trig, args) { var sel = args[0], cls = args[1], dir = args[2] || 'left'; if (!sel || !cls) return { status: 'fail', err: '参数缺失(需 selector|class[|left|right])' }; var before = classesOf(ctx.root, sel, cls); var r = swipe(ctx, sel, dir); if (r.err) return { status: 'fail', err: r.err }; var after = before; pump(ctx.doc, function () { after = classesOf(ctx.root, sel, cls); return after !== before; }); if (before === after) return { status: 'fail', err: dir + '滑后 ' + sel + ' 的 .' + cls + ' 无变化(' + before + ')' }; return { status: 'pass', err: null }; }, 'swipe-sets-attr': function (ctx, trig, args) { var sel = args[0], attr = args[1], val = args[2], dir = args[3] || 'left'; if (!sel || !attr || val === undefined) return { status: 'fail', err: '参数缺失(需 selector|attr|value[|left|right])' }; var r = swipe(ctx, sel, dir); if (r.err) return { status: 'fail', err: r.err }; var got = null; pump(ctx.doc, function () { var n = q(ctx.root, sel); got = n ? n.getAttribute(attr) : null; return String(got) === val; }); if (String(got) !== val) return { status: 'fail', err: dir + '滑后 ' + sel + ' 的 ' + attr + ' 期望=' + val + ' 实际=' + got }; return { status: 'pass', err: null }; }, 'pull-triggers': function (ctx, trig, args) { var sel = args[0], cls = args[1], dist = Number(args[2]) || 80; if (!sel || !cls) return { status: 'fail', err: '参数缺失(需 selector|class[|distance])' }; if (dist < 61) return { status: 'fail', err: '下拉距离 ' + dist + 'px 小于默认阈值 60px,无法触发' }; var before = classesOf(ctx.root, sel, cls); var el = q(ctx.root, sel); if (!el) return { status: 'fail', err: '找不到元素 ' + sel }; gesture(ctx, el, 0, dist, 4); var after = before; pump(ctx.doc, function () { after = classesOf(ctx.root, sel, cls); return after !== before; }); if (before === after) return { status: 'fail', err: '下拉 ' + dist + 'px 后 ' + sel + ' 的 .' + cls + ' 无变化(' + before + ')' }; return { status: 'pass', err: null }; } }; /* 行为断言的适用范围(2026-09-20 修正:此前是硬编码白名单,索引里登记了 behaviors 的组件 也不会被点击验证 —— 演示页写了 data-behavior 却静默不跑,属静默缺口)。 现在两道来源取并集: ① 演示页里**真的**写了 [data-behavior](组件自己声明了行为,就该被验证); ② 兜底名单(历史 5 个组件;即使哪天演示页漏写属性也仍然跑)。 build-mobile 在生成测试页时把 ① 的结果写进 window.__koleBehaviorSlugs。 */ var FALLBACK_PILOTS = { navbar: 1, tabbar: 1, actionsheet: 1, pullrefresh: 1, swipecell: 1 }; function inScope(slug, root) { if (FALLBACK_PILOTS[slug]) return true; if (window.__koleBehaviorSlugs && window.__koleBehaviorSlugs.indexOf(slug) >= 0) return true; /* 兜底:本页确实有 data-behavior(生成期数据未注入时,例如手工打开测试页) */ try { return !!(root || document).querySelector && !!document.querySelector("[data-behavior]") && !!root && !!root.querySelector("[data-behavior]"); } catch (e) { return false; } } var PILOTS = FALLBACK_PILOTS; function runBehaviors(ctx) { if (!inScope(ctx.slug, ctx.root)) return []; /* 幂等:手势会改变演示页状态,同一 page-load 内只跑一次(重载演示页即可重测) */ 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), mobile: true }; })();