feat: add unified learning workflows and six-tab assistant
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
/* Browser plugin not available. 独立六栏面板:本机 HTTP + Chrome,不访问业务站点。 */
|
||||
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_TABS_QA_DIR || path.join(os.tmpdir(), 'yianxue-tabs-qa');
|
||||
fs.mkdirSync(qaDir, { recursive: true });
|
||||
|
||||
async function main() {
|
||||
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}</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-six-tabs');
|
||||
assert.equal(page.url(), origin + '/panel-six-tabs');
|
||||
assert.equal(await page.title(), '谊安学六栏面板验证');
|
||||
await page.addScriptTag({ content: panelSource });
|
||||
await page.evaluate(() => {
|
||||
window.qaCalls = { actions: [], settings: [], logs: [], search: [], auto: [], copy: [], export: [], exportEnabled: [], courses: [], imports: [], positions: [] };
|
||||
window.qaPanel = YianAssistantPanel.create({
|
||||
onTaskAction: action => qaCalls.actions.push(action),
|
||||
onLearningSetting: (key, value) => qaCalls.settings.push([key, JSON.parse(JSON.stringify(value))]),
|
||||
onLogAction: action => qaCalls.logs.push(action),
|
||||
onSearch: (query, settings) => qaCalls.search.push({ query, settings }),
|
||||
onAutoChange: value => qaCalls.auto.push(value),
|
||||
onCopyChange: value => qaCalls.copy.push(value),
|
||||
onExport: format => qaCalls.export.push(format),
|
||||
onExportEnabledChange: value => qaCalls.exportEnabled.push(value),
|
||||
onCourseChange: value => qaCalls.courses.push(value),
|
||||
onImport: files => qaCalls.imports.push(files.map(file => file.name)),
|
||||
onPositionChange: position => qaCalls.positions.push(position)
|
||||
});
|
||||
qaPanel.setState({ expanded: true, canAnswer: true, canExport: true, bankTotal: 5520, uniqueTotal: 1810, conflictTotal: 1,
|
||||
courses: ['安全领导力', '风险管理'], pageMode: 'test', pageStatus: '考试已就绪,按答案全文匹配。',
|
||||
metrics: { selected: 1, missing: 1, details: [{ number: 1, status: 'selected', question: '正确答案文字必须准确。', course: '安全领导力', correctTexts: ['技术控制'], currentLabels: ['D'] }, { number: 2, status: 'missing', reason: '题库未匹配' }] },
|
||||
learning: { status: 'running', mode: 'all', execution: 'sequential', total: 9, completed: 2, skipped: 1, waiting: 1, failed: 0, etaSeconds: 362,
|
||||
message: '当前等待考试核验,视频正常学习。', currentTitle: '1-1安全生产方针和目标',
|
||||
tasks: [{ id: 'v1', type: 'video', title: '1-1安全生产方针和目标', status: 'playing' }, { id: 'e1', type: 'exam', title: '安全考试', status: 'waitingHuman', message: '预计得分 80 分,等待人工' }],
|
||||
video: { title: '1-1安全生产方针和目标', status: 'playing', currentTime: 45, duration: 633, learnedTime: 27, actualRate: 1.5, refreshCount: 1 },
|
||||
exam: { title: '安全考试', status: 'waitingHuman', predictedScore: 80, remainingSeconds: 1000, elapsedSeconds: 361, preflightMessage: '已关联安全领导力', message: '正好 80 分,等待人工操作。' },
|
||||
logs: [{ time: '10:00:00', level: 'info', message: '已识别学习任务' }, { time: '10:00:04', level: 'warning', message: '预计 80 分,等待人工' }, { time: '10:00:05', level: 'error', message: '模拟恢复失败' }]
|
||||
} });
|
||||
});
|
||||
const host = page.locator('#yian-question-assistant');
|
||||
const tab = id => host.getByTestId('tab-' + id);
|
||||
const pane = id => host.getByTestId('pane-' + id);
|
||||
async function screenshot(name) { await page.screenshot({ path: path.join(qaDir, name), fullPage: false }); }
|
||||
async function visible() {
|
||||
await page.waitForFunction(() => {
|
||||
const box = qaPanel.host.getBoundingClientRect();
|
||||
return box.width > 0 && box.height > 0 && box.left >= -1 && box.top >= -1 && box.right <= innerWidth + 1 && box.bottom <= innerHeight + 1;
|
||||
});
|
||||
}
|
||||
await visible();
|
||||
assert.equal(await host.getByRole('tab').count(), 6);
|
||||
assert.equal(await tab('overview').getAttribute('aria-selected'), 'true');
|
||||
assert(await pane('overview').isVisible());
|
||||
assert.equal(await host.getByRole('radio').count(), 0, '统一题库不再有岗位选择');
|
||||
assert(await host.getByTestId('learning-status').textContent().then(text => text.includes('等待人工')));
|
||||
assert.equal(await host.getByTestId('setting-mode').inputValue(), 'all');
|
||||
assert.equal(await host.getByTestId('setting-execution').inputValue(), 'sequential');
|
||||
await screenshot('01-desktop-overview.png');
|
||||
console.log('PASS six accessible tabs, unified bank, persistent status/progress and desktop layout');
|
||||
|
||||
const callsBeforeNavigation = await page.evaluate(() => JSON.stringify(qaCalls));
|
||||
for (const key of ['video', 'exam', 'bank', 'console', 'settings', 'overview']) {
|
||||
await tab(key).click();
|
||||
assert.equal(await tab(key).getAttribute('aria-selected'), 'true');
|
||||
assert(await pane(key).isVisible());
|
||||
assert(await host.getByTestId('global-pause').isVisible());
|
||||
assert.equal(await host.getByRole('tabpanel').filter({ visible: true }).count(), 1);
|
||||
}
|
||||
assert.equal(await page.evaluate(() => JSON.stringify(qaCalls)), callsBeforeNavigation, '切栏不能修改设置、答题、导出或启动任务');
|
||||
await tab('overview').focus(); await tab('overview').press('ArrowRight');
|
||||
assert.equal(await tab('video').getAttribute('aria-selected'), 'true');
|
||||
await tab('video').press('End'); assert.equal(await tab('settings').getAttribute('aria-selected'), 'true');
|
||||
await tab('settings').press('Home'); assert.equal(await tab('overview').getAttribute('aria-selected'), 'true');
|
||||
console.log('PASS navigation changes visibility only, supports keyboard and keeps global pause visible');
|
||||
|
||||
await tab('bank').click();
|
||||
await host.getByRole('textbox', { name: '搜索题目' }).fill('安全控制');
|
||||
await host.getByRole('textbox', { name: '搜索题目' }).press('Enter');
|
||||
assert.equal(await page.evaluate(() => qaCalls.search.at(-1).query), '安全控制');
|
||||
await host.getByRole('combobox', { name: '选择课程' }).selectOption('安全领导力');
|
||||
assert.equal(await page.evaluate(() => qaCalls.courses.at(-1)), '安全领导力');
|
||||
await page.evaluate(() => qaPanel.renderSearch({ total: 1, items: [{ type: 'single', question: '安全生产方针是什么?', options: [{ label: 'A', text: '安全第一' }, { label: 'B', text: '效率第一' }], answer: 'A', sources: [{ role: '主操', course: '安全领导力', file: '原题库.csv' }] }] }));
|
||||
assert.equal(await host.locator('.result').count(), 1);
|
||||
assert(await host.locator('.answer').textContent().then(text => text.includes('安全第一')));
|
||||
await tab('console').click(); await tab('bank').click();
|
||||
assert.equal(await host.getByRole('textbox', { name: '搜索题目' }).inputValue(), '安全控制');
|
||||
assert.equal(await host.getByRole('combobox', { name: '选择课程' }).inputValue(), '安全领导力');
|
||||
assert.equal(await host.locator('.result').count(), 1);
|
||||
await host.locator('input[type=file]').setInputFiles({ name: '统一题库.csv', mimeType: 'text/csv', buffer: Buffer.from('题目,答案\n题目,A') });
|
||||
assert.deepEqual(await page.evaluate(() => qaCalls.imports), [['统一题库.csv']]);
|
||||
await host.getByText('课程与考试主题关联', { exact: true }).click();
|
||||
await host.getByRole('textbox', { name: '关联题库课程' }).fill('安全领导力\n风险管理');
|
||||
await host.getByRole('button', { name: '保存关联', exact: true }).click();
|
||||
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['associations', { '1-1安全生产方针和目标': ['安全领导力', '风险管理'] }]);
|
||||
await host.getByRole('button', { name: '删除关联', exact: true }).click();
|
||||
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['associations', {}]);
|
||||
console.log('PASS search/import without role, persistent query/filter/results and exact theme associations');
|
||||
|
||||
await tab('exam').click();
|
||||
assert.equal(await host.getByTestId('setting-answerInterval').inputValue(), '4');
|
||||
assert.equal(await host.getByTestId('setting-minSubmitMinutes').inputValue(), '6');
|
||||
assert(await pane('exam').textContent().then(text => text.includes('80 分(题库估计)')));
|
||||
await host.getByTestId('setting-answerInterval').fill('5'); await host.getByTestId('setting-answerInterval').press('Tab');
|
||||
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['answerInterval', 5]);
|
||||
await host.getByRole('switch', { name: '自动选择答案', exact: true }).check();
|
||||
assert.deepEqual(await page.evaluate(() => qaCalls.auto), [true]);
|
||||
await host.locator('.answer-check summary').click();
|
||||
assert(await host.locator('.answer-check-options').textContent().then(text => text.includes('D. 技术控制')));
|
||||
await tab('overview').click(); await page.evaluate(() => qaPanel.setState({ learning: { etaSeconds: 359 } })); await tab('exam').click();
|
||||
assert(await host.getByRole('switch', { name: '自动选择答案', exact: true }).isChecked());
|
||||
assert(await host.locator('.answer-check').getAttribute('open') !== null);
|
||||
assert.equal(await host.getByTestId('setting-answerInterval').inputValue(), '5');
|
||||
await host.getByTestId('task-examResume').click();
|
||||
assert.equal(await page.evaluate(() => qaCalls.actions.at(-1)), 'examResume');
|
||||
await screenshot('02-desktop-exam.png');
|
||||
console.log('PASS exam defaults/score/full answer evidence, partial learning updates and persistent switch/details state');
|
||||
|
||||
await tab('video').click();
|
||||
await host.getByTestId('setting-rate').selectOption('2');
|
||||
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['rate', 2]);
|
||||
await host.getByTestId('task-videoPause').click();
|
||||
assert.equal(await page.evaluate(() => qaCalls.actions.at(-1)), 'videoPause');
|
||||
assert(await pane('video').textContent().then(text => text.includes('00:27') && text.includes('尚未确认')));
|
||||
await tab('settings').click();
|
||||
assert(!await host.locator('.export-content').isVisible());
|
||||
await host.getByRole('switch', { name: '允许复制文字', exact: true }).uncheck();
|
||||
assert.deepEqual(await page.evaluate(() => qaCalls.copy), [false]);
|
||||
await host.getByRole('switch', { name: '导出题库', exact: true }).check();
|
||||
assert(await host.locator('.export-content').isVisible());
|
||||
await host.getByRole('button', { name: '导出 JSON', exact: true }).click();
|
||||
assert.deepEqual(await page.evaluate(() => qaCalls.export), ['json']);
|
||||
await host.getByTestId('setting-maxRefresh').fill('2'); await host.getByTestId('setting-maxRefresh').press('Tab');
|
||||
assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['maxRefresh', 2]);
|
||||
await tab('bank').click(); await tab('settings').click();
|
||||
assert.deepEqual(await page.evaluate(() => qaCalls.export), ['json'], '切栏不会再次导出');
|
||||
assert.equal(await host.getByTestId('setting-maxRefresh').inputValue(), '2');
|
||||
console.log('PASS video rate/local pause, copy and gated folded export, configurable bounded recovery');
|
||||
|
||||
await tab('console').click();
|
||||
await host.getByRole('combobox', { name: '日志级别' }).selectOption('warning');
|
||||
assert.equal(await host.locator('.log-entry').count(), 1);
|
||||
await host.getByRole('button', { name: '复制日志', exact: true }).click();
|
||||
await host.getByRole('button', { name: '清空显示', exact: true }).click();
|
||||
assert.deepEqual(await page.evaluate(() => qaCalls.logs), ['copy', 'clear']);
|
||||
await page.evaluate(() => qaPanel.setState({ learning: { logs: Array.from({ length: 510 }, (_, index) => ({ time: '10:00:00', level: 'info', message: '任务状态 ' + index })) } }));
|
||||
await host.getByRole('combobox', { name: '日志级别' }).selectOption('all');
|
||||
assert.equal(await host.locator('.log-entry').count(), 500);
|
||||
await host.getByTestId('global-pause').click();
|
||||
assert.equal(await page.evaluate(() => qaCalls.actions.at(-1)), 'pause');
|
||||
await page.evaluate(() => qaPanel.setState({ learning: { status: 'paused' } }));
|
||||
assert.equal(await host.getByTestId('global-pause').textContent(), '继续全部');
|
||||
await host.getByTestId('global-pause').click();
|
||||
assert.equal(await page.evaluate(() => qaCalls.actions.at(-1)), 'resume');
|
||||
console.log('PASS filtered/capped logs and shared global pause/resume');
|
||||
|
||||
await tab('overview').click();
|
||||
await page.evaluate(() => qaPanel.setState({ position: { left: 100, top: 20 }, learning: { logs: [], status: 'running' } }));
|
||||
await visible();
|
||||
const box = await host.boundingBox(); const brand = await host.locator('.brand').boundingBox();
|
||||
await page.mouse.move(brand.x + 20, brand.y + brand.height / 2); await page.mouse.down();
|
||||
await page.mouse.move(brand.x + 55, brand.y + brand.height / 2 + 20, { steps: 5 }); await page.mouse.up();
|
||||
assert.equal(await host.locator('.toggle').getAttribute('aria-expanded'), 'true');
|
||||
assert.equal(await page.evaluate(() => qaCalls.positions.length), 1);
|
||||
const moved = await host.boundingBox(); assert(Math.abs(moved.x - box.x - 35) <= 2);
|
||||
await page.setViewportSize({ width: 390, height: 844 }); await visible();
|
||||
assert.equal(await host.locator('.navigation').evaluate(element => getComputedStyle(element).flexDirection), 'row');
|
||||
await tab('bank').click(); await screenshot('03-mobile-bank.png');
|
||||
await page.setViewportSize({ width: 320, height: 480 }); await visible();
|
||||
await tab('overview').click(); await screenshot('04-small-overview.png');
|
||||
await host.locator('.toggle').click();
|
||||
assert.equal(await host.locator('.toggle').getAttribute('aria-expanded'), 'false');
|
||||
assert(await host.locator('.collapse-summary').isVisible()); await visible();
|
||||
await host.locator('.toggle').click(); await visible();
|
||||
assert.equal(await tab('overview').getAttribute('aria-selected'), 'true');
|
||||
assert.deepEqual(errors, []);
|
||||
assert.equal(await page.locator('vite-error-overlay, nextjs-portal, #webpack-dev-server-client-overlay').count(), 0);
|
||||
console.log('PASS desktop drag, mobile horizontal tabs, small viewport clamp, collapse summary and no console/runtime errors');
|
||||
console.log('截图:' + qaDir);
|
||||
} finally { if (browser) await browser.close(); await new Promise(resolve => server.close(resolve)); }
|
||||
}
|
||||
main().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
Reference in New Issue
Block a user