Files
lsky-pro-docker/test/infinite-scroll-click-delegate.test.mjs
mole404 32859cc5cb fix(frontend): 收窄 infiniteScroll 点击判据 + 顶栏折叠按钮贴左(含产物重建)
F14:click 委托判据从「容器内任意 span」收窄到插件自己插入的哨兵专属类(closest 保留子元素与 .disabled 语义),绑定/解绑改用 click.infiniteScroll 命名空间 —— destroy() 不再摘掉同一容器上别人的委托(图片页还有一条 .image-selector)。header:折叠按钮绝对定位贴内容区左缘(桌面 292→256/折叠 100→64/手机 20→0),点击区 36→44,标题视觉 x 全程 Δ0。产物已真重建(npm run prod,Node 18):public/ 下只有 public/js/app.js 变(c4678319→7459c100),钉住的两个补丁产物字节未变。jsdom 288 条全绿。
2026-09-30 06:18:56 +08:00

214 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* F14 回归测试:infiniteScroll 的 click 委托判据「收窄」+ 事件命名空间
*
* 背景:
* 老的实现是 `$(selector).off('click').on('click', 'span:not(.disabled)', () => load())`
* —— 判据是「容器内任意 span」。列表里任何一个 span(相册名、徽标…)被点都会被
* 当成「点了加载更多」,顺手多发一页请求。真正「有意」的触发器只有本方法自己插在
* 列表末尾的那条哨兵(.infinite-scroll > span,文案「加载更多 / 我也是有底线的~」)。
* 修法:给哨兵一个专属类 js-infinite-scroll-trigger,click 判据收窄到它;点哨兵里的
* 子元素(图标/文字)仍算点了哨兵。.disabled 语义保留(加载中/到底/出错点了不加载)。
*
* 另外:destroy() 原来写的是 `.unbind('click')`(无命名空间),会把绑在同一个容器上
* 别人的 click 委托一起摘掉(图片页容器上还有一条 .image-selector 委托)。本次改成
* `click.infiniteScroll` 命名空间,谁注册谁解绑。
*
* 做法沿用 infinite-scroll-destroy.test.mjs:把 app.js 里的 infiniteScroll 方法原样
* 抽出来(花括号配对,不是抄一份),塞进 jsdom + 真实 jQuery 里跑,axios 打桩。
*
* 运行:
* node infinite-scroll-click-delegate.test.mjs [要测的 app.js 路径]
* 传路径时测的是那份文件(用来验证「改前会失败」,默认测仓库里这份)。
*/
import fs from 'node:fs';
import path from 'node:path';
import { JSDOM } from 'jsdom';
const here = path.dirname(decodeURIComponent(new URL(import.meta.url).pathname));
const JQUERY_SRC = fs.readFileSync(path.join(here, 'node_modules/jquery/dist/jquery.js'), 'utf8');
const APP_JS = process.argv[2] || path.join(here, '..', 'src', 'resources', 'js', 'app.js');
const APP_SRC = fs.readFileSync(APP_JS, 'utf8');
// 插件那条哨兵 span 的专属类(与源码里保持一致;下面「前置自检」会核对源码里真有它,
// 不是手抄一份假货)
const SENTINEL_CLASS = 'js-infinite-scroll-trigger';
// 与另外两个 infinite-scroll 测试同一个抽取器:`infiniteScroll(selector, options) { … }`
function extractMethodBody(src, signature) {
const start = src.indexOf(signature);
if (start < 0) throw new Error(`app.js 里找不到 ${signature}`);
let depth = 0;
let quote = null;
let i = src.indexOf('{', start);
for (; i < src.length; i++) {
const ch = src[i];
if (quote) {
if (ch === '\\') { i++; continue; }
if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'" || ch === '`') { quote = ch; continue; }
if (ch === '/' && src[i + 1] === '/') { while (i < src.length && src[i] !== '\n') i++; continue; }
if (ch === '{') depth++;
else if (ch === '}') { depth--; if (depth === 0) break; }
}
if (depth !== 0) throw new Error('花括号没配平:抽 infiniteScroll 的函数体失败');
return src.slice(src.indexOf('{', start), i + 1);
}
const METHOD_BODY = extractMethodBody(APP_SRC, 'infiniteScroll(selector, options)');
const results = [];
function check(name, pass, detail = '') {
results.push({ name, pass, detail });
console.log(`${pass ? ' PASS' : ' FAIL'} ${name}${detail ? ' → ' + detail : ''}`);
}
function boot() {
const dom = new JSDOM(`<!DOCTYPE html><html><body>
<div id="images-scroll"><div id="images-grid"></div></div>
<div id="album-switch-scroll"><div id="albums-container"></div></div>
</body></html>`, { runScripts: 'outside-only', pretendToBeVisual: true, url: 'https://lsky.test/user/images' });
const { window } = dom;
window.eval(JQUERY_SRC);
const $ = window.jQuery;
const calls = { gets: [] };
window.axios = {
get: (url, config) => { calls.gets.push({ url, params: config && { ...config.params } }); return Promise.resolve({ data: {} }); },
};
window.eval(`window.utils = { infiniteScroll(selector, options) ${METHOD_BODY} };`);
const fireScroll = (el) => { el.dispatchEvent(new window.Event('scroll')); };
return { window, $, calls, fireScroll };
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ---------------------------------------------------------------- 前置自检
// 抽出来的那段必须真的是仓库里这份 app.js 的实现(别哪天被换成手抄的假货)
{
check('抽到的 infiniteScroll 就是 app.js 里那份(含 useWindowScroll 与两个分支)',
METHOD_BODY.includes("const useWindowScroll = options.root === 'window';")
&& METHOD_BODY.includes("$(window).on('scroll.infiniteScroll', onScroll);")
&& METHOD_BODY.includes("$(selector).on('scroll.infiniteScroll', onScroll);"),
`${APP_JS}`);
check(`哨兵生成时就带上专属类 .${SENTINEL_CLASS}`,
METHOD_BODY.includes(SENTINEL_CLASS),
METHOD_BODY.includes(SENTINEL_CLASS) ? '' : '源码里没找到这个类');
check('click 委托判据收窄到哨兵类、并且带命名空间 click.infiniteScroll',
METHOD_BODY.includes(SENTINEL_CLASS)
&& METHOD_BODY.includes('click.infiniteScroll')
&& ! METHOD_BODY.includes("'span:not(.disabled)'"));
check('destroy() 只解绑自己的命名空间(有 off/unbind click.infiniteScroll)',
/\.(off|unbind)\('click\.infiniteScroll'\)/.test(METHOD_BODY));
}
// ---------------------------------------------------------------- 判据收窄
console.log('\n[判据收窄] 只有插件自己插的那条哨兵(及其子元素)才算「点了加载更多」');
{
// (A) 点容器内一个非哨兵 span → 不该加载(改前必红)
{
const { window, $, calls } = boot();
window.utils.infiniteScroll('#album-switch-scroll', { url: '/albums' });
await sleep(0); // 等首屏那发请求落地,props.loading 复位
// 模拟相册名那种行内 span(真实模板里现在没有,但老委托会把它当触发器)
$('#albums-container').append('<div class="albums-row"><span class="album-name">三亚</span></div>');
const before = calls.gets.length;
$('#albums-container .album-name').trigger('click');
check('点容器内一个非哨兵 span(相册名那种):不触发加载',
calls.gets.length === before, `gets=${calls.gets.length}(期望 ${before})`);
}
// (B) 点哨兵本身 → 照旧加载(行为保持)
{
const { window, $, calls } = boot();
window.utils.infiniteScroll('#album-switch-scroll', { url: '/albums' });
await sleep(0);
const before = calls.gets.length;
$('#album-switch-scroll .infinite-scroll span').trigger('click');
check('点哨兵本身:照旧加载下一页',
calls.gets.length === before + 1, `gets=${calls.gets.length}(期望 ${before + 1})`);
}
// (C) 点哨兵里的子元素(图标/文字)→ 也算点了哨兵(行为保持)
{
const { window, $, calls } = boot();
window.utils.infiniteScroll('#album-switch-scroll', { url: '/albums' });
await sleep(0);
const $sentinel = $('#album-switch-scroll .infinite-scroll span');
$sentinel.append('<i class="js-infinite-scroll-icon">↻</i>');
const before = calls.gets.length;
$('#album-switch-scroll .infinite-scroll span i.js-infinite-scroll-icon').trigger('click');
check('点哨兵里的子元素(图标):也算点了哨兵,照旧加载',
calls.gets.length === before + 1, `gets=${calls.gets.length}(期望 ${before + 1})`);
}
// (D) .disabled 态点了不加载(行为保持)
{
const { window, $, calls } = boot();
window.utils.infiniteScroll('#album-switch-scroll', { url: '/albums' });
await sleep(0);
const $sentinel = $('#album-switch-scroll .infinite-scroll span');
$sentinel.addClass('disabled');
const before = calls.gets.length;
$sentinel.trigger('click');
check('.disabled 态(加载中/到底/出错)点哨兵:不加载',
calls.gets.length === before, `gets=${calls.gets.length}(期望 ${before})`);
}
}
// ---------------------------------------------------------------- 命名空间
console.log('\n[命名空间] destroy() 不得连带摘掉同一容器上别人的 click 委托');
{
const { window, $, calls } = boot();
const album = window.utils.infiniteScroll('#album-switch-scroll', { url: '/albums' });
await sleep(0);
// 同容器上另有一条(非命名空间的)click 委托 —— 例如图片页容器上的 .image-selector
let otherClicks = 0;
$('#album-switch-scroll').on('click', '.image-selector', () => { otherClicks++; });
$('#albums-container').append('<a class="image-selector">点我</a>');
album.destroy();
check('destroy() 之后:同一容器上另一条(非命名空间)click 委托仍然有效',
(() => { $('#albums-container .image-selector').trigger('click'); return otherClicks === 1; })(),
`otherClicks=${otherClicks}(期望 1)`);
const before = calls.gets.length;
$('#album-switch-scroll .infinite-scroll span').trigger('click');
check('destroy() 之后:它自己的哨兵点击已被解绑(不再加载)',
calls.gets.length === before, `gets=${calls.gets.length}(期望 ${before})`);
}
// ---------------------------------------------------------------- 滚到底自动加载(两分支)
console.log('\n[滚动] 跳到底自动加载(window 版与容器版)不被本次改动破坏');
{
const { window, $, calls, fireScroll } = boot();
window.utils.infiniteScroll('#images-grid', { root: 'window', url: '/wall' });
window.utils.infiniteScroll('#album-switch-scroll', { url: '/albums' });
await sleep(0);
const before = calls.gets.length;
fireScroll(window);
check('window 版:整页滚到底仍然自动加载',
calls.gets.length === before + 1, `gets=${calls.gets.length}(期望 ${before + 1})`);
fireScroll($('#album-switch-scroll').get(0));
check('容器版:滚自己的容器到底仍然自动加载',
calls.gets.length === before + 2, `gets=${calls.gets.length}(期望 ${before + 2})`);
}
// ---------------------------------------------------------------- 汇总
const failed = results.filter((r) => !r.pass);
console.log(`\n${results.length - failed.length}/${results.length} 通过`);
if (failed.length) {
console.log('\n失败项:');
failed.forEach((f) => console.log(` - ${f.name}${f.detail ? ' → ' + f.detail : ''}`));
}
process.exit(failed.length ? 1 : 0);