Files
lsky-pro-docker/test/infinite-scroll-destroy.test.mjs
mole404 89f9d28fbe fix(回归): infiniteScroll 销毁时误杀 window 滚动监听,图片墙不再自动加载
① app.js 的 infiniteScroll.destroy() 原来无条件执行 $(window).off('scroll.infiniteScroll'),
   而该命名空间在 window 上是全局唯一 —— 相册弹窗(容器版实例)一关闭,就把图片墙(window 版)
   的整页滚动监听一起解绑了:桌面「我的图片」滚到底不再自动加载,只能手动点哨兵文字。
   改为只有自己就是 window 版(useWindowScroll)时才解绑 window;容器版只解绑自己的 selector。
   该 bug 旧抽屉关闭时同样存在(抽屉用得少,一直没暴露)。
② 相册行名称 <span class="name"> → <div class="name">:infiniteScroll 第 133 行的委托会把
   列表容器里任意 span 当作「加载更多」,点相册名会顺带多拉一页相册(改前实测请求 2→3,
   改后 2→2)。只改模板侧,不动那个全站共用的委托选择器(改它需单独做全站回归)。
   「移动到相册」行的名称与两处 ls-badge 徽标改前已是 div,无需改动。
③ 桌面端相册弹窗宽度 420px → 520px(@media min-width:768px;手机底部抽屉不受影响)。
④ 新增回归测试 test/infinite-scroll-destroy.test.mjs(12 条,含 RED→GREEN 证据:补丁前 10/12,
   失败项正是「销毁容器版后 window 上的监听是否仍在」「销毁后整页滚到底是否仍自动加载」)。
   同步:Dockerfile 的 images_blade_md5 = cab1ae9abf815ae3ca30a63608dbf004、
   patches/ios-longpress.patch 重生成(3115 行、打回上游字节一致)、两套测试更新。
   两份 context-js.js 未改动(仍 b13fc1e4…)。
2026-09-29 23:25:07 +08:00

184 lines
9.4 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.
/*
* utils.infiniteScroll 的 destroy() 回归测试(jsdom + 真实 jQuery)
*
* 背景:同一页面上有两个无限加载实例 —— 图片墙({root:'window'},跟整页滚动)与
* 相册弹窗列表(默认,跟容器自己的滚动)。老的 destroy() 里 `$(window).off('scroll.infiniteScroll')`
* 是无条件执行的:只要相册弹窗开一次再关掉(closeAlbums() → albumsInfinite.destroy()),
* 挂在 window 上的图片墙监听就被顺手解绑 → 「我的图片」页滚到底不再自动加载,
* 必须手动点列表底部那行哨兵文字。这个文件把「谁注册谁解绑」这条钉住。
*
* 做法:把 app.js 里的 infiniteScroll 方法原样抽出来(花括号配对,不是抄一份),
* 塞进 jsdom + 真实 jQuery 里跑,其余依赖(axios)打桩 —— 与 images-modal-dom.test.mjs
* 同一套路(读源码 → 在 jsdom 里 eval → 驱动真实代码路径)。
*
* 运行:
* node infinite-scroll-destroy.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');
// 把 `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 : ''}`);
}
// app.js 是 ESM(顶部 import Alpine 之类),不能在 jsdom 里整份跑;只把 infiniteScroll
// 这一段装进 window.utils,函数体里引用的 $ / axios / toastr 由 jsdom 全局提供。
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 = {
// params 拷一份:opts.data 是同一个对象的引用(page 会被实现自己 ++),不拷会看到改后的值
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 scrollEvents = (el) => ($._data(el, 'events') || {}).scroll || [];
const fireScroll = (el) => { el.dispatchEvent(new window.Event('scroll')); };
return { window, $, calls, scrollEvents, 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}`);
}
// ---------------------------------------------------------------- 两种实例各挂在哪
{
const { window, $, calls, scrollEvents, fireScroll } = boot();
const wall = window.utils.infiniteScroll('#images-grid', { root: 'window', url: '/wall' });
const album = window.utils.infiniteScroll('#album-switch-scroll', { url: '/albums' });
// 建实例时会立刻拉第一页;等它落地(props.loading 复位),后面的「滚动触发」才作数
await sleep(0);
check('window 版(图片墙,root:\'window\')把监听挂在 window 上',
scrollEvents(window).some((h) => h.namespace === 'infiniteScroll'),
`window scroll 监听 ${scrollEvents(window).length} 条`);
check('容器版(相册弹窗列表)的监听挂在自己身上,window 上没有多挂',
scrollEvents(window).length === 1
&& scrollEvents($('#album-switch-scroll').get(0)).some((h) => h.namespace === 'infiniteScroll'),
`window=${scrollEvents(window).length} container=${scrollEvents($('#album-switch-scroll').get(0)).length}`);
// 两个实例各在建的时候立刻拉了一次第一页
const afterCreate = calls.gets.length;
check('两个实例各自都先拉了第一页(各 1 个请求)', afterCreate === 2, JSON.stringify(calls.gets));
await sleep(0);
fireScroll(window);
check('window 版:整页滚到底触发加载', calls.gets.length === afterCreate + 1,
`gets=${calls.gets.length}(期望 ${afterCreate + 1})`);
}
// ---------------------------------------------------------------- 核心回归
console.log('\n[回归] 销毁容器版实例不能连带解绑 window 版(相册弹窗开→关之后图片墙还能自动加载)');
{
const { window, $, calls, scrollEvents, fireScroll } = boot();
const wall = window.utils.infiniteScroll('#images-grid', { root: 'window', url: '/wall' });
const album = window.utils.infiniteScroll('#album-switch-scroll', { url: '/albums' });
await sleep(0); // 等首屏那次请求落地(否则 props.loading 还是 true,滚动不会触发)
const before = calls.gets.length;
album.destroy(); // ← 相册弹窗关闭时走的就是这一步(closeAlbums() → albumsInfinite.destroy())
check('销毁容器版后:window 上的 scroll.infiniteScroll 监听仍在(没被顺手 off 掉)',
scrollEvents(window).some((h) => h.namespace === 'infiniteScroll'),
`window scroll 监听 ${scrollEvents(window).length} 条`);
fireScroll(window);
check('销毁容器版后:整页滚到底仍然会自动加载(图片墙没死)',
calls.gets.length === before + 1, `gets=${calls.gets.length}(期望 ${before + 1})`);
check('销毁容器版后:它自己的 selector 仍被解绑(滚容器不再触发它)',
! scrollEvents($('#album-switch-scroll').get(0)).some((h) => h.namespace === 'infiniteScroll'));
const afterContainerScroll = calls.gets.length;
fireScroll($('#album-switch-scroll').get(0));
check('销毁容器版后:滚它自己的容器不再发请求',
calls.gets.length === afterContainerScroll, `gets=${calls.gets.length}`);
}
// ---------------------------------------------------------------- 反向:销毁 window 版
console.log('\n[反向] 销毁 window 版时确实解绑 window(别改成谁都不解)');
{
const { window, $, calls, scrollEvents, fireScroll } = boot();
const wall = window.utils.infiniteScroll('#images-grid', { root: 'window', url: '/wall' });
const album = window.utils.infiniteScroll('#album-switch-scroll', { url: '/albums' });
await sleep(0);
wall.destroy();
check('销毁 window 版:window 上的 scroll.infiniteScroll 被解绑',
scrollEvents(window).length === 0, JSON.stringify(scrollEvents(window).map((h) => h.namespace)));
const before = calls.gets.length;
fireScroll(window);
check('销毁 window 版:整页滚动不再触发它加载(不多发请求)',
calls.gets.length === before, `gets=${calls.gets.length}(期望 ${before})`);
fireScroll($('#album-switch-scroll').get(0));
check('销毁 window 版:容器版实例不受影响(滚容器照常加载)',
calls.gets.length === before + 1, `gets=${calls.gets.length}(期望 ${before + 1})`);
}
// ---------------------------------------------------------------- 汇总
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);