Files
yianxue-assistant/tests/panel-drag.cjs
T

254 lines
16 KiB
JavaScript

/* Browser plugin not available. 独立面板拖动回归:本机 HTTP、真实 Chrome 鼠标及 CDP 触屏输入。 */
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const http = require('node:http');
const { chromium } = require('playwright');
const workspace = path.resolve(__dirname, '..');
const panelSource = fs.readFileSync(path.join(workspace, 'assistant-panel.js'), 'utf8');
const qaDir = process.env.YIAN_DRAG_QA_DIR || path.join(os.tmpdir(), 'yianxue-drag-qa');
fs.mkdirSync(qaDir, { recursive: true });
function near(actual, expected, message) { assert(Math.abs(actual - expected) <= 2, `${message}: ${actual} != ${expected}`); }
async function main() {
// URL 无练习路径,证明拖动功能由独立面板模块负责。
const server = http.createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' });
response.end('<!doctype html><meta charset="utf-8"><title>助手拖动验证</title><style>body{margin:0;background:#edf2f8;color:#203451;font:17px system-ui}main{padding:40px}p{color:#65758c;max-width:650px}</style><main><h1>题库助手拖动验证</h1><p>拖动标题栏调整位置。面板内的岗位、搜索和开关保持正常操作。</p></main>');
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
let browser;
try {
browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
const errors = [];
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') errors.push(message.text()); });
const origin = `http://127.0.0.1:${server.address().port}`;
await page.goto(origin + '/panel-only');
assert.equal(await page.title(), '助手拖动验证');
assert.equal(page.url(), origin + '/panel-only');
await page.addScriptTag({ content: panelSource });
await page.evaluate(() => {
window.qaCalls = { positions: [], auto: [], copy: [], searches: [] };
window.qaBase = { role: '主操', bankTotal: 60, roleCounts: { 主操: 20, 班长: 20, 副操: 20 }, courses: ['安全课程'], canAnswer: true, canExport: true, pageMode: 'exercise', pageStatus: '已识别练习,可以选择答案。', storageMode: 'userscript' };
window.qaMakePanel = partial => {
window.qaPanel?.destroy();
window.qaPanel = YianAssistantPanel.create({
onPositionChange: value => window.qaCalls.positions.push({ left: value.left, top: value.top }),
onAutoChange: value => window.qaCalls.auto.push(value),
onCopyChange: value => window.qaCalls.copy.push(value),
onSearch: (query, settings) => window.qaCalls.searches.push({ query, settings })
});
window.qaPanel.setState(Object.assign({}, window.qaBase, partial || {}));
};
window.qaMakePanel({ expanded: false });
});
const host = page.locator('#yian-question-assistant');
const brand = host.locator('.brand');
const toggle = host.locator('.toggle');
async function rect() {
return page.evaluate(() => {
const box = window.qaPanel.host.getBoundingClientRect();
return { left: box.left, top: box.top, width: box.width, height: box.height, right: box.right, bottom: box.bottom, viewportWidth: innerWidth, viewportHeight: innerHeight };
});
}
async function visible() {
await page.waitForFunction(() => {
const panel = window.qaPanel.host;
const box = panel.getBoundingClientRect();
const header = window.qaPanel.root.querySelector('.header').getBoundingClientRect();
return box.width > 0 && box.height > 0 && box.left >= -1 && box.top >= -1 && box.right <= innerWidth + 1 && box.bottom <= innerHeight + 1 && header.height > 0;
});
return rect();
}
async function positionSaves() { return page.evaluate(() => window.qaCalls.positions.length); }
async function isExpanded() { return toggle.getAttribute('aria-expanded'); }
async function startMouse(locator) {
const box = await locator.boundingBox();
assert(box, '拖动目标必须可见');
const point = { x: box.x + Math.min(box.width / 2, 50), y: box.y + box.height / 2 };
await page.mouse.move(point.x, point.y);
await page.mouse.down();
return point;
}
async function dragTo(left, top) {
const before = await rect();
const point = await startMouse(brand);
await page.mouse.move(point.x + left - before.left, point.y + top - before.top, { steps: 8 });
await page.mouse.up();
return visible();
}
async function screenshot(filename) { await page.screenshot({ path: path.join(qaDir, filename), fullPage: false }); }
const initial = await visible();
near(initial.viewportWidth - initial.right, 16, '默认右侧间距');
near(initial.viewportHeight - initial.bottom, 16, '默认底部间距');
assert.equal(await isExpanded(), 'false');
assert((await host.textContent()).trim().length > 0 || await brand.textContent(), '面板必须呈现内容');
await screenshot('01-desktop-default.png');
console.log('PASS independent module loads collapsed at bottom right');
const oldPosition = { left: Math.round(initial.left), top: Math.round(initial.top) };
const beforeDragSaves = await positionSaves();
const point = await startMouse(brand);
await page.mouse.move(point.x + 220 - initial.left, point.y + 60 - initial.top, { steps: 10 });
const duringDrag = await rect();
near(duringDrag.left, 220, '折叠面板水平拖动');
near(duringDrag.top, 60, '折叠面板垂直拖动');
assert.equal(await positionSaves(), beforeDragSaves, 'pointermove 不应持续保存位置');
await page.evaluate(position => window.qaPanel.setState({ position, bankTotal: 61 }), oldPosition);
const afterRefresh = await rect();
near(afterRefresh.left, duringDrag.left, '拖中旧 state 不回跳');
near(afterRefresh.top, duringDrag.top, '拖中旧 state 不回跳');
assert.equal(await positionSaves(), beforeDragSaves);
await page.mouse.up();
assert.equal(await positionSaves(), beforeDragSaves + 1, 'mouseup 应保存一次');
const dragged = await visible();
const saved = await page.evaluate(() => window.qaCalls.positions.at(-1));
near(saved.left, dragged.left, '保存的 left');
near(saved.top, dragged.top, '保存的 top');
assert.equal(await isExpanded(), 'false', '真实拖动产生的 click 不能展开面板');
await page.evaluate(position => window.qaPanel.setState({ position }), oldPosition);
near((await rect()).left, dragged.left, '拖后相同旧 state 不回跳');
assert.deepEqual(await page.evaluate(() => [qaCalls.auto, qaCalls.copy]), [[], []]);
await screenshot('02-desktop-collapsed-dragged.png');
console.log('PASS real collapsed drag saves only on end and ignores stale position refresh');
await brand.click();
assert.equal(await isExpanded(), 'true', '普通品牌点击应展开');
await visible();
await toggle.click();
assert.equal(await isExpanded(), 'false', '普通 toggle 点击应折叠');
const tinySaves = await positionSaves();
const tinyPoint = await startMouse(brand);
await page.mouse.move(tinyPoint.x + 2, tinyPoint.y + 1);
await page.mouse.up();
assert.equal(await isExpanded(), 'true', '小于 4px 移动仍是普通点击');
assert.equal(await positionSaves(), tinySaves, '微小移动不能被保存为拖动');
await visible();
console.log('PASS brand/toggle clicks work and movement below threshold remains a click');
const beforeExpandedDrag = await positionSaves();
const expanded = await dragTo(80, 20);
near(expanded.left, 80, '展开面板 left');
near(expanded.top, 20, '展开面板 top');
assert.equal(await positionSaves(), beforeExpandedDrag + 1);
assert.equal(await isExpanded(), 'true', '展开面板拖动不能误折叠');
const beforeToggleDrag = await rect();
const toggleSaves = await positionSaves();
const togglePoint = await startMouse(toggle);
await page.mouse.move(togglePoint.x + 120, togglePoint.y + 30, { steps: 5 });
await page.mouse.up();
near((await rect()).left, beforeToggleDrag.left, 'toggle 控件不能拖动面板');
near((await rect()).top, beforeToggleDrag.top, 'toggle 控件不能拖动面板');
assert.equal(await positionSaves(), toggleSaves);
assert.equal(await isExpanded(), 'true');
assert.deepEqual(await page.evaluate(() => [qaCalls.auto, qaCalls.copy]), [[], []]);
await screenshot('03-desktop-expanded-dragged.png');
console.log('PASS real expanded drag suppresses brand click; header controls do not drag');
const beforeControls = await rect();
const controlsSaves = await positionSaves();
await host.getByRole('switch', { name: '自动选择答案', exact: true }).click();
await host.getByRole('switch', { name: '允许复制文字', exact: true }).click();
await host.getByRole('textbox', { name: '搜索题目' }).fill('安全控制');
await host.getByRole('textbox', { name: '搜索题目' }).press('Enter');
assert.deepEqual(await page.evaluate(() => [qaCalls.auto, qaCalls.copy]), [[true], [false]]);
assert.equal(await page.evaluate(() => qaCalls.searches.at(-1).query), '安全控制');
near((await rect()).left, beforeControls.left, '开关、搜索不移动面板');
near((await rect()).top, beforeControls.top, '开关、搜索不移动面板');
assert.equal(await positionSaves(), controlsSaves);
const restoredPosition = await page.evaluate(() => window.qaCalls.positions.at(-1));
const restoreSaves = await positionSaves();
await page.evaluate(position => window.qaMakePanel({ expanded: true, position }), restoredPosition);
const restored = await visible();
near(restored.left, restoredPosition.left, '恢复 left');
near(restored.top, restoredPosition.top, '恢复 top');
assert.equal(await positionSaves(), restoreSaves, '合法位置恢复不应额外保存');
console.log('PASS controls/search remain usable and saved position restores in a new panel');
await page.evaluate(() => window.qaPanel.setState({ position: { left: 10000, top: -10000 } }));
const clamped = await visible();
assert(clamped.top >= 0 && clamped.right <= clamped.viewportWidth + 1, '超出视口的恢复位置应钳制');
await page.setViewportSize({ width: 390, height: 844 });
await visible();
await page.evaluate(() => window.qaPanel.renderSearch({ total: 20, items: Array.from({ length: 20 }, (_, index) => ({ number: index + 1, type: '单选', question: '检索结果内容变高后仍应在视口内。'.repeat(4), options: [{ label: 'A', text: '安全控制' }, { label: 'B', text: '其他选项' }], answer: 'A', role: '主操', course: '安全课程' })) }));
const grown = await visible();
assert(grown.height <= grown.viewportHeight + 1);
await screenshot('04-mobile-expanded-results.png');
await toggle.click();
assert.equal(await isExpanded(), 'false');
await dragTo(280, 800);
await toggle.click();
assert.equal(await isExpanded(), 'true');
await visible();
await page.setViewportSize({ width: 320, height: 480 });
const small = await visible();
assert(small.width <= 320 && small.height <= 480);
await screenshot('05-small-viewport.png');
console.log('PASS offscreen restore, content growth, fold/unfold and viewport resize remain visible');
await page.setViewportSize({ width: 1280, height: 900 });
await page.evaluate(() => window.qaPanel.setState({ expanded: true, position: { left: 120, top: 15 } }));
await visible();
const cancelPoint = await startMouse(brand);
await page.mouse.move(cancelPoint.x + 35, cancelPoint.y + 25, { steps: 4 });
const canceledAt = await rect();
await page.evaluate(() => window.qaPanel.root.querySelector('.header').dispatchEvent(new PointerEvent('pointercancel', { pointerId: 1, pointerType: 'mouse', button: 0, bubbles: true, composed: true, cancelable: true })));
const cancelSaves = await positionSaves();
await page.mouse.move(cancelPoint.x + 160, cancelPoint.y + 100, { steps: 4 });
await page.mouse.up();
near((await rect()).left, canceledAt.left, 'pointercancel 后不能继续拖');
near((await rect()).top, canceledAt.top, 'pointercancel 后不能继续拖');
assert.equal(await positionSaves(), cancelSaves, '取消后旧 pointerup 不能再次保存');
assert.equal(await page.evaluate(() => qaPanel.root.querySelector('.panel').classList.contains('dragging')), false);
assert.equal(await page.evaluate(() => { const event = new Event('selectstart', { bubbles: true, cancelable: true }); document.body.dispatchEvent(event); return event.defaultPrevented; }), false);
console.log('PASS pointercancel removes capture/global drag state and selection blocking');
assert.equal(await page.evaluate(() => getComputedStyle(qaPanel.root.querySelector('.header')).touchAction), 'none');
const cdp = await page.context().newCDPSession(page);
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
const touchBefore = await rect();
const touchBox = await brand.boundingBox();
const touchPoint = { x: Math.round(touchBox.x + 35), y: Math.round(touchBox.y + touchBox.height / 2) };
const touchSaves = await positionSaves();
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [touchPoint] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: touchPoint.x + 45, y: touchPoint.y + 35 }] });
assert.equal(await positionSaves(), touchSaves, '触屏移动不能持续保存');
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const touchAfter = await visible();
near(touchAfter.left, Math.max(0, Math.min(touchBefore.left + 45, touchBefore.viewportWidth - touchBefore.width)), 'CDP 真实触屏水平拖动');
near(touchAfter.top, Math.max(0, Math.min(touchBefore.top + 35, touchBefore.viewportHeight - touchBefore.height)), 'CDP 真实触屏垂直拖动及边界钳制');
assert.equal(await positionSaves(), touchSaves + 1);
assert.equal(await isExpanded(), 'true');
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: false });
await cdp.detach();
console.log('PASS touch-action is none and CDP touch drag saves once without folding');
const destroyPoint = await startMouse(brand);
await page.mouse.move(destroyPoint.x + 20, destroyPoint.y + 15);
assert.equal(await page.evaluate(() => { const event = new Event('selectstart', { bubbles: true, cancelable: true }); document.body.dispatchEvent(event); return event.defaultPrevented; }), true);
const destroySaves = await positionSaves();
await page.evaluate(() => window.qaPanel.destroy());
await page.mouse.move(destroyPoint.x + 80, destroyPoint.y + 60);
await page.mouse.up();
await page.setViewportSize({ width: 900, height: 650 });
assert.equal(await page.locator('#yian-question-assistant').count(), 0);
assert.equal(await positionSaves(), destroySaves, 'destroy 后不能保留回调或 resize 保存');
assert.equal(await page.evaluate(() => { const event = new Event('selectstart', { bubbles: true, cancelable: true }); document.body.dispatchEvent(event); return event.defaultPrevented; }), false);
assert.deepEqual(errors, []);
console.log('PASS destroy during dragging cleans listeners and observer; console remains clean');
console.log('Screenshots: ' + qaDir);
} finally {
if (browser) await browser.close();
await new Promise(resolve => server.close(resolve));
}
}
main().catch(error => { console.error(error); process.exitCode = 1; });