/* 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('助手拖动验证

题库助手拖动验证

拖动标题栏调整位置。面板内的岗位、搜索和开关保持正常操作。

'); }); 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; });