feat: add unified learning workflows and six-tab assistant

This commit is contained in:
薇星幻海
2026-10-18 01:11:33 +08:00
parent 20abb33740
commit eb9c610b9f
32 changed files with 23806 additions and 353 deletions
+27 -14
View File
@@ -47,6 +47,11 @@ function documentHTML(practice) {
document.addEventListener('click',function(event){const n=event.target.closest('.pc_examtest_content_list_des');if(n){const box=n.closest('.pc_examtest_content');if(box.dataset.showanswer==='true')return;const label=n.querySelector('.examtest_content_list_des_optionsort').textContent;window.optionClicks.push(box.dataset.number+':'+label);if(box.dataset.kind==='多选')n.classList.toggle('examtest_content_list_des_checked');else{box.querySelectorAll('.pc_examtest_content_list_des').forEach(x=>x.classList.remove('examtest_content_list_des_checked'));n.classList.add('examtest_content_list_des_checked');box.dataset.showanswer='true';box.querySelector('.pc_examtest_content_question_showanswer').remove();box.querySelectorAll('.pc_examtest_content_list_child1').forEach(x=>x.className='pc_examtest_content_list_child2');}}if(event.target.closest('.pc_examtest_content_question_showanswer'))window.parseClicks++;if(event.target.id==='submit')window.submitClicks++;});
</script></body>`;
}
async function activate(page, key) {
const expand = page.getByRole('button', { name: '展开题库助手', exact: true });
if (await expand.isVisible()) await expand.click();
await page.getByTestId('tab-' + key).click();
}
(async () => {
const memory = new Map([['yianxue.assistant.setting.v2.autoExport', true], ['yianxue.assistant.setting.v2.exportEnabled', true]]);
@@ -116,53 +121,58 @@ function documentHTML(practice) {
const startURL = origin + '/clientcn/main?from=assistant-test';
await page.goto(startURL); await page.addScriptTag({ content: sandboxScript });
await page.evaluate(() => YianAssistant.ready);
await page.evaluate(() => YianAssistant.setLearningSetting('answerInterval', 0));
assert.equal(page.url(), startURL); assert.equal(await page.title(), '主站主页验证');
assert(await page.getByRole('heading', { name: '主站主页的题库搜索' }).isVisible());
await page.getByRole('button', { name: '展开题库助手' }).click();
assert(await page.locator('#yian-question-assistant .brand').textContent() === '谊安学助手 2.0');
await activate(page, 'settings');
assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false);
await activate(page, 'exam');
assert(await page.getByRole('switch', { name: '自动选择答案' }).isDisabled());
assert.equal(await page.evaluate(() => typeof window.GM_getValue), 'undefined');
console.log('PASS 主站 /clientcn/main 查询参数下显示搜索面板,非练习页禁止自动选择,沙箱 API 兼容');
const input = await bankInput();
await activate(page, 'bank');
await page.locator('#yian-question-assistant input[type=file]').setInputFiles(input.file);
await page.waitForFunction(total => YianAssistant.getSnapshot().total === total, input.total);
assert.deepEqual(await page.evaluate(() => YianAssistant.getSnapshot().counts), input.counts);
assert.deepEqual(await page.evaluate(() => YianAssistant.getSnapshot().counts), Object.assign({ 未标明: 0 }, input.counts));
assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().storage), 'userscript');
await page.getByRole('radio', { name: '主操', exact: true }).check();
assert.equal(await page.getByRole('radio').count(), 0, '统一题库已取消岗位选择');
await page.getByRole('textbox', { name: '搜索题目' }).fill('工程控制措施');
await page.getByRole('button', { name: '搜索', exact: true }).click();
assert(await page.locator('#yian-question-assistant .result .conflict').count() > 0);
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('主操'));
console.log('PASS ' + (input.real ? '实际ZIP导入5520条' : '合成ZIP导入90条') + '、岗位数量与冲突来源显示');
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('统一题库'));
console.log('PASS ' + (input.real ? '实际ZIP导入5520条' : '合成ZIP导入90条') + '、旧岗位来源数量与统一冲突显示');
// 未点击搜索按钮的新输入,也应在换岗位时立即检索。
// 三种岗位的同题聚合为统一结果,切栏保留尚未提交的关键词。
const query = '广西华谊新材料有限公司涉及几种高危工艺';
await page.getByRole('textbox', { name: '搜索题目' }).fill(query);
await page.getByRole('radio', { name: '副操', exact: true }).check();
await activate(page, 'console'); await activate(page, 'bank');
assert.equal(await page.getByRole('textbox', { name: '搜索题目' }).inputValue(), query);
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('副操'));
await page.getByRole('button', { name: '搜索', exact: true }).click();
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('统一题库'));
assert(await page.locator('#yian-question-assistant .result').count() > 0);
assert((await page.locator('#yian-question-assistant .sources').first().innerText()).includes('副操'));
const unifiedSources = await page.locator('#yian-question-assistant .sources').first().innerText();
for (const source of ['主操', '班长', '副操']) assert(unifiedSources.includes(source), '统一结果应保留来源:' + source);
await page.getByRole('button', { name: '复制题目与答案' }).first().click();
assert(clipboard.includes('正确答案:')); assert(clipboard.includes('高危工艺'));
await page.getByRole('radio', { name: '班长', exact: true }).check();
assert((await page.locator('#yian-question-assistant .results-title').innerText()).includes('班长'));
await page.getByRole('radio', { name: '主操', exact: true }).check();
await page.getByRole('textbox', { name: '搜索题目' }).fill('作业');
await page.getByRole('button', { name: '搜索', exact: true }).click();
assert.equal(await page.locator('#yian-question-assistant .result').count(), 20);
await page.getByRole('button', { name: '下一页', exact: true }).click();
assert((await page.locator('#yian-question-assistant .pagination').innerText()).includes('21–'));
assert(await page.locator('#yian-question-assistant .result').count() > 0);
console.log('PASS 三岗位隔离、换岗位保留当前关键词、结果复制与20条分页');
console.log('PASS 跨岗位来源统一检索、切栏保留关键词、结果复制与20条分页');
const restrictions = () => page.evaluate(() => ({ select: document.body.getAttribute('onselectstart'), context: document.body.getAttribute('oncontextmenu'), blocked: !document.body.dispatchEvent(new Event('selectstart', { bubbles: true, cancelable: true })), style: getComputedStyle(document.querySelector('#copy-target')).userSelect }));
assert.deepEqual(await restrictions(), { select: null, context: null, blocked: false, style: 'text' });
await activate(page, 'settings');
await page.getByRole('switch', { name: '允许复制文字' }).uncheck();
assert.deepEqual(await restrictions(), { select: 'return false', context: 'return false', blocked: true, style: 'none' });
await page.getByRole('switch', { name: '允许复制文字' }).check();
await activate(page, 'bank');
await page.evaluate(() => { const range = document.createRange(); range.selectNodeContents(document.getElementById('copy-target')); const selection = getSelection(); selection.removeAllRanges(); selection.addRange(range); });
await page.getByRole('button', { name: '用选中文字搜索', exact: true }).click();
assert.equal(await page.getByRole('textbox', { name: '搜索题目' }).inputValue(), query);
@@ -178,10 +188,13 @@ function documentHTML(practice) {
const second = await context.newPage(); capture(second);
const secondURL = origin + '/clientcn/exam/nested/fixture/questions?flag=exercise';
await second.goto(secondURL); await second.addScriptTag({ content: sandboxScript }); await second.evaluate(() => YianAssistant.ready);
await second.evaluate(() => YianAssistant.setLearningSetting('answerInterval', 0));
await second.waitForFunction(() => YianAssistant.getState().pageReady);
await activate(second, 'settings');
assert.equal(await second.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false, '旧版导出偏好不能开启新版开关');
assert.equal(await second.evaluate(() => YianAssistant.getSnapshot().total), input.total);
assert.equal(await second.evaluate(() => YianAssistant.getState().role), '主操');
assert.equal(await second.evaluate(() => YianAssistant.getState().role), '');
await activate(second, 'exam');
assert.equal(await second.getByRole('switch', { name: '自动选择答案' }).isChecked(), false);
await second.getByRole('switch', { name: '自动选择答案' }).check();
await second.waitForFunction(() => YianAssistant.getState().metrics.selected === 3);
@@ -193,7 +206,7 @@ function documentHTML(practice) {
await second.getByRole('switch', { name: '自动选择答案' }).check();
await second.waitForTimeout(500); assert.equal(await second.evaluate(() => window.optionClicks.length), 5);
await second.getByRole('switch', { name: '自动选择答案' }).uncheck();
console.log('PASS 同站不同 /clientcn 路径题库岗位共用,实际选项点击、换序、多选差集与无重复');
console.log('PASS 同站不同 /clientcn 路径统一题库共用,实际选项点击、换序、多选差集与无重复');
await second.evaluate(html => { history.pushState({}, '', '/clientcn/exam/another/random-route?flag=exercise'); const doc = new DOMParser().parseFromString(html, 'text/html'); document.querySelector('app-examtest').replaceWith(doc.querySelector('app-examtest')); window.optionClicks = []; }, practiceHTML());
await second.waitForFunction(() => YianAssistant.getContext().ready);
+12 -8
View File
@@ -103,7 +103,7 @@ function csv(rows) {
const sameStemDifferentAnswers = [semanticRecords[0], store.validateRecord({ ...semanticRecords[0], course: '另一课程', answer: 'B', correctTexts: undefined })];
assert.equal(matcher.match(pressurePage, { role: '主操' }, { records: sameStemDifferentAnswers, revision: 1 }).status, 'conflict', '不同大小写的正确文本应保留为答案冲突');
// Existing v2 data retains its source texts; recompute stale loose keys in memory.
// Existing v2 data retains its source texts and backup while upgrading to v3.
const oldMemory = new Map([['yianxue.assistant.bank.v2', {
records: semanticRecords.map(item => ({ ...item, questionKey: 'old-loose-key', optionKey: 'old-loose-options', id: 'old-id' })),
revision: 7, updatedAt: '2026-10-10T00:00:00.000Z'
@@ -118,7 +118,9 @@ function csv(rows) {
assert.notEqual(item.id, 'old-id');
});
assert.notEqual(upgraded.records[0].questionKey, upgraded.records[1].questionKey);
assert.equal(oldMemory.size, 1, '不更换稳定存储键或清空旧库');
assert.equal(oldMemory.size, 2, '写入新的 v3 键并保留原始 v2 备份');
assert.equal(oldMemory.get('yianxue.assistant.bank.v2').records[0].id, 'old-id', '升级不应改写原始 v2 数据');
assert.equal(upgraded.schemaVersion, 3);
const escaped = record({ options: [{ label: 'A', text: "'-50" }, { label: 'B', text: "'引用" }, { label: 'C', text: "''-50" }] });
let state = await store.importFiles([file('风险管理_题库_1题.csv', csv([escaped]))], { role: '主操' });
@@ -137,7 +139,9 @@ function csv(rows) {
const revision = state.revision;
await assert.rejects(store.importFiles([file('坏.csv', csv([record({ answer: 'H' })]))], { role: '主操' }), /不存在的选项/);
assert.equal((await store.load()).revision, revision, '无效导入应保持原题库不变');
await assert.rejects(store.importFiles([file('没有岗位.csv', csv([record()]))]), /选择主操/);
const roleFree = await runtime().store.importFiles([file('没有岗位.csv', csv([record()]))]);
assert.equal(roleFree.total, 1, '无岗位的旧导出可直接导入统一题库');
assert.equal(roleFree.counts.未标明, 1);
await assert.rejects(store.importFiles([file('坏.json', JSON.stringify([record({ correctTexts: ['作业甲'] })]))]), /不一致/);
await assert.rejects(store.importFiles([file('空.json', '{"records":[]}')]), /没有题目/);
await store.setSetting('role', '班长');
@@ -156,7 +160,7 @@ function csv(rows) {
assert.equal(matcher.match({ ...q, question: q.question.replace('不应', '应') }, { role: '主操' }, state).status, 'missing');
assert.equal(matcher.match({ ...q, options: q.options.slice(0, 2) }, { role: '主操' }, state).status, 'missing');
assert.equal(matcher.match({ ...q, type: '多选' }, { role: '主操' }, state).status, 'missing');
assert.equal(matcher.match(q, {}, state).status, 'unsupported');
assert.equal(matcher.match(q, {}, state).status, 'matched');
assert.equal(matcher.match({ ...q, questionImages: ['image.png'] }, { role: '主操' }, state).status, 'unsupported');
assert.equal(matcher.match({ ...q, options: [q.options[0], { label: 'B', text: q.options[0].text }] }, { role: '主操' }, state).status, 'unsupported');
assert.deepEqual(Array.from(matcher.match({ ...q, options: q.options.map(option => ({ ...option, label: option.label + '.' })) }, { role: '主操' }, state).correctLabels), ['A']);
@@ -171,13 +175,13 @@ function csv(rows) {
assert.equal(answer.status, 'matched'); assert.equal(answer.records.length, 1);
answer = matcher.match(q, { role: '主操', course: '重大危险源' }, groupedState);
assert.equal(answer.status, 'matched'); assert.deepEqual(Array.from(answer.correctLabels), ['C']);
assert.equal(matcher.search('温度', {}, groupedState).total, 0);
assert.equal(matcher.search('温度', {}, groupedState).total, 2);
assert.equal(matcher.search('', { role: '主操' }, groupedState).total, 0);
let found = matcher.search('温度 -50.5 作业乙', { role: '主操' }, groupedState);
assert.equal(found.total, 2, '同题同选项同答案合并,冲突答案分别展示');
assert(found.items.every(item => item.conflict));
assert(found.items.some(item => item.sources.length === 2));
assert.equal(matcher.search('温度', { role: '班长' }, groupedState).total, 1);
assert(found.items.some(item => item.sources.length === 3));
assert.equal(matcher.search('温度', { role: '班长' }, groupedState).total, 2, '遗留岗位设置不应过滤统一搜索');
assert.equal(matcher.search('温度', { role: '主操', course: '风险管理' }, groupedState).items[0].conflict, false);
assert.equal(matcher.search('温度不存在', { role: '主操' }, groupedState).total, 0);
found = matcher.search('温度', { role: '主操', limit: 1, offset: 0 }, groupedState);
@@ -216,7 +220,7 @@ function csv(rows) {
await assert.rejects(store.importFiles([file('损坏.zip', await invalidZip.generateAsync({ type: 'nodebuffer' }))]), /不存在的选项/);
assert.equal((await store.load()).total, 1);
await assert.rejects(store.importFiles([file('题库.zip', await zip.generateAsync({ type: 'nodebuffer' })), file('一份.csv', csv([record()]))]), /只选择一个/);
console.log('PASS 严格语义匹配与宽松搜索分离、原答案证据、旧库重算键、本地存储和原子导入、岗位隔离、换序/冲突/搜索分页');
console.log('PASS 严格语义匹配与宽松搜索分离、原答案证据、旧库安全升级、本地存储和原子导入、统一题库、换序/冲突/搜索分页');
const realZip = path.join(rootDir, '题库.zip');
if (fs.existsSync(realZip)) {
+37 -8
View File
@@ -48,6 +48,11 @@ function documentHTML(fixture) {
body{font:14px/1.6 system-ui,"Microsoft YaHei",sans-serif;margin:24px;background:#f3f6fa;color:#23334c}h1{font-size:24px;margin:0 0 12px}.pc_examtest{display:grid;grid-template-columns:minmax(0,1fr) 205px;gap:20px;max-width:1120px}.pc_examtest_left{min-width:0}.pc_examtest_right{background:white;padding:15px;align-self:start;border-radius:9px}.pc_examtest_title{font-size:20px;margin-bottom:12px}.pc_examtest_content{background:white;padding:14px 18px;margin-bottom:9px;border:1px solid #e1e7ef;border-radius:8px;overflow-wrap:anywhere}.pc_examtest_content_question p{margin:0 0 8px}.pc_examtest_content_question_value,.pc_examtest_content_question_difficulty{color:#8792a3;font-size:11px}.pc_examtest_content_list_des{padding:6px 9px;cursor:pointer;border-radius:5px}.examtest_content_list_des_optionsort{display:inline-block;margin-right:8px}.examtest_content_list_des_checked{background:#e3f5e9;color:#246240}.hidden,[hidden]{display:none!important}.modal:not(.show):not(.in){display:none}.examine_box.show{position:fixed;z-index:1000;inset:130px auto auto 160px;background:white;border:1px solid #ccd6e2;padding:24px;border-radius:10px}.pc_examtest_countdown{margin-bottom:15px}.pc_examtest_card{display:flex;flex-wrap:wrap;gap:5px;margin-top:12px}.examtestcard_count_defult{border:1px solid #d7dee7;border-radius:4px;padding:2px 5px;font-size:11px}.examtestcard_count_assignment{background:#e4f4e9;color:#296343}.pc_examtest_commit{margin-top:14px;background:#285fd3;color:white;padding:8px;border-radius:6px;text-align:center;cursor:pointer}.pc_examtest_right_col{display:flex;gap:15px}.pc_examtest_right_col h4{margin:4px 0}.pc_examtest_content img{max-width:100%}@media(max-width:700px){body{margin:12px}.pc_examtest{display:block}.pc_examtest_right{display:none}.pc_examtest_content{padding:12px}}
</style></head><body onselectstart="return false" oncontextmenu="return false"><h1>考试答案全文核对</h1><p>本地验证:40 道保存题目,交卷由用户操作。</p><app-examtest>${fixture.html}</app-examtest></body></html>`;
}
async function activate(page, key) {
const expand = page.getByRole('button', { name: '展开题库助手', exact: true });
if (await expand.isVisible()) await expand.click();
await page.getByTestId('tab-' + key).click();
}
// 模拟题目组件自己的作答状态。点击先更新独立模型,再根据模型渲染 class 与答题卡。
// 不加载原站 Angular,也不实现提交接口。
@@ -152,9 +157,12 @@ function installExamModel() {
await page.addScriptTag({ content: sandboxScript });
await page.evaluate(installExamModel);
await page.evaluate(() => YianAssistant.ready);
await page.evaluate(() => YianAssistant.setLearningSetting('answerInterval', 0));
await activate(page, 'exam');
}
await open(page, examURL);
assert.equal(await page.getByRole('switch', { name: '自动选择答案' }).isChecked(), false);
await activate(page, 'settings');
assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false);
assert.equal(await page.locator('#yian-question-assistant .section-title').filter({ hasText: '当前考试' }).count(), 1);
const extracted = await page.evaluate(() => YianAssistant.collectCurrent().questions);
@@ -164,6 +172,7 @@ function installExamModel() {
assert(!/(?:难度低|难度中|难度高)/.test(question.question), '题干应去除难度');
});
const bankFiles = realSample ? bankPaths : { name: '三岗位考试题库.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(fixture.records)) };
await activate(page, 'bank');
await page.locator('#yian-question-assistant input[type=file]').setInputFiles(bankFiles);
await page.waitForFunction(() => YianAssistant.getSnapshot().counts.主操 > 0 && YianAssistant.getSnapshot().counts.班长 > 0 && YianAssistant.getSnapshot().counts.副操 > 0);
console.log('PASS ' + (realSample ? '真实保存考试 DOM 与仓库三岗位 CSV 导入' : '可独立运行的 40 题 / 三岗位内部 fixture') + '、考试识别、默认关闭、分值与难度去除');
@@ -171,9 +180,16 @@ function installExamModel() {
const pages = [page, await context.newPage(), await context.newPage()];
pages.slice(1).forEach(capture);
await Promise.all(pages.map(async (target, index) => {
if (index) await open(target, examURL + '&rolecase=' + index);
await target.getByRole('radio', { name: roles[index], exact: true }).check();
if (index) await open(target, examURL + '&sessioncase=' + index);
await target.evaluate(async () => {
const title = YianAssistant.collectCurrent().title;
const courses = Array.from(new Set(YianAssistant.getSnapshot().records.map(record => record.course))).filter(Boolean);
await YianAssistant.setLearningSetting('associations', { [title]: courses });
});
await activate(target, 'exam');
assert.equal(await target.getByRole('radio').count(), 0, '统一题库没有岗位选择');
await target.waitForFunction(() => YianAssistant.getContext().ready);
assert((await target.evaluate(() => YianAssistant.getContext().courses || [])).length > 0, '明确关联后须确认可用题库主题,再启动答题');
const expectations = await target.evaluate(() => {
const context = YianAssistant.getContext(), snapshot = YianAssistant.getSnapshot();
return YianAssistant.collectCurrent().questions.map(question => {
@@ -181,14 +197,24 @@ function installExamModel() {
return { number: question.number, type: question.type, status: matched.status, reason: matched.reason,
originalAnswer: matched.records[0] && matched.records[0].answer,
bankTexts: matched.records[0] && matched.records[0].correctTexts,
currentLabels: matched.correctLabels, evidenceTexts: matched.correctTexts };
currentLabels: matched.correctLabels, evidenceTexts: matched.correctTexts,
sourceRoles: Array.from(new Set((matched.records || []).map(record => record.role))).sort() };
});
});
assert.equal(expectations.filter(item => item.status === 'matched').length, 40, roles[index] + '应匹配 40 / 40;' + JSON.stringify(expectations.filter(item => item.status !== 'matched')));
assert.equal(expectations.filter(item => item.status === 'matched').length, 40, '统一题库应匹配 40 / 40;' + JSON.stringify(expectations.filter(item => item.status !== 'matched')));
expectations.forEach(item => assert.deepEqual(item.sourceRoles, roles.slice().sort(), '同题聚合保留三个岗位来源'));
assert.equal(expectations.filter(item => item.currentLabels.join('') !== item.originalAnswer).length, 20, '应有 20 题需要按全文重新映射字母');
target.__expectations = expectations;
await target.getByRole('switch', { name: '自动选择答案' }).check();
await target.waitForFunction(() => YianAssistant.getState().metrics.selected === 40, undefined, { timeout: 35000 });
await target.waitForFunction(() => YianAssistant.getState().metrics.selected === 40, undefined, { timeout: 35000 }).catch(async error => {
console.error('考试超时诊断:' + JSON.stringify(await target.evaluate(() => {
const state = YianAssistant.getState(), context = YianAssistant.getContext();
return { autoAnswer: state.autoAnswer, metrics: state.metrics, examStage: state.exam.stage, examMessage: state.exam.message,
ready: context.ready, courses: context.courses, clicks: examModel.clicks.length,
notice: document.querySelector('#yian-question-assistant').shadowRoot.querySelector('.notice').textContent };
})));
throw error;
});
const actual = await target.evaluate(() => {
const bank = YianAssistant.collectCurrent();
const rows = Array.from(document.querySelectorAll('.pc_examtest_content'));
@@ -204,12 +230,12 @@ function installExamModel() {
assert.equal(actual.cards, 40, '独立模型应更新全部答题卡');
assert.equal(actual.evidence.length, 40);
expectations.forEach((expected, qIndex) => {
assert.deepEqual(sortedTexts(actual.selectedTexts[qIndex]), sortedTexts(expected.bankTexts), roles[index] + '第 ' + expected.number + ' 题 DOM 应等于题库全文');
assert.deepEqual(sortedTexts(actual.modelTexts[qIndex]), sortedTexts(expected.bankTexts), roles[index] + '第 ' + expected.number + ' 题独立作答模型应等于题库全文');
assert.deepEqual(sortedTexts(actual.selectedTexts[qIndex]), sortedTexts(expected.bankTexts), '会话 ' + (index + 1) + ' 第 ' + expected.number + ' 题 DOM 应等于题库全文');
assert.deepEqual(sortedTexts(actual.modelTexts[qIndex]), sortedTexts(expected.bankTexts), '会话 ' + (index + 1) + ' 第 ' + expected.number + ' 题独立作答模型应等于题库全文');
expected.evidenceTexts.forEach((text, optionIndex) => assert(normalize(actual.evidence[qIndex]).includes(normalize(expected.currentLabels[optionIndex] + '. ' + text)), '核对面板应显示原库全文与对应当前字母'));
});
await target.getByRole('switch', { name: '自动选择答案' }).uncheck();
console.log('PASS ' + roles[index] + ':40 / 40 已选择,20 题字母变化;所有 DOM、独立模型及核对面板均与原题库正确答案全文一致');
console.log('PASS 统一题库会话 ' + (index + 1) + ':40 / 40 已选择,20 题字母变化;三岗位来源聚合,所有 DOM、独立模型及核对面板均与正确答案全文一致');
}));
await Promise.all(pages.slice(1).map(target => target.close()));
const evidenceDetails = page.locator('#yian-question-assistant .answer-check');
@@ -224,10 +250,12 @@ function installExamModel() {
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), '小屏页面不得横向溢出');
await page.screenshot({ path: path.join(qaDir, 'exam-mobile.png') });
await page.setViewportSize({ width: 1366, height: 900 });
await activate(page, 'settings');
await page.evaluate(() => YianAssistant.setExportEnabled(true));
assert.equal(await page.getByRole('button', { name: '导出 CSV', exact: true }).isDisabled(), true);
assert.equal(await page.evaluate(() => { try { YianAssistant.exportCurrent('csv'); return ''; } catch (error) { return error.message; } }), '请先进入支持的练习页面');
await page.evaluate(() => YianAssistant.setExportEnabled(false));
await activate(page, 'exam');
console.log('PASS 答案核对展开状态 / 节点复用、桌面与小屏截图、考试禁用导出');
let sequence = 0;
@@ -246,6 +274,7 @@ function installExamModel() {
await page.evaluate(installExamModel);
if (waitReady) await page.waitForFunction(() => YianAssistant.getContext().ready);
else await page.waitForTimeout(1600);
await activate(page, 'exam');
}
await reset();
await page.getByRole('switch', { name: '自动选择答案' }).check();
+362
View File
@@ -0,0 +1,362 @@
/* Real DOM exam evidence and submit-state tests. No business scripts or requests. */
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const rootDir = path.resolve(__dirname, '..');
const modules = ['question-bank-core.js', 'bank-store.js', 'answer-matcher.js', 'answer-runner.js', 'exam-controller.js'];
function paperHTML(total = 25) {
const questions = Array.from({ length: total }, (_, i) => `<div app-exam-question><div class="pc_examtest_content" data-number="${i + 1}">
<div class="pc_examtest_content_question"><p>[单选] ${i + 1}. 安全核对题${i + 1}应该怎样操作?<span class="pc_examtest_content_question_value">(${100 / total}分)</span></p><div class="pc_examtest_content_question_difficulty">难度低</div></div>
<div class="pc_examtest_content_list"><div class="pc_examtest_content_list_des examtest_content_list_des_checked" data-label="A"><span class="examtest_content_list_des_optionsort">A</span>核对措施${i + 1}</div><div class="pc_examtest_content_list_des" data-label="B"><span class="examtest_content_list_des_optionsort">B</span>任意操作${i + 1}</div></div>
</div></div>`).join('');
return `<!doctype html><html><head><meta charset="utf-8"><style>body{font:14px system-ui;margin:12px}.pc_examtest_content{padding:3px}.pc_examtest_content_question p{margin:0}.pc_examtest_content_list_des{padding:2px;cursor:pointer}.pc_examtest_content_list_des_checked{background:#ddf2e3}.pc_examtest_commit{padding:12px;background:#ddd;cursor:pointer}.dialog_box{position:fixed;inset:20px auto auto 20px;background:white;border:1px solid;padding:12px}[aria-hidden="true"]{display:none!important}</style></head><body><app-examtest><div class="pc_examtest_title">安全核对考试</div>${questions}<div class="pc_examtest_countdown"><span class="time">00:30:00</span></div><div class="pc_examtest_card">${Array.from({ length: total }, (_, i) => `<div class="examtestcard_count_defult">${i + 1}</div>`).join('')}</div><button class="pc_examtest_commit">交卷</button></app-examtest></body></html>`;
}
(async () => {
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
const errors = [], requests = [];
try {
const context = await browser.newContext({ viewport: { width: 1000, height: 800 } });
await context.route('**/*', route => { requests.push(route.request().url()); return route.abort(); });
const page = await context.newPage();
page.on('pageerror', error => errors.push(error.message));
for (const name of modules) await page.addScriptTag({ content: fs.readFileSync(path.join(rootDir, name), 'utf8') });
async function setup({ matched = 25, unknownPoint = false, confirm = false, showResult = false, total = 25 } = {}) {
await page.evaluate(({ html, matched, unknownPoint, confirm, showResult }) => {
window.qaExam?.destroy(); window.qaRunner?.destroy();
document.documentElement.innerHTML = html.replace(/^<!doctype html>/, '').replace(/^<html>/, '').replace(/<\/html>$/, '');
window.qaClock = { now: 0 };
window.qaSetClock = value => {
qaClock.now = value;
const remaining = Math.max(0, 1800 - Math.floor(value / 1000));
document.querySelector('.pc_examtest_countdown .time').textContent = [Math.floor(remaining / 3600), Math.floor(remaining / 60) % 60, remaining % 60].map(part => String(part).padStart(2, '0')).join(':');
};
window.qaStorage = new Map(); window.qaLogs = []; window.qaStates = []; window.qaStorageHook = null; window.qaReadGate = null; window.qaStorageReads = 0;
window.qaModel = { commitClicks: 0, confirmClicks: 0, optionClicks: [], confirm, showResult };
window.qaContext = { mode: 'test', exam: true, practice: false, ready: true, local: false,
sourceUrl: 'http://127.0.0.1/clientcn/exam/qa-paper?flag=test', course: '安全核对考试', revision: 1,
root: document.querySelector('app-examtest'), routeKey: 'qa-paper', pageSignature: 'qa-stable', topicKey: 'qa-theme' };
window.qaTopic = { status: 'matched', courses: ['安全知识题库'] };
const extracted = QuestionBankCore.extract(document);
window.qaSnapshot = { revision: 1, records: extracted.questions.slice(0, matched).map(question => YianBankStore.validateRecord({
...question, role: '', course: '安全知识题库', sourceFile: '测试题库.json', answer: 'A', correctTexts: undefined
})) };
window.qaRunnerState = 'complete';
window.qaSettings = { minSubmitMinutes: 6 };
if (unknownPoint) document.querySelector('.pc_examtest_content_question_value').textContent = '分值待定';
const boxes = Array.from(document.querySelectorAll('.pc_examtest_content'));
boxes.forEach((box, index) => box.querySelectorAll('.pc_examtest_content_list_des').forEach(node => node.addEventListener('click', () => {
box.querySelectorAll('.pc_examtest_content_list_des').forEach(option => option.classList.remove('examtest_content_list_des_checked'));
node.classList.add('examtest_content_list_des_checked');
qaModel.optionClicks.push({ number: index + 1, label: node.dataset.label });
})));
document.querySelector('.pc_examtest_commit').addEventListener('click', () => {
qaModel.commitClicks++;
if (!qaModel.confirm) return;
const dialog = document.createElement('div'); dialog.className = 'dialog_box';
dialog.innerHTML = '<div role="dialog"><p>还有未答题,是否确认交卷?</p><button class="qa-cancel">取消</button><button class="qa-confirm">确认交卷</button></div>';
document.body.appendChild(dialog);
dialog.querySelector('.qa-confirm').addEventListener('click', () => {
qaModel.confirmClicks++;
dialog.remove();
if (qaModel.showResult) {
const result = document.createElement('div'); result.className = 'exam_submited_box'; result.textContent = '交卷成功,考试成绩:84分'; document.body.appendChild(result);
}
});
});
window.qaMakeController = () => YianExamController.create({
document, now: () => qaClock.now,
storage: { get: async (key, fallback) => { qaStorageReads++; if (qaReadGate) await qaReadGate; return qaStorage.has(key) ? structuredClone(qaStorage.get(key)) : fallback; },
set: async (key, value) => { if (qaStorageHook) await qaStorageHook(key, value); qaStorage.set(key, structuredClone(value)); } },
getContext: () => qaContext, getTopic: () => qaTopic, getSnapshot: () => qaSnapshot,
getSettings: () => qaSettings, getRunnerState: () => qaRunnerState,
onState: state => qaStates.push(state), onLog: (level, message) => qaLogs.push({ level, message })
});
window.qaExam = qaMakeController();
}, { html: paperHTML(total), matched, unknownPoint, confirm, showResult });
return page.evaluate(async () => { await qaExam.observe(); const enabled = await qaExam.enable(); await qaExam.tick(); return { enabled, ...qaExam.getState() }; });
}
async function tickAt(seconds) {
return page.evaluate(async value => { qaSetClock(value * 1000); await qaExam.tick(); return { state: qaExam.getState(), model: qaModel }; }, seconds);
}
let initial = await setup({ matched: 20 });
assert.equal(initial.total, 25);
assert.equal(initial.totalScore, 100, '按实际 DOM 分值计算 25×4=100');
assert.equal(initial.scoreValid, true);
assert.equal(initial.predictedScore, 80);
assert.equal(initial.matchedCount, 20);
assert.equal(initial.missing, 5);
assert.equal(initial.waitSeconds, 360, 'now 从 0 开始且无 totaltime 参数可记录首次入卷');
let result = await tickAt(360);
assert.equal(result.model.commitClicks, 0, '正好 80 分不得自动交卷');
assert.equal(result.state.stage, 'waiting');
assert.match(result.state.message, /小于或等于 80/);
initial = await setup({ matched: 21 });
assert.equal(initial.predictedScore, 84);
assert.equal(initial.verifiedCount, 21);
assert.equal(initial.missing, 4, '部分题目缺失仍可用已核验题目的实际分值计算');
result = await tickAt(240);
assert.equal(result.model.commitClicks, 0, '84 分但只满 4 分钟不得提交');
assert.equal(result.state.waitSeconds, 120);
assert.equal(result.state.stage, 'waitingSubmit');
result = await tickAt(360);
assert.equal(result.model.commitClicks, 1, '84 分且满 6 分钟点击一次真实交卷入口');
await tickAt(365);
assert.equal((await tickAt(372)).model.commitClicks, 1, '提交后等待结果时不得重复点击');
result = await tickAt(373);
assert.equal(result.state.stage, 'unknown');
assert.equal(result.state.enabled, false);
assert.equal(result.model.commitClicks, 1);
assert.equal((await tickAt(600)).model.commitClicks, 1, '超过 12 秒未确认后仍禁止重复交卷');
assert.equal(await page.evaluate(() => qaExam.enable()), false, '未知提交状态不得通过重复启动绕过');
console.log('PASS 实际分值与部分命中、80 分等待、84 分 4/6 分钟门槛、未知结果有界停止并禁止重复提交');
initial = await setup({ matched: 25, unknownPoint: true });
assert.equal(initial.scoreValid, false);
assert.match(initial.reason, /分值/);
assert.equal((await tickAt(600)).model.commitClicks, 0, '分值不明,即使命中全部题目也不得提交');
await setup({ matched: 21 });
await page.evaluate(() => { document.querySelector('.pc_examtest_content_list_des').classList.remove('examtest_content_list_des_checked'); });
result = await tickAt(360);
assert.equal(result.state.verifiedCount, 20);
assert.equal(result.state.predictedScore, 80);
assert.equal(result.model.commitClicks, 0, '匹配记录存在但实际选中内容未回读核验时不计分');
console.log('PASS 分值未知与实际选中证据变化均阻止自动交卷');
await setup({ matched: 21 });
await page.locator('.pc_examtest_content').first().locator('[data-label="B"]').click();
result = await tickAt(360);
assert.equal(result.state.enabled, false);
assert.equal(result.state.stage, 'paused');
assert.match(result.state.message, /人工/);
assert.equal(result.model.commitClicks, 0, '可信人工点击立即取消交卷计划');
await setup({ matched: 21 });
await page.evaluate(() => { qaContext.revision = 2; qaSnapshot.revision = 2; });
result = await tickAt(360);
assert.equal(result.state.enabled, false);
assert.equal(result.model.commitClicks, 0, '题库 revision 更新使旧授权失效');
assert.match(result.state.message, /题库|变更/);
console.log('PASS 人工作答取消与题库 revision 变化取消旧交卷授权');
await setup({ matched: 21, confirm: true, showResult: true });
result = await tickAt(360);
assert.equal(result.model.commitClicks, 1);
assert.equal(result.model.confirmClicks, 0);
result = await tickAt(361);
assert.equal(result.model.confirmClicks, 1, '单个真实确认按钮只点击一次');
result = await tickAt(362);
assert.equal(result.state.stage, 'submitted');
assert.equal(result.state.submitted, true, '只有网站结果 DOM 才确认提交成功');
assert.equal(result.state.enabled, false);
assert.equal((await tickAt(363)).model.confirmClicks, 1);
console.log('PASS 交卷确认弹窗及网站成功结果,不从点击动作推断成功');
await setup({ matched: 21 });
await tickAt(240);
const restored = await page.evaluate(async () => {
qaExam.destroy(); qaExam = qaMakeController();
await qaExam.observe(); await qaExam.tick();
return qaExam.getState();
});
assert.equal(restored.elapsedSeconds, 240);
assert.equal(restored.waitSeconds, 120, '同场重建 controller 复用原始 startedAt=0,计时不归零');
assert.equal(restored.enabled, false, '刷新恢复仍需明确启动');
await page.evaluate(() => qaExam.enable());
assert.equal((await tickAt(360)).model.commitClicks, 1, '同场恢复后仍按原始入卷 6 分钟交卷');
await setup({ matched: 21 });
await page.evaluate(() => {
qaExam.destroy(); qaStorage.clear(); qaClock.now = 240000;
qaContext.sourceUrl += '&totaltime=30';
document.querySelector('.pc_examtest_countdown .time').textContent = '00:26:00';
qaExam = qaMakeController();
});
const inferred = await page.evaluate(async () => { await qaExam.observe(); await qaExam.tick(); return qaExam.getState(); });
assert.equal(inferred.elapsedSeconds, 240, '首次注入可由明确考试时长和剩余时间反推入卷时间');
assert.equal(inferred.waitSeconds, 120);
console.log('PASS 原始入卷时间持久化、0 时间值和明确倒计时反推');
for (const mutation of ['ready', 'route', 'revision', 'answers', 'points', 'topic', 'pause']) {
await setup({ matched: 21 });
const blocked = await page.evaluate(async mutation => {
qaSetClock(360000);
qaStorageHook = async (_key, value) => {
if (value.stage !== 'submitting') return;
if (mutation === 'ready') qaContext.ready = false;
if (mutation === 'route') qaContext.sourceUrl = 'http://127.0.0.1/clientcn/exam/another?flag=test';
if (mutation === 'revision') { qaContext.revision++; qaSnapshot.revision++; }
if (mutation === 'answers') document.querySelector('.pc_examtest_content_list_des').classList.remove('examtest_content_list_des_checked');
if (mutation === 'points') {
const nodes = document.querySelectorAll('.pc_examtest_content_question_value');
nodes[0].textContent = '(6分)'; nodes[21].textContent = '(2分)';
}
if (mutation === 'topic') qaTopic = { status: 'matched', courses: ['其他主题'] };
if (mutation === 'pause') qaExam.disable('用户已暂停');
};
await qaExam.tick(); return { model: qaModel, state: qaExam.getState() };
}, mutation);
assert.equal(blocked.model.commitClicks, 0, '异步保存期间发生 ' + mutation + ' 时禁止旧提交点击');
assert.equal(blocked.state.enabled, false);
}
for (const mutation of ['revision', 'duplicateButton', 'pause']) {
await setup({ matched: 21, confirm: true });
await tickAt(360);
const blocked = await page.evaluate(async mutation => {
qaStorageHook = async (_key, value) => {
if (value.stage !== 'confirming') return;
if (mutation === 'revision') { qaContext.revision++; qaSnapshot.revision++; }
if (mutation === 'duplicateButton') { const button = document.createElement('button'); button.textContent = '确认交卷'; document.querySelector('.dialog_box').append(button); }
if (mutation === 'pause') qaExam.disable('用户已暂停');
};
await qaExam.tick(); return { model: qaModel, state: qaExam.getState() };
}, mutation);
assert.equal(blocked.model.confirmClicks, 0, '二次确认保存期间发生 ' + mutation + ' 时禁止旧确认点击');
assert.equal(blocked.state.enabled, false);
}
console.log('PASS 最后点击前复核:异步保存期间页面、题库、选中集合、主题、暂停及确认入口变化均阻止旧动作');
await setup({ matched: 21 });
const interruptedRead = await page.evaluate(async () => {
qaExam.destroy(); qaStorageReads = 0;
let release; qaReadGate = new Promise(resolve => { release = resolve; });
qaExam = qaMakeController();
const first = qaExam.observe(), second = qaExam.observe();
const shared = first === second;
const enabling = qaExam.enable();
qaExam.disable('用户暂停'); release();
const returned = await enabling; await Promise.all([first, second]); qaReadGate = null;
return { returned, shared, reads: qaStorageReads, state: qaExam.getState() };
});
assert.equal(interruptedRead.shared, true, '同场并行 observe 共用一个进行中的 Promise');
assert.equal(interruptedRead.reads, 1);
assert.equal(interruptedRead.returned, false);
assert.equal(interruptedRead.state.enabled, false, 'storage.get 返回后不得重新启用已暂停的考试');
await setup({ matched: 21 });
const interruptedWrite = await page.evaluate(async () => {
let release; const gate = new Promise(resolve => { release = resolve; });
qaStorageHook = async (_key, value) => { if (value.stage === 'answering') { qaExam.disable('用户停止'); await gate; } };
const enabling = qaExam.enable();
release(); return { returned: await enabling, state: qaExam.getState() };
});
assert.equal(interruptedWrite.returned, false);
assert.equal(interruptedWrite.state.enabled, false);
console.log('PASS observe 共享异步读取及 enable 在读写期间被暂停后不复活');
await setup({ matched: 21 });
const retake = await page.evaluate(async () => {
qaExam.destroy();
const key = Array.from(qaStorage.keys())[0], saved = qaStorage.get(key);
qaStorage.set(key, { ...saved, startedAt: -1200000, stage: 'submitted' });
qaClock.now = 60000; qaContext.sourceUrl += '&totaltime=30';
document.querySelector('.pc_examtest_countdown .time').textContent = '00:29:00';
qaExam = qaMakeController(); await qaExam.observe(); await qaExam.tick();
const before = qaExam.getState();
const accepted = await qaExam.enable(); await qaExam.tick();
return { before, accepted, after: qaExam.getState(), model: qaModel };
});
assert.equal(retake.before.submitted, false, '缓存已交卷不能当作当前场次网站成功');
assert.equal(retake.before.stage, 'awaitingAttempt');
assert.equal(retake.accepted, true, '明确启动可为当前可作答试卷建立新场次');
assert.equal(retake.after.elapsedSeconds, 60, '当前30分钟/剩29分钟证据优先,禁止沿用上一场20分钟前的时间');
assert.equal(retake.after.waitSeconds, 300);
assert.equal(retake.model.commitClicks, 0);
assert.equal((await tickAt(360)).model.commitClicks, 1);
await setup({ matched: 21 });
const missingIdentity = await page.evaluate(async () => {
qaExam.destroy();
const key = Array.from(qaStorage.keys())[0], saved = qaStorage.get(key);
qaStorage.set(key, { ...saved, startedAt: -1200000, stage: 'submitted' });
qaClock.now = 1000000; qaExam = qaMakeController();
await qaExam.observe(); const accepted = await qaExam.enable(); await qaExam.tick();
return { accepted, state: qaExam.getState(), model: qaModel };
});
assert.equal(missingIdentity.accepted, true);
assert.equal(missingIdentity.state.elapsedSeconds, 0, '没有明确时长时,新场次从本次明确启动时开始计时');
assert.equal(missingIdentity.state.waitSeconds, 360);
assert.equal(missingIdentity.model.commitClicks, 0);
await setup({ matched: 21 });
const equalCountdownRetake = await page.evaluate(async () => {
qaExam.destroy(); qaClock.now = 360000;
// Both old checkpoint and new attempt start at 1800. Compare elapsed wall time,
// not merely whether the new raw value exceeds the old raw value.
document.querySelector('.pc_examtest_countdown .time').textContent = '00:30:00';
qaExam = qaMakeController(); await qaExam.observe(); await qaExam.tick();
const before = qaExam.getState();
const accepted = await qaExam.enable(); await qaExam.tick();
return { before, accepted, after: qaExam.getState(), model: qaModel };
});
assert.equal(equalCountdownRetake.before.stage, 'awaitingAttempt');
assert.equal(equalCountdownRetake.accepted, true);
assert.equal(equalCountdownRetake.after.elapsedSeconds, 0, '旧1800秒检查点六分钟后看到新1800秒,不能算本场已过六分钟');
assert.equal(equalCountdownRetake.after.waitSeconds, 360);
assert.equal(equalCountdownRetake.model.commitClicks, 0);
await setup({ matched: 21 });
const noClockEvidence = await page.evaluate(async () => {
qaExam.destroy(); qaClock.now = 360000;
document.querySelector('.pc_examtest_countdown').remove();
qaExam = qaMakeController(); await qaExam.observe(); await qaExam.tick();
const before = qaExam.getState();
const accepted = await qaExam.enable(); await qaExam.tick();
return { before, accepted, after: qaExam.getState(), model: qaModel };
});
assert.equal(noClockEvidence.before.stage, 'awaitingAttempt', '没有倒计时或真实场次ID,不能默复用旧时长');
assert.match(noClockEvidence.before.message, /计时连续性|新场次/);
assert.equal(noClockEvidence.accepted, true);
assert.equal(noClockEvidence.after.elapsedSeconds, 0);
assert.equal(noClockEvidence.after.waitSeconds, 360);
assert.equal(noClockEvidence.model.commitClicks, 0);
await setup({ matched: 21 });
const pendingAttempt = await page.evaluate(async () => {
qaExam.destroy();
const key = Array.from(qaStorage.keys())[0], saved = qaStorage.get(key);
qaStorage.set(key, { ...saved, stage: 'submitting', submitAt: 0 });
qaExam = qaMakeController(); await qaExam.observe();
const blocked = await qaExam.enable(); const stage = qaExam.getState().stage;
qaContext.sourceUrl += '&attemptId=second-real-attempt';
const fresh = await qaExam.enable(); await qaExam.tick();
const queryIsolated = YianExamController.paperKey(qaContext.sourceUrl) !== YianExamController.paperKey(qaContext.sourceUrl.replace('second-real-attempt', 'third-real-attempt'));
const first = YianExamController.paperKey(qaContext.sourceUrl, qaContext.root);
qaContext.root.setAttribute('data-attempt-id', 'server-dom-attempt');
const domIsolated = first !== YianExamController.paperKey(qaContext.sourceUrl, qaContext.root);
return { blocked, stage, fresh, queryIsolated, domIsolated, model: qaModel };
});
assert.equal(pendingAttempt.blocked, false, '未知提交状态禁止按无ID重考自动复用');
assert.equal(pendingAttempt.stage, 'unknown');
assert.equal(pendingAttempt.fresh, true, '真实新 attemptId 使用独立场次');
assert.equal(pendingAttempt.queryIsolated, true);
assert.equal(pendingAttempt.domIsolated, true);
assert.equal(pendingAttempt.model.commitClicks, 0);
console.log('PASS 重考与旧场次隔离:缓存 submitted 不算当前完成、倒计时连续性/无时钟须重确认、未知提交禁止复用及 query/DOM 场次标识');
await setup({ total: 3, matched: 3 });
const interval = await page.evaluate(async () => {
qaExam.destroy();
document.querySelectorAll('.pc_examtest_content_list_des').forEach(node => node.classList.remove('examtest_content_list_des_checked'));
const nativeSetTimeout = window.setTimeout;
const timers = [];
window.setTimeout = (callback, delay, ...args) => { timers.push(delay); return nativeSetTimeout(callback, 0, ...args); };
let runnerState = 'idle';
qaRunner = YianAnswerRunner.create({ getContext: () => qaContext,
match: question => YianAnswerMatcher.match(question, { courses: qaTopic.courses }, qaSnapshot), getInterval: () => 4,
onState: state => { runnerState = state; } });
try { await qaRunner.start(); return { timers, clicks: qaModel.optionClicks.length, state: runnerState }; }
finally { window.setTimeout = nativeSetTimeout; qaRunner.destroy(); }
});
assert.equal(interval.state, 'complete');
assert.equal(interval.clicks, 3, 'runner 通过真实 DOM 点击完成三题');
assert.equal(interval.timers.filter(delay => delay === 4000).length, 2, 'getInterval=4 按相邻题目设置两个 4000ms 间隔');
assert.equal(requests.length, 0, '整组测试不得请求业务站或其他网络资源');
assert.deepEqual(errors, [], '测试过程无浏览器脚本错误');
console.log('PASS 真实 runner 点击与默认 4 秒题间隔;全部测试零网络请求');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+286
View File
@@ -0,0 +1,286 @@
/* 本机隔离浏览器学习调度回归;所有业务地址均被 route 拦截,无真实学习记录。 */
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const ZIP = require('../vendor/jszip.min.js');
const workspace = path.resolve(__dirname, '..');
const source = fs.readFileSync(path.join(workspace, 'learning-engine.js'), 'utf8');
const core = fs.readFileSync(path.join(workspace, 'question-bank-core.js'), 'utf8');
const origin = 'http://10.119.67.7/clientcn/';
const videoHTML = '<app-course><div class="course-courseware-other-top-left">测试课程</div><li class="cn_courseware_img active" data-courseware-id="v1" data-required-seconds="100"><p>视频一</p><div class="cn_courseware_img_icon">视频</div><div class="menu_courseware_left">已学习20秒</div><div class="courseware_finished_status"><img class="en_courseware_finished en_courseware_noFinished"></div></li><app-course-courseware-video><video id="player1"></video></app-course-courseware-video></app-course>';
async function main() {
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const errors = [];
page.on('pageerror', e => errors.push(e.message));
await page.route('**/*', route => route.request().isNavigationRequest() ? route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><meta charset="utf-8"><body></body>' }) : route.abort());
await page.goto(origin + 'course/test');
await page.addScriptTag({ content: core });
await page.addScriptTag({ content: source });
let checks = 0;
try {
async function setup(html = videoHTML, config = {}, settings = {}) {
await page.evaluate(async ({ html, config, settings }) => {
window.qaEngine?.destroy();
history.replaceState(null, '', '/clientcn/course/test');
document.body.innerHTML = html;
window.qaClock = 100000; window.qaMemory = new Map(); window.qaReloads = 0; window.qaNavigations = []; window.qaWorkerURLs = []; window.qaPlayCalls = 0; window.qaPauseCalls = 0; window.qaExamCalls = 0; window.qaQuizCalls = 0;
window.qaVideo = { currentTime: 20, duration: 100, ended: false, paused: true, playbackRate: 1 };
window.qaExam = { stage: 'answering', total: 40, verifiedCount: 0 };
window.qaSnapshot = { document, url: location.href, accountId: 'fixture-account', bankVersion: 1, planId: 'fixture-plan', courses: ['测试课程'] };
window.qaStorage = { get: async key => qaMemory.has(key) ? JSON.parse(JSON.stringify(qaMemory.get(key))) : null, set: async (key, value) => { qaMemory.set(key, JSON.parse(JSON.stringify(value))); } };
window.qaInstallVideo = () => {
const video = document.querySelector('video'); if (!video) return;
for (const key of ['currentTime', 'duration', 'ended', 'paused', 'playbackRate']) Object.defineProperty(video, key, { configurable: true, get: () => qaVideo[key], set: v => { qaVideo[key] = v; } });
video.play = async () => { qaPlayCalls++; if (settings.rejectPlay) throw new Error('autoplay'); qaVideo.paused = false; };
video.pause = () => { qaPauseCalls++; qaVideo.paused = true; };
};
qaInstallVideo();
window.qaCreate = (extra = {}) => YianLearningEngine.create(Object.assign({
getSnapshot: () => Object.assign({}, qaSnapshot, { document, url: location.href }),
getExamState: () => qaExam,
onExamStart: async () => { qaExamCalls++; return settings.examNotReady ? false : true; },
onExamPause: () => {},
onState: state => { window.qaLastState = state; },
storage: qaStorage, now: () => qaClock,
reload: async () => { qaReloads++; }, navigate: async url => { qaNavigations.push(url); },
openWorker: async url => { qaWorkerURLs.push(url); return settings.blockWorker ? false : {}; },
solveQuiz: settings.noQuizAdapter ? undefined : async () => { qaQuizCalls++; if (settings.solveQuiz) { document.querySelector('[data-learning-quiz],.question-modal-pc')?.remove(); return { status: 'completed', verified: true }; } return { status: 'waiting', reason: 'fixture无精确答案' }; }
}, extra));
window.qaEngine = qaCreate(); await qaEngine.configure(config);
}, { html, config, settings });
}
async function read() { return page.evaluate(() => qaEngine.getState()); }
async function tick(delta = 0) { return page.evaluate(async delta => { qaClock += delta; return qaEngine.tick(); }, delta); }
// 安全解析实际7页,只读取DOM,不执行任何归档脚本和媒体。
const samples = [];
const archiveAvailable = fs.existsSync(path.join(workspace, '谊安学网页.zip'));
if (archiveAvailable) {
const zip = await ZIP.loadAsync(fs.readFileSync(path.join(workspace, '谊安学网页.zip')));
for (const file of Object.values(zip.files).filter(f => /\.html$/i.test(f.name))) samples.push({ name: file.name, html: await file.async('string') });
} else {
// 仓库不提交原站ZIP;新克隆仍用最小DOM覆盖同一适配器。
samples.push({ name: 'fixture/01/page.html', html: '<app-learning-map-activity-list>' + Array.from({ length: 26 }, (_, i) => '<div class="activity_content"><div class="activity_name">课程' + i + '</div><div class="acitvity_other_info">必修课程</div></div>').join('') + '</app-learning-map-activity-list>' });
samples.push({ name: 'fixture/02/page.html', html: videoHTML.replace('测试课程', '1-1安全生产方针和目标').replace('</li>', '<p class="related_test">关联考试</p></li>') });
samples.push({ name: 'fixture/05/page.html', html: videoHTML.replace('已学习20秒', '已学习27秒') });
samples.push({ name: 'fixture/06/page.html', html: '<app-examdetail><div class="examdetail_header">测试课程 已答0/40</div></app-examdetail>' });
}
const inspected = await page.evaluate(samples => samples.map(({ name, html }) => {
const doc = QuestionBankCore.parseHTML(html); const p = YianLearningEngine.describePage(doc, location.href); const tasks = YianLearningEngine.discoverTasks(doc, location.href);
return { name, kind: p.kind, title: p.title, learned: p.learnedSeconds, complete: p.platformComplete, quiz: Boolean(p.quiz), tasks: tasks.map(t => ({ kind: t.kind, title: t.title, status: t.status })) };
}), samples);
const map = inspected.find(x => x.name.includes('/01/'));
assert.equal(map.tasks.length, 26, '实际26课程不能把9阶段当作任务数');
assert.equal(map.tasks.filter(t => t.kind === 'course').length, 26);
const detail = inspected.find(x => x.name.includes('/02/'));
assert.equal(detail.title, '1-1安全生产方针和目标');
assert.deepEqual(detail.tasks.map(t => t.kind), ['video', 'exam']);
const played = inspected.find(x => x.name.includes('/05/'));
assert.equal(played.complete, false, 'noFinished不能因共同class前缀误认为完成');
assert.equal(played.learned, 27); assert.equal(played.quiz, false);
assert.equal(inspected.find(x => x.name.includes('/06/')).kind, 'exam');
checks++;
const identities = await page.evaluate(() => {
const d = new DOMParser().parseFromString('<a data-kind="video" href="/clientcn/course/one">同名</a><a data-kind="video" href="/clientcn/course/two">同名</a><a href="http://example.com/clientcn/exam/other">外站</a>', 'text/html');
return YianLearningEngine.discoverTasks(d, location.href);
});
assert.equal(identities.length, 2); assert.notEqual(identities[0].id, identities[1].id); checks++;
const topics = await page.evaluate(() => {
const d = new DOMParser().parseFromString('<app-examdetail><div class="examdetail_header">安全课程 已答0/40</div></app-examdetail>', 'text/html');
return [YianLearningEngine.preflightTopic(d, location.href, { courses: [] }, {}), YianLearningEngine.preflightTopic(d, { courses: ['安全课程'] }, {}), YianLearningEngine.preflightTopic(d, { courses: ['安全'] }, {}), YianLearningEngine.preflightTopic(d, { courses: ['安全领导力'] }, { 安全课程: ['安全领导力'] }), YianLearningEngine.preflightTopic(d, location.href, { records: [{ course: '安全课程' }] }, {})];
});
assert.deepEqual(topics.map(t => t.status), ['missing', 'matched', 'waiting', 'matched', 'matched']); checks++;
await setup(videoHTML, { rate: 4, mode: 'videoQuiz' });
await page.evaluate(() => qaEngine.start());
let state = await read();
assert.equal(state.rate, 2); assert.equal(state.answerInterval, 4); assert.equal(state.video.actualRate, 2);
assert.equal(state.total, 1); assert.equal(state.completed, 0); assert.equal(state.etaSeconds, 80);
await page.evaluate(() => { qaVideo.currentTime = 100; qaVideo.ended = true; });
state = await tick(1000); assert.equal(state.tasks[0].status, 'waitingPlatform'); assert.equal(state.completed, 0);
await tick(180000); assert.equal(await page.evaluate(() => qaReloads), 0);
await page.evaluate(() => document.querySelector('.cn_courseware_img').setAttribute('data-is-learned', 'true'));
state = await tick(); assert.equal(state.completed, 1); assert.equal(state.tasks[0].status, 'completed'); checks++;
await setup(); await page.evaluate(() => qaEngine.start());
await page.evaluate(() => qaEngine.configure({ videoPaused: true })); await tick();
assert.equal(await page.evaluate(() => qaVideo.paused), true);
await page.evaluate(() => qaEngine.resume()); assert.equal((await read()).videoPaused, false);
await page.evaluate(() => qaEngine.pause()); const prior = await page.evaluate(() => qaPlayCalls);
await tick(180000); assert.equal(await page.evaluate(() => qaPlayCalls), prior); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
await setup(videoHTML + '<div data-learning-quiz><p data-question>强制题</p></div>', { mode: 'video' });
state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'waiting');
await tick(180000); assert.equal(await page.evaluate(() => qaQuizCalls), 0); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
await setup(videoHTML + '<div class="cdk-overlay-pane question-modal-pc"><div class="question_box"><p>真实portal题</p></div></div>', { mode: 'videoQuiz' }, { solveQuiz: true });
await page.evaluate(() => qaEngine.start()); await tick();
assert.equal(await page.evaluate(() => qaQuizCalls), 1); assert.equal((await read()).tasks[0].status, 'playing'); checks++;
await setup(videoHTML + '<div data-learning-quiz><p data-question>未知题</p></div>', { mode: 'videoQuiz' });
state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'waiting');
assert.match(state.tasks[0].reason, /无精确答案/); await tick(180000); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
await setup(); await page.evaluate(() => qaEngine.start()); await tick(60000);
assert.equal(await page.evaluate(() => qaReloads), 0); assert.equal((await read()).tasks[0].status, 'retrying');
await tick(15000); assert.equal(await page.evaluate(() => qaReloads), 1); assert.equal((await read()).tasks[0].retryCount, 1);
const recovery = await page.evaluate(async () => { qaEngine.destroy(); await new Promise(r => setTimeout(r, 0)); qaEngine = qaCreate(); return qaEngine.tick(); });
assert.equal(recovery.status, 'running'); assert.equal(recovery.tasks[0].retryCount, 1);
for (let i = 0; i < 3; i++) { await tick(60000); await tick(15000); }
assert.equal(await page.evaluate(() => qaReloads), 3); assert.equal((await read()).tasks[0].status, 'failed'); checks++;
await setup(); await page.evaluate(() => qaEngine.start());
state = await page.evaluate(async () => { qaEngine.destroy(); await new Promise(r => setTimeout(r, 0)); qaEngine = qaCreate(); return qaEngine.tick(); });
assert.equal(state.status, 'paused'); assert.match(state.message, /人工刷新/);
await page.evaluate(() => qaEngine.resume()); assert.equal((await read()).status, 'running');
await page.evaluate(() => { qaSnapshot.accountId = 'other-account'; });
state = await tick(); assert.equal(state.status, 'paused'); assert.match(state.message, /账号/); checks++;
// 正常任务跳转有一次性授权;人工刷新同一地址没有该授权。
await setup('<app-learning-map-activity-list><div class="activity_content"><div class="activity_name">测试课程</div><div class="acitvity_other_info">课程</div></div></app-learning-map-activity-list>');
await page.evaluate(() => qaEngine.start());
state = await page.evaluate(async html => {
qaEngine.destroy(); await new Promise(r => setTimeout(r, 0));
history.replaceState(null, '', '/clientcn/course/authorized-target'); document.body.innerHTML = html; qaInstallVideo(); qaEngine = qaCreate(); return qaEngine.tick();
}, videoHTML);
assert.equal(state.status, 'running'); assert.equal(state.tasks.filter(t => t.kind === 'course')[0].status, 'expanded'); assert.equal(state.video.title, '视频一'); checks++;
// 播放器内的用户主动暂停不触发卡顿恢复。
await setup(videoHTML); await page.evaluate(() => qaEngine.start());
await page.evaluate(() => { qaClock += 1000; const v = document.querySelector('video'); v.style.cssText = 'width:200px;height:100px'; v.onclick = () => { qaVideo.paused = true; v.dispatchEvent(new Event('pause', { bubbles: true })); }; });
await page.locator('video').click(); state = await tick(); assert.equal(state.tasks[0].status, 'waiting'); assert.match(state.tasks[0].reason, /主动暂停/);
await tick(180000); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
await setup(videoHTML, {}, { rejectPlay: true });
state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'waiting');
await tick(180000); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
const examDetail = '<app-examdetail><div class="examdetail_header">测试课程 已答0/40</div><div class="examdetail_button" onclick="window.qaEnterCount=(window.qaEnterCount||0)+1">开始答题</div></app-examdetail>';
await setup(examDetail, { mode: 'exam' });
await page.evaluate(() => qaEngine.start()); assert.equal(await page.evaluate(() => qaEnterCount), 1);
await page.evaluate(() => { document.body.innerHTML = '<app-examtest><div class="pc_examtest_title">测试课程</div></app-examtest>'; });
await tick(); assert.equal(await page.evaluate(() => qaExamCalls), 1);
await page.evaluate(() => { qaExam = { stage: 'waiting', predictedScore: 80, message: '预计得分正好80分等待人工' }; });
state = await tick(); assert.equal(state.tasks[0].status, 'waiting');
await tick(600000); assert.equal(await page.evaluate(() => qaExamCalls), 1); assert.equal(await page.evaluate(() => qaReloads), 0); checks++;
await setup('<app-examdetail><div class="examdetail_header">没有题库的考试</div><div class="examdetail_button" onclick="window.qaWrongEnter=true">开始答题</div></app-examdetail>', { mode: 'exam' });
state = await page.evaluate(() => qaEngine.start()); assert.equal(state.tasks[0].status, 'skipped'); assert.equal(await page.evaluate(() => Boolean(window.qaWrongEnter)), false); checks++;
await setup(videoHTML + '<a data-kind="exam" href="/clientcn/exam/e1">测试课程</a>', { execution: 'parallel' }, { blockWorker: true });
state = await page.evaluate(() => qaEngine.start());
assert.equal(await page.evaluate(() => qaWorkerURLs.length), 1); assert.equal(state.tasks.find(t => t.kind === 'exam').status, 'waiting');
assert.match(state.tasks.find(t => t.kind === 'exam').reason, /拦截/); checks++;
await setup(videoHTML + '<a data-kind="exam" href="/clientcn/exam/e1">测试课程</a>', { execution: 'parallel' });
await page.evaluate(() => qaEngine.start());
const opened = await page.evaluate(() => qaWorkerURLs[0]); assert.match(opened, /__yian_worker=/);
state = await tick(31000); assert.equal(state.tasks.find(t => t.kind === 'exam').status, 'waiting'); assert.match(state.tasks.find(t => t.kind === 'exam').reason, /未确认启动/); checks++;
await setup(); await page.evaluate(() => qaEngine.start());
const exclusive = await page.evaluate(async () => { window.qaSecond = qaCreate(); await qaSecond.tick(); await qaSecond.resume(); return { first: qaEngine.getState(), second: qaSecond.getState(), plays: qaPlayCalls }; });
assert.equal(exclusive.plays, 1, '同类租约阻止第二页面重复播放'); assert.match(exclusive.second.tasks[0].reason, /另一个页面/); checks++;
// 两个实际标签通过同一GM式存储确认工作页,暂停信号同步,完全隔离真实业务网络。
const shared = new Map();
await context.exposeBinding('__learningGet', (_, key) => shared.has(key) ? JSON.parse(JSON.stringify(shared.get(key))) : null);
await context.exposeBinding('__learningSet', (_, key, value) => { shared.set(key, JSON.parse(JSON.stringify(value))); });
const relatedHTML = videoHTML.replace('</li>', '<p class="related_test">测试课程</p></li>');
await setup(relatedHTML, { execution: 'parallel' });
await page.evaluate(async () => { qaEngine.destroy(); qaStorage = { get: key => __learningGet(key), set: (key, value) => __learningSet(key, value) }; qaEngine = qaCreate(); await qaEngine.configure({ execution: 'parallel' }); await qaEngine.start(); });
const workerURL = await page.evaluate(() => qaWorkerURLs[0]);
const workerPage = await context.newPage();
try {
await workerPage.route('**/*', route => route.request().isNavigationRequest() ? route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><meta charset="utf-8">' + relatedHTML }) : route.abort());
await workerPage.goto(workerURL); await workerPage.addScriptTag({ content: source });
let workerState = await workerPage.evaluate(async examDetail => {
window.fixtureExamCalls = 0; window.fixtureExamPause = 0;
document.querySelector('.related_test').onclick = () => { document.body.innerHTML = examDetail; };
window.fixtureEngine = YianLearningEngine.create({ getSnapshot: () => ({ document, url: location.href, courses: ['测试课程'], accountId: 'fixture-account', bankVersion: 1, planId: 'fixture-plan' }),
storage: { get: key => __learningGet(key), set: (key, value) => __learningSet(key, value) }, now: () => 100000,
onExamStart: async () => { fixtureExamCalls++; return true; }, getExamState: () => window.fixtureExamState || ({ stage: 'answering', predictedScore: 84, verifiedCount: 32, total: 40, elapsedSeconds: 120, waitSeconds: 240 }), onExamPause: () => { fixtureExamPause++; } });
return fixtureEngine.tick();
}, examDetail);
assert.equal(workerState.status, 'running'); assert.equal(workerState.tasks.find(t => t.kind === 'exam').status, 'loading');
assert.equal(await workerPage.evaluate(() => Boolean(document.querySelector('app-examdetail'))), true, '无href时从真实课程页点击原关联考试入口');
await workerPage.evaluate(() => { history.replaceState(null, '', '/clientcn/examtest/e1?flag=test'); document.body.innerHTML = '<app-examtest><div class="pc_examtest_title">测试课程</div></app-examtest>'; });
workerState = await workerPage.evaluate(() => fixtureEngine.tick()); assert.equal(await workerPage.evaluate(() => fixtureExamCalls), 1);
state = await tick(); assert.match(state.message, /并行执行器已确认/); assert.equal(state.tasks.filter(t => t.kind === 'exam').length, 1, '详情转试卷不得复制同一考试');
assert.equal(state.exam.predictedScore, 84); assert.equal(state.exam.verified, 32); assert.equal(state.etaSeconds, 240, '并行总览显示另一场考试证据和剩余时间');
await page.evaluate(() => qaEngine.pause()); workerState = await workerPage.evaluate(() => fixtureEngine.tick());
assert.equal(workerState.status, 'paused'); assert.ok(await workerPage.evaluate(() => fixtureExamPause) > 0);
await page.evaluate(() => qaEngine.resume()); await workerPage.evaluate(() => fixtureEngine.tick()); workerState = await workerPage.evaluate(() => fixtureEngine.tick());
assert.equal(workerState.status, 'running'); assert.equal(await workerPage.evaluate(() => fixtureExamCalls), 2, '全局继续后重新核验考试');
await workerPage.evaluate(() => fixtureEngine.pause()); state = await tick(); assert.equal(state.status, 'paused', '工作页的全局暂停同步主页面');
await workerPage.evaluate(() => fixtureEngine.resume()); await tick(); state = await tick(); assert.equal(state.status, 'running');
await page.evaluate(() => document.querySelector('.cn_courseware_img').setAttribute('data-is-learned', 'true'));
state = await tick(); assert.equal(state.completed, 1); assert.equal(state.status, 'running', '视频先完成仍等待另一页考试');
await workerPage.evaluate(() => { window.fixtureExamState = { stage: 'submitted', predictedScore: 84, total: 40, verifiedCount: 32 }; });
await workerPage.evaluate(() => fixtureEngine.tick()); state = await tick();
assert.equal(state.completed, 2); assert.equal(state.status, 'finished', '本地任务完成后仍合并远端终态');
await page.evaluate(() => qaEngine.start()); workerState = await workerPage.evaluate(() => fixtureEngine.tick());
// 旧执行器已完成,不会因新一轮开始重新答题。
assert.ok(['finished', 'paused'].includes(workerState.status)); checks++;
} finally { await workerPage.close(); }
await setup(); await page.evaluate(() => qaEngine.start());
await page.evaluate(() => { for (let i = 0; i < 520; i++) qaEngine.log('info', '日志' + i); });
assert.equal((await read()).logs.length, 500);
await page.evaluate(() => qaEngine.clearLogs()); state = await read(); assert.equal(state.logs.length, 0); assert.equal(state.tasks.length, 1); checks++;
await setup(); await page.evaluate(() => qaEngine.start()); await page.evaluate(() => qaEngine.pause());
const initializedRace = await page.evaluate(async () => {
qaEngine.destroy();
const previous = qaStorage; let release; const gate = new Promise(resolve => { release = resolve; });
qaStorage = { get: async key => { if (key === 'session.v1') await gate; return previous.get(key); }, set: previous.set };
qaEngine = qaCreate(); const first = qaEngine.tick(); const update = qaEngine.configure({ rate: 2, mode: 'video' });
await new Promise(r => setTimeout(r, 10)); release(); await Promise.all([first, update]); return qaEngine.getState();
});
assert.equal(initializedRace.rate, 2); assert.equal(initializedRace.mode, 'video'); checks++;
// 长时间课中题异步流程也须续租,超过18秒后仍只有原执行器操作。
await setup(videoHTML + '<div data-learning-quiz><p data-question>异步核验题</p></div>', { mode: 'videoQuiz' });
await page.evaluate(() => {
qaEngine.destroy(); window.qaQuizCommitted = 0;
qaEngine = qaCreate({ solveQuiz: async () => {
await new Promise(resolve => { window.qaReleaseQuiz = resolve; });
if (qaEngine.getState().status !== 'running') return { status: 'waiting', reason: '任务已失效' };
qaQuizCommitted++; document.querySelector('[data-learning-quiz]').remove(); return { status: 'completed', verified: true };
} });
window.qaHeldStart = qaEngine.start();
});
await page.waitForFunction(() => typeof qaReleaseQuiz === 'function');
await tick(10000); await tick(10000);
const heldLease = await page.evaluate(async () => ({ lease: await qaStorage.get('lease:video'), clock: qaClock, status: qaEngine.getState().status }));
assert.equal(heldLease.status, 'running'); assert.ok(heldLease.lease.expires > heldLease.clock, '异步题超过18秒仍续租');
await page.evaluate(async () => { qaReleaseQuiz(); await qaHeldStart; });
assert.equal(await page.evaluate(() => qaQuizCommitted), 1); checks++;
// 租约被另一执行器取得后,旧异步题不得继续提交,也不得清除新租约。
await setup(videoHTML + '<div data-learning-quiz><p data-question>异步核验题</p></div>', { mode: 'videoQuiz' });
await page.evaluate(() => {
qaEngine.destroy(); window.qaReleaseQuiz = null; window.qaQuizCommitted = 0;
qaEngine = qaCreate({ solveQuiz: async () => {
await new Promise(resolve => { window.qaReleaseQuiz = resolve; });
if (qaEngine.getState().status !== 'running') return { status: 'waiting', reason: '任务已失效' };
qaQuizCommitted++; return { status: 'completed', verified: true };
} });
window.qaHeldStart = qaEngine.start();
});
await page.waitForFunction(() => typeof qaReleaseQuiz === 'function');
await page.evaluate(async () => { const lease = await qaStorage.get('lease:video'); lease.owner = 'another-worker'; lease.expires = qaClock + 18000; await qaStorage.set('lease:video', lease); });
state = await tick(1000); assert.equal(state.status, 'paused'); assert.match(state.message, /租约/);
assert.equal(await page.evaluate(() => qaVideo.paused), true);
await page.evaluate(async () => { qaReleaseQuiz(); await qaHeldStart; });
assert.equal(await page.evaluate(() => qaQuizCommitted), 0); assert.equal(await page.evaluate(async () => (await qaStorage.get('lease:video')).owner), 'another-worker'); checks++;
assert.deepEqual(errors, []);
console.log('learning-engine:', checks, 'checks passed (' + (archiveAvailable ? '真实归档DOM' : '合成DOM(未提供原站归档)') + '、平台完成、课中题、恢复、主题预检、租约、并行确认)');
} finally { await context.close(); await browser.close(); }
}
main().catch(error => { console.error(error); process.exitCode = 1; });
+5 -1
View File
@@ -34,7 +34,7 @@ async function main() {
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.qaBase = { bankTotal: 60, uniqueTotal: 20, conflictTotal: 0, courses: ['安全课程'], canAnswer: true, canExport: true, pageMode: 'exercise', pageStatus: '已识别练习,可以选择答案。', storageMode: 'userscript' };
window.qaMakePanel = partial => {
window.qaPanel?.destroy();
window.qaPanel = YianAssistantPanel.create({
@@ -154,8 +154,11 @@ async function main() {
const beforeControls = await rect();
const controlsSaves = await positionSaves();
await host.getByTestId('tab-exam').click();
await host.getByRole('switch', { name: '自动选择答案', exact: true }).click();
await host.getByTestId('tab-settings').click();
await host.getByRole('switch', { name: '允许复制文字', exact: true }).click();
await host.getByTestId('tab-bank').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]]);
@@ -177,6 +180,7 @@ async function main() {
assert(clamped.top >= 0 && clamped.right <= clamped.viewportWidth + 1, '超出视口的恢复位置应钳制');
await page.setViewportSize({ width: 390, height: 844 });
await visible();
await host.getByTestId('tab-bank').click();
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);
+199
View File
@@ -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; });
+138
View File
@@ -0,0 +1,138 @@
/* Template-backed courseware popups with independent selection/grade models. */
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const JSZip = require('../vendor/jszip.min.js');
const rootDir = path.resolve(__dirname, '..');
(async () => {
const savedZip = path.join(rootDir, '谊安学网页.zip');
if (fs.existsSync(savedZip)) {
const zip = await JSZip.loadAsync(fs.readFileSync(savedZip));
const bundle = Object.values(zip.files).find(file => /yianxue\/01\/.*\/5-es2015\./.test(file.name));
assert(bundle, '保存网页应包含课中题模板所在 bundle');
const source = await bundle.async('string');
for (const evidence of ['selectors:[["app-question-modal"]]', 'question-modal-pc', 'question_box', 'answer_box', 'examtest_content_question', 'examtest_content_list_des_optionsort', 'examtest_content_list_des_checked', 'doCheck(e,t)', 'this.getAnswer(s,n,a,r),this.showAnswerDialog()', 'this.continuePlay.emit(!0)', '查看答案', '回答正确', '下一题', '关闭']) {
assert(source.includes(evidence), '真实模板证据缺少:' + evidence);
}
console.log('PASS 保存 ZIP 静态确认 app-question-modal、PC overlay、文字/选中状态、判分及继续播放模板;未执行原脚本');
}
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
const requests = [], errors = [];
try {
const context = await browser.newContext({ viewport: { width: 1000, height: 800 } });
await context.route('**/*', route => { requests.push(route.request().url()); return route.abort(); });
const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message));
for (const name of ['question-bank-core.js', 'bank-store.js', 'answer-matcher.js', 'quiz-adapter.js']) await page.addScriptTag({ content: fs.readFileSync(path.join(rootDir, name), 'utf8') });
async function setup(config = {}) {
await page.evaluate(config => {
document.body.innerHTML = '<style>.question_box,.answer_box{display:block;padding:20px;border:1px solid}.hidden,[hidden],[aria-hidden="true"]{display:none!important}.examtest_content_list_des{padding:8px;cursor:pointer}.examtest_content_list_des_checked{background:#ded}.modal-footer{padding:8px}</style><app-question-modal></app-question-modal><div class="cdk-overlay-container"><div class="cdk-overlay-pane question-modal-pc"></div></div>';
window.qaActive = true; window.qaAbort = new AbortController();
window.qaModel = { optionClicks: [], submits: 0, advances: 0, grading: false, index: 0, selected: [], config };
const single = { type: '单选', question: '压力达到 2 MPa 时应该怎样操作?', bank: ['保持 MPa 单位并核对', '切换 mPa 单位', '任意操作'], page: ['任意操作', '切换 mPa 单位', '保持 MPa 单位并核对'], wanted: ['保持 MPa 单位并核对'] };
const multi = { type: '多选', question: '下列哪些属于准确的完整措施?', bank: ['措施甲(2)', '措施乙', '错误措施'], page: ['错误措施', '措施乙', '措施甲(2)'], wanted: ['措施甲(2)', '措施乙'] };
const judge = { type: '判断', question: 'MPa 与 mPa 应当区分。', bank: ['对', '错'], page: ['错', '对'], wanted: ['对'] };
window.qaQuestions = config.multiple ? [single, multi, judge] : [config.type === 'multi' ? multi : config.type === 'judge' ? judge : single];
window.qaSnapshot = { revision: 1, records: qaQuestions.map((q, i) => YianBankStore.validateRecord({ role: '', course: '安全课中题', number: i + 1, type: q.type, question: q.question,
options: q.bank.map((text, index) => ({ label: String.fromCharCode(65 + index), text })), answer: q.wanted.map(text => String.fromCharCode(65 + q.bank.indexOf(text))).join('') })) };
if (config.conflict) qaSnapshot.records.push(YianBankStore.validateRecord({ ...qaSnapshot.records[0], course: '安全课中题', sourceFile: '冲突题库.json', answer: 'B', correctTexts: undefined }));
if (config.missing) qaSnapshot.records = [];
const pane = document.querySelector('.question-modal-pc');
const escape = text => String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const grade = () => {
const q = qaQuestions[qaModel.index]; qaModel.grading = true;
const correct = !config.wrongResult && JSON.stringify(qaModel.selected.slice().sort()) === JSON.stringify(q.wanted.slice().sort());
pane.innerHTML = '<div class="answer"><div class="answer_box fade_in"><div class="modal-dialog"><div class="modal-content"><div class="modal-body"><div class="model_dec"><span>' + (correct ? '恭喜你' : '对不起') + '</span><span class="model_dec_span">' + (correct ? '回答正确' : '回答错误') + '</span></div><div class="model_answer_wrapper"><div class="model_answer">参考答案:' + escape(q.wanted.join(';')) + '</div></div></div><div class="modal-footer"><button type="button"><span aria-hidden="true">' + (qaModel.index < qaQuestions.length - 1 ? '下一题' : '关闭') + '</span></button></div></div></div></div></div>';
pane.querySelector('button').addEventListener('click', () => {
qaModel.advances++;
if (qaModel.index < qaQuestions.length - 1) {
qaModel.index++;
if (config.deferNext) { pane.innerHTML = ''; setTimeout(render, 150); }
else render();
}
else pane.innerHTML = '';
});
};
const render = () => {
const q = qaQuestions[qaModel.index];
qaModel.selected = config.type === 'multi' || qaModel.index === 1 ? ['错误措施'] : [];
pane.innerHTML = '<div class="question"><div class="question_box fade_in"><div class="modal-dialog"><div class="modal-content"><div class="modal-body"><div class="examtest_content_question">' + ['单选', '多选', '判断'].map(type => '<p class="' + (q.type === type ? 'show' : 'hidden') + '"><span>[' + type + ']</span>' + escape(q.question) + '</p>').join('') + '</div><div class="examtest_content_list">' + q.page.map((text, i) => '<div class="option-row"><div class="examtest_content_list_des' + (qaModel.selected.includes(text) ? ' examtest_content_list_des_checked' : '') + '"><div><span class="examtest_content_list_des_optionsort">' + String.fromCharCode(65 + i) + '</span>' + escape(text) + ' </div></div></div>').join('') + '</div></div><div class="modal-footer"></div></div></div></div></div>';
const footer = pane.querySelector('.modal-footer');
const submit = () => {
if (footer.querySelector('button')) return;
const button = document.createElement('button'); button.type = 'button'; button.innerHTML = '<span aria-hidden="true">查看答案</span>'; footer.append(button);
button.addEventListener('click', () => {
qaModel.submits++;
if (config.noGrade) return;
if (config.deferGrade) { pane.innerHTML = ''; window.qaReleaseGrade = grade; }
else grade();
});
};
pane.querySelectorAll('.option-row').forEach((row, i) => row.addEventListener('click', () => {
qaModel.optionClicks.push({ index: qaModel.index, text: q.page[i], label: String.fromCharCode(65 + i) });
if (config.noSelection) return;
if (q.type === '多选') qaModel.selected = qaModel.selected.includes(q.page[i]) ? qaModel.selected.filter(text => text !== q.page[i]) : qaModel.selected.concat(q.page[i]);
else qaModel.selected = [q.page[i]];
pane.querySelectorAll('.examtest_content_list_des').forEach((node, j) => node.classList.toggle('examtest_content_list_des_checked', qaModel.selected.includes(q.page[j])));
submit();
if (config.cancelAfterClick) qaActive = false;
}));
};
render();
if (config.image) { const image = document.createElement('img'); image.alt = '需要人工识别的图片'; pane.querySelector('.examtest_content_question').append(image); }
if (config.unknown) pane.innerHTML = '<div class="unrecognized-popup"><p>新题型</p><button>提交</button></div>';
window.qaSolve = () => YianQuizAdapter.solve({ document, snapshot: qaSnapshot, matcher: YianAnswerMatcher,
context: { course: '当前视频标题', courses: ['安全课中题'], answerInterval: 0 }, signal: qaAbort.signal, getActive: () => qaActive, timeoutMs: config.timeoutMs || 300 });
}, config);
}
await setup();
let result = await page.evaluate(() => qaSolve());
assert.equal(result.status, 'complete'); assert.equal(result.verified, true);
let model = await page.evaluate(() => qaModel);
assert.deepEqual(model.optionClicks.map(item => item.label), ['C'], '按原答案全文映射到打乱后的 C,不使用原 A');
assert.equal(model.submits, 1); assert.equal(model.advances, 1);
assert.equal(await page.locator('.question_box,.answer_box').count(), 0, '正确结果后正常关闭弹题');
await setup({ multiple: true, deferNext: true });
result = await page.evaluate(() => qaSolve());
assert.equal(result.status, 'complete'); assert.equal(result.verifiedCount, 3);
model = await page.evaluate(() => qaModel);
assert.deepEqual(model.optionClicks.filter(item => item.index === 1).map(item => item.label), ['A', 'B', 'C'], '多选先去除错误 A,再补选 B、C');
assert.equal(model.submits, 3); assert.equal(model.advances, 3);
console.log('PASS 空 app-question-modal + 真实 PC portal:乱序全文映射、单选/多选/判断、完整集合回读、判分及下一题异步空隙/关闭');
for (const config of [{ conflict: true }, { missing: true }, { image: true }, { unknown: true }]) {
await setup(config); result = await page.evaluate(() => qaSolve());
assert.equal(result.status, config.conflict ? 'conflict' : config.missing ? 'missing' : 'waiting');
model = await page.evaluate(() => qaModel);
assert.equal(model.optionClicks.length, 0); assert.equal(model.submits, 0);
}
console.log('PASS 冲突、未命中、图片与未知 DOM 均不猜选、不提交');
await setup({ cancelAfterClick: true }); result = await page.evaluate(() => qaSolve());
assert.equal(result.status, 'waiting'); assert.equal((await page.evaluate(() => qaModel)).submits, 0);
await setup();
result = await page.evaluate(() => { qaAbort.abort(); return qaSolve(); });
assert.equal(result.status, 'waiting'); assert.equal((await page.evaluate(() => qaModel)).optionClicks.length, 0);
await setup({ noSelection: true }); result = await page.evaluate(() => qaSolve());
assert.equal(result.status, 'waiting'); assert.equal((await page.evaluate(() => qaModel)).submits, 0);
console.log('PASS getActive 失效、signal 中止、网站选中状态无回读时立即停止');
await setup({ deferGrade: true, timeoutMs: 2000 });
await page.evaluate(() => { window.qaDone = false; window.qaPromise = qaSolve().then(result => { qaDone = true; return result; }); });
await page.waitForFunction(() => typeof window.qaReleaseGrade === 'function');
assert.equal(await page.evaluate(() => qaDone), false, '题干消失但网站尚未返回结果时不得视为完成');
await page.evaluate(() => qaReleaseGrade());
result = await page.evaluate(() => qaPromise);
assert.equal(result.status, 'complete'); assert.equal(result.verified, true);
await setup({ wrongResult: true }); result = await page.evaluate(() => qaSolve());
assert.equal(result.status, 'waiting'); assert.match(result.reason, /错误/);
assert.equal((await page.evaluate(() => qaModel)).advances, 0, '网站判错时不自动点击下一题');
await setup({ noGrade: true }); result = await page.evaluate(() => qaSolve());
assert.equal(result.status, 'waiting'); assert.equal((await page.evaluate(() => qaModel)).submits, 1, '结果未确认只提交一次');
console.log('PASS 题干消失不冒充结果、待网站明确判分、判错等待人工与未知结果不重复提交');
assert.equal(requests.length, 0, '所有适配测试不得连接业务或任何网络');
assert.deepEqual(errors, []);
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+1 -1
View File
@@ -1,6 +1,6 @@
const { spawnSync } = require('node:child_process');
const path = require('node:path');
for (const file of ['bank-engine.cjs', 'runner-browser.cjs', 'verify.cjs', 'assistant.cjs', 'exam-assistant.cjs', 'panel-drag.cjs']) {
for (const file of ['bank-engine.cjs', 'unified-bank.cjs', 'runner-browser.cjs', 'verify.cjs', 'learning-engine.cjs', 'quiz-adapter.cjs', 'exam-controller.cjs', 'panel-tabs.cjs', 'assistant.cjs', 'exam-assistant.cjs', 'panel-drag.cjs', 'workflow.cjs', 'video-workflow.cjs']) {
const result = spawnSync(process.execPath, [path.join(__dirname, file)], { stdio: 'inherit' });
if (result.status !== 0) process.exit(result.status || 1);
}
+2 -2
View File
@@ -134,12 +134,12 @@ async function main() {
await setup([{ type: '单选' }], 'delayed');
await delayedStart();
await page.evaluate(() => { qaContext.role = '班长'; });
await page.evaluate(() => { qaContext.topicKey = '新主题关联'; });
await finishMatch();
assert.equal(await page.evaluate(() => qaClicks.length), 0);
await page.evaluate(() => { window.qaMode = 'normal'; return qaRunner.start(); });
assert.equal(await page.evaluate(() => qaClicks.length), 1);
console.log('PASS role change cancels stale match and restart uses new role');
console.log('PASS topic association change cancels stale match and restart revalidates');
for (const field of ['revision', 'routeKey', 'sourceUrl']) {
await setup([{ type: '单选' }], 'delayed');
+123
View File
@@ -0,0 +1,123 @@
/* Unified-bank identity, provenance, topic boundaries and durable v2 migration. */
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const rootDir = path.resolve(__dirname, '..');
const JSZip = require(path.join(rootDir, 'vendor', 'jszip.min.js'));
const V2 = 'yianxue.assistant.bank.v2', V3 = 'yianxue.assistant.bank.v3';
function runtime(memory = new Map(), failWrites = false) {
const environment = {
TextDecoder, URL, URLSearchParams, JSZip,
GM_getValue: (key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback,
GM_setValue: (key, value) => { if (failWrites) throw new Error('quota exhausted'); memory.set(key, structuredClone(value)); }
};
vm.createContext(environment);
for (const name of ['bank-store.js', 'answer-matcher.js']) vm.runInContext(fs.readFileSync(path.join(rootDir, name), 'utf8'), environment);
return { store: environment.YianBankStore, matcher: environment.YianAnswerMatcher, memory };
}
function file(name, content) {
const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content);
return { name, size: bytes.length, arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) };
}
function row(overrides = {}) {
return { role: '主操', course: '安全领导力-1', sourceUrl: 'http://old/clientcn/exam/leadership/questions', sourceFile: '题库/主操/领导力.csv',
number: 1, type: '单选', question: '技术措施应当怎样执行?',
options: [{ label: 'A', text: '先核对要求' }, { label: 'B', text: '任意操作' }], answer: 'A', ...overrides };
}
(async () => {
const { store, matcher } = runtime();
const origins = [row(), row({ role: '副操', sourceFile: '题库/副操/领导力.csv' }), row({ role: '班长', sourceFile: '题库/班长/领导力.csv' }),
row({ role: '', sourceFile: '独立题库.json' })];
let state = await store.importFiles([file('统一.json', JSON.stringify({ records: origins }))]);
assert.equal(state.schemaVersion, 3);
assert.equal(state.total, 4, '保留四份来源记录');
assert.equal(state.uniqueTotal, 1, '跨岗位同题同答案仅聚合为一项');
assert.equal(state.questionTotal, 1);
assert.equal(state.conflictTotal, 0);
assert.equal(state.counts.未标明, 1);
for (const role of ['', '主操', '班长', '副操', '旧版遗留值']) {
const found = matcher.search('技术措施', { role }, state);
assert.equal(found.total, 1);
assert.equal(found.items[0].sources.length, 4, '聚合显示全部原岗位和来源文件');
assert.equal(matcher.match(row(), { role }, state).status, 'matched');
}
const reordered = { ...row(), options: [{ label: 'A', text: '任意操作' }, { label: 'B', text: '先核对要求' }] };
const mapped = matcher.match(reordered, {}, state);
assert.deepEqual(Array.from(mapped.correctLabels), ['B']);
assert.deepEqual(Array.from(mapped.correctTexts), ['先核对要求']);
state = await store.importFiles([file('统一.json', JSON.stringify({ records: origins }))]);
assert.equal(state.total, 4, '重复导入不增加记录');
const conflict = row({ role: '副操', course: '另一主题', sourceUrl: 'http://old/clientcn/exam/other/questions', sourceFile: '另一主题.csv', answer: 'B' });
state = await store.importFiles([file('另一主题.json', JSON.stringify({ records: [conflict] }))]);
assert.equal(state.total, 5);
assert.equal(state.uniqueTotal, 2);
assert.equal(state.questionTotal, 1);
assert.equal(state.conflictTotal, 1);
assert.equal(matcher.match(reordered, {}, state).status, 'conflict', '四比一也不得多数决');
assert(matcher.search('技术措施', {}, state).items.every(item => item.conflict));
assert.equal(matcher.match(reordered, { course: '安全领导力-1' }, state).status, 'matched');
assert.equal(matcher.match(reordered, { sourceUrl: 'http://new/clientcn/exam/other/questions' }, state).correctLabels[0], 'A');
assert.equal(matcher.match(reordered, { courses: ['安全领导力-1'], course: '1-1安全生产方针和目标' }, state).correctLabels[0], 'B');
assert.equal(matcher.match(reordered, { courses: ['另一主题'] }, state).correctLabels[0], 'A');
assert.equal(matcher.match(reordered, { courses: ['不存在'], sourceUrl: origins[0].sourceUrl }, state).status, 'missing', '主题名单没有匹配记录不得退回其他来源');
assert.equal(matcher.match(reordered, { courses: [] }, state).status, 'missing');
assert.equal(matcher.match(reordered, { courses: ['安全领导力'] }, state).status, 'missing', '课程名单必须为全名,关键词不能授权作答');
assert.equal(matcher.match(reordered, { courses: ['安全领导力-1', '另一主题'] }, state).status, 'conflict');
const legacyValue = { schemaVersion: 2, records: origins.concat(origins[0]), revision: 8, updatedAt: '2026-10-17T00:00:00.000Z' };
const oldMemory = new Map([[V2, JSON.stringify(legacyValue)]]);
const oldBackup = oldMemory.get(V2);
const migrated = await runtime(oldMemory).store.load();
assert.equal(migrated.schemaVersion, 3);
assert.equal(migrated.total, 4, '升级过程去掉完全重复的来源记录');
assert.equal(migrated.uniqueTotal, 1);
assert.equal(migrated.revision, 8);
assert.equal(migrated.updatedAt, legacyValue.updatedAt);
assert.equal(migrated.migration.status, 'complete');
assert.equal(oldMemory.get(V2), oldBackup, '升级成功依然保留旧库原值');
assert.equal((await runtime(oldMemory).store.load()).total, 4, '重复加载不会再次追加 v2');
const migratedTwice = await runtime(oldMemory).store.importFiles([file('统一.json', JSON.stringify({ records: origins }))]);
assert.equal(migratedTwice.total, 4);
const quotaMemory = new Map([[V2, structuredClone(legacyValue)]]);
const quotaBefore = JSON.stringify(quotaMemory.get(V2));
const quota = await runtime(quotaMemory, true).store.load();
assert.equal(quota.total, 4, '迁移保存失败时仍可读取已经校验的旧库');
assert.equal(quota.migration.status, 'pending');
assert.match(quota.migration.error, /quota/);
assert.equal(JSON.stringify(quotaMemory.get(V2)), quotaBefore);
assert.equal(quotaMemory.has(V3), false, '失败不得写入半份新库');
for (const damaged of ['not json', { records: [row({ answer: 'H' })] }, { records: 'bad' }]) {
const memory = new Map([[V2, damaged]]);
const before = JSON.stringify(memory.get(V2));
await assert.rejects(runtime(memory).store.load(), /损坏|无效/);
assert.equal(JSON.stringify(memory.get(V2)), before);
assert.equal(memory.has(V3), false);
}
const corruptedV3 = new Map([[V2, legacyValue], [V3, { schemaVersion: 3, records: [row({ correctTexts: ['任意操作'] })] }]]);
await assert.rejects(runtime(corruptedV3).store.load(), /无效/, 'v3 损坏时不得静默恢复较旧题库并覆盖新数据');
assert.deepEqual(corruptedV3.get(V2), legacyValue);
const unifiedPath = path.join(rootDir, '题库', '统一题库.csv');
if (fs.existsSync(unifiedPath)) {
const real = runtime();
const unifiedFile = file('统一题库.csv', fs.readFileSync(unifiedPath));
const once = await real.store.importFiles([unifiedFile]);
const twice = await real.store.importFiles([unifiedFile]);
assert.equal(once.total, 5520);
assert.equal(twice.total, once.total);
assert.equal(twice.uniqueTotal, once.uniqueTotal);
assert.equal(new Set(twice.records.map(record => record.sourceFile)).size, 28);
assert(twice.conflictTotal > 0, '真实跨来源冲突必须保留');
console.log('PASS 统一 CSV:5520 条来源 / ' + twice.uniqueTotal + ' 个答案组合 / ' + twice.questionTotal + ' 道题 / ' + twice.conflictTotal + ' 道冲突题,重复导入记录数不变');
}
console.log('PASS 统一题库跨岗位与无岗位、文字映射、来源聚合、冲突不多数决、准确主题名单、v2 安全迁移及损坏/配额保护');
})().catch(error => { console.error(error); process.exitCode = 1; });
+1
View File
@@ -87,6 +87,7 @@ function parseCSV(text) {
await page.evaluate(() => YianAssistant.ready);
await page.waitForFunction(() => YianAssistant.getState().pageReady);
await page.waitForTimeout(800);
await page.getByTestId('tab-settings').click();
assert.equal(downloads.length, 0, '导出开关默认关闭,不应自动下载');
assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true }).isChecked(), false);
assert(await page.getByRole('button', { name: '导出 CSV', exact: true, includeHidden: true }).isHidden());
+144
View File
@@ -0,0 +1,144 @@
/* Browser plugin not available. 完整 bundle 视频→真实模板课中题→平台完成;全部业务 URL 被本地响应拦截。 */
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { chromium } = require('playwright');
const workspace = path.resolve(__dirname, '..');
const source = fs.readFileSync(path.join(workspace, '谊安学助手 2.0.user.js'), 'utf8');
const qaDir = process.env.YIAN_VIDEO_QA_DIR || path.join(os.tmpdir(), 'yianxue-video-workflow-qa');
fs.mkdirSync(qaDir, { recursive: true });
const fixtureURL = 'http://10.119.67.7/clientcn/course/video-workflow';
const course = '视频联动安全课程';
const fixture = `<!doctype html><meta charset="utf-8"><title>谊安学视频联动验证</title><style>
body{margin:26px;background:#edf2f8;color:#203451;font:15px/1.5 system-ui}h1{font-size:24px}app-course{display:block;max-width:700px}li{list-style:none;background:white;padding:14px;border-radius:8px;margin-bottom:15px}li>p{font-weight:600}video{display:block;width:640px;max-width:100%;height:220px;background:#172a49;border-radius:10px}.menu_courseware_left{font-size:12px;color:#64758e}.cn_courseware_img_icon{color:#64758e}.hidden,[hidden],[aria-hidden="true"]{display:none!important}.question-modal-pc{position:fixed;inset:130px auto auto 60px;width:530px;max-width:calc(100vw - 50px);padding:20px;background:white;box-shadow:0 10px 60px #172a4955;border-radius:12px;z-index:1000}.examtest_content_list_des{padding:10px;margin:6px 0;border:1px solid #dae3ef;border-radius:6px;cursor:pointer}.examtest_content_list_des_optionsort{margin-right:10px}.examtest_content_list_des_checked{background:#e0f3e7}.modal-footer button{padding:7px 16px;background:#225aca;color:white;border:0;border-radius:6px}
</style><h1>视频学习与课中答题联动</h1><p>独立模拟播放与网站判分,所有网络已拦截。</p><app-course><div data-course-title>${course}</div><li class="cn_courseware_img active" data-courseware-id="workflow-video" data-required-seconds="100"><p>${course}</p><div class="cn_courseware_img_icon">视频</div><div class="menu_courseware_left">已学习10秒</div><div class="courseware_finished_status"><span class="en_courseware_finished en_courseware_noFinished">尚未完成</span></div></li><app-course-courseware-video><app-video-base><video id="player1" controls></video></app-video-base></app-course-courseware-video><app-question-modal></app-question-modal></app-course><div class="cdk-overlay-container"></div>`;
async function main() {
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
const memory = new Map(), requests = [], blocked = [], errors = [];
try {
const context = await browser.newContext({ viewport: { width: 1366, height: 900 } });
await context.exposeBinding('__gmRead', (_, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback);
await context.exposeBinding('__gmWrite', (_, key, value) => { memory.set(key, structuredClone(value)); });
await context.route('**/*', route => {
requests.push(route.request().url());
if (route.request().isNavigationRequest() && route.request().url() === fixtureURL) return route.fulfill({ status: 200, contentType: 'text/html;charset=utf-8', body: fixture });
blocked.push(route.request().url()); return route.abort();
});
const page = await context.newPage();
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') errors.push(message.text()); });
await page.goto(fixtureURL);
assert.equal(page.url(), fixtureURL); assert.equal(await page.title(), '谊安学视频联动验证');
await page.evaluate(() => {
window.qaMedia = { currentTime: 10, duration: 100, ended: false, paused: true, playbackRate: 1, playCalls: 0, pauseCalls: 0 };
const video = document.querySelector('video');
for (const key of ['currentTime', 'duration', 'ended', 'paused', 'playbackRate']) Object.defineProperty(video, key, { configurable: true, get: () => qaMedia[key], set: value => { qaMedia[key] = value; } });
video.play = async () => { qaMedia.playCalls++; qaMedia.paused = false; };
video.pause = () => { qaMedia.pauseCalls++; qaMedia.paused = true; };
window.qaQuiz = { clicks: [], selected: [], submits: 0, correct: false, closes: 0, videoPausedAtSelection: false };
window.qaShowQuiz = () => {
const pane = document.createElement('div'); pane.className = 'cdk-overlay-pane question-modal-pc';
const texts = ['任意操作', '切换 mPa 单位', '保持 MPa 单位并核对'];
pane.innerHTML = '<div class="question"><div class="question_box fade_in"><div class="modal-dialog"><div class="modal-content"><div class="modal-body"><div class="examtest_content_question"><p class="show"><span>[单选]</span>压力达到 2 MPa 时应该怎样操作?</p><p class="hidden">[多选]隐藏模板</p><p class="hidden">[判断]隐藏模板</p></div><div class="examtest_content_list">' + texts.map((text, index) => '<div class="option-row"><div class="examtest_content_list_des"><div><span class="examtest_content_list_des_optionsort">' + String.fromCharCode(65 + index) + '</span>' + text + '</div></div></div>').join('') + '</div></div><div class="modal-footer"></div></div></div></div></div>';
document.querySelector('.cdk-overlay-container').append(pane);
const grade = () => {
qaQuiz.submits++;
qaQuiz.correct = JSON.stringify(qaQuiz.selected) === JSON.stringify(['保持 MPa 单位并核对']);
pane.innerHTML = '<div class="answer"><div class="answer_box fade_in"><div class="modal-dialog"><div class="modal-content"><div class="modal-body"><div class="model_dec"><span>网站判分</span><span class="model_dec_span">' + (qaQuiz.correct ? '回答正确' : '回答错误') + '</span></div><div class="model_answer_wrapper"><div class="model_answer">参考答案:保持 MPa 单位并核对</div></div></div><div class="modal-footer"><button type="button"><span aria-hidden="true">关闭</span></button></div></div></div></div></div>';
pane.querySelector('button').addEventListener('click', () => { qaQuiz.closes++; pane.remove(); });
};
pane.querySelectorAll('.option-row').forEach((row, index) => row.addEventListener('click', () => {
qaQuiz.clicks.push({ label: String.fromCharCode(65 + index), text: texts[index] });
qaQuiz.videoPausedAtSelection = qaMedia.paused;
qaQuiz.selected = [texts[index]];
pane.querySelectorAll('.examtest_content_list_des').forEach((node, i) => node.classList.toggle('examtest_content_list_des_checked', i === index));
const footer = pane.querySelector('.modal-footer');
if (!footer.querySelector('button')) { const button = document.createElement('button'); button.type = 'button'; button.innerHTML = '<span aria-hidden="true">查看答案</span>'; button.addEventListener('click', grade); footer.append(button); }
if (window.qaPauseAfterSelection) { qaPauseAfterSelection = false; YianAssistant.taskAction('pause'); }
}));
};
});
const sandbox = '(function(){const GM_getValue=(key,fallback)=>window.__gmRead(key,fallback);const GM_setValue=(key,value)=>window.__gmWrite(key,value);\n' + source + '\n})();';
await page.addScriptTag({ content: sandbox }); await page.evaluate(() => YianAssistant.ready);
const host = page.locator('#yian-question-assistant');
await host.getByRole('button', { name: '展开题库助手', exact: true }).click();
await host.getByTestId('tab-bank').click();
await host.locator('input[type=file]').setInputFiles({ name: '视频课中题.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify([{ course, type: '单选', question: '压力达到 2 MPa 时应该怎样操作?', options: ['保持 MPa 单位并核对', '切换 mPa 单位', '任意操作'].map((text, index) => ({ label: String.fromCharCode(65 + index), text })), answer: 'A' }])) });
await page.waitForFunction(() => YianAssistant.getSnapshot().total === 1);
assert.equal(await page.evaluate(() => YianAssistant.getSnapshot().records[0].role), '', '不带岗位的统一题库可导入');
await page.evaluate(async () => {
await YianAssistant.setLearningSetting('mode', 'videoQuiz');
await YianAssistant.setLearningSetting('rate', 2);
await YianAssistant.setLearningSetting('answerInterval', 0);
await YianAssistant.taskAction('start');
});
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'running' && !qaMedia.paused && qaMedia.playbackRate === 2);
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().total), 1);
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 0);
console.log('PASS 完整 bundle:无岗位题库导入、videoQuiz 调度、独立播放器真实 2X 状态');
const beforeTabs = await page.evaluate(() => ({ plays: qaMedia.playCalls, pauses: qaMedia.pauseCalls, task: YianAssistant.getLearningState().tasks[0].id }));
for (const key of ['overview', 'video', 'exam', 'bank', 'console', 'settings', 'video']) await host.getByTestId('tab-' + key).click();
const afterTabs = await page.evaluate(() => ({ plays: qaMedia.playCalls, pauses: qaMedia.pauseCalls, task: YianAssistant.getLearningState().tasks[0].id }));
assert.deepEqual(afterTabs, beforeTabs, '切栏不得重新播放、暂停或领取新任务');
await host.getByTestId('global-pause').click();
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'paused' && qaMedia.paused);
await page.evaluate(async () => {
await YianAssistant.setLearningSetting('stallSeconds', 1);
await YianAssistant.setLearningSetting('retryDelaySeconds', 1);
qaShowQuiz();
});
const pausedPlays = await page.evaluate(() => qaMedia.playCalls);
await page.waitForTimeout(2400);
assert.equal(await page.evaluate(() => qaMedia.playCalls), pausedPlays, '全局暂停后不能自动续播');
assert.equal(await page.evaluate(() => qaQuiz.clicks.length + qaQuiz.submits), 0, '暂停期间不操作课中题');
assert.equal(requests.length, 1, '暂停期间没有故障刷新或其他业务请求');
assert.equal(await page.locator('.question_box').count(), 1);
await page.evaluate(async () => {
await YianAssistant.setLearningSetting('stallSeconds', 60);
await YianAssistant.setLearningSetting('retryDelaySeconds', 15);
window.qaPauseAfterSelection = true;
});
await host.getByTestId('global-pause').click();
await page.waitForFunction(() => qaQuiz.clicks.length === 1 && YianAssistant.getLearningState().status === 'paused');
await page.waitForTimeout(400);
assert.equal(await page.evaluate(() => qaQuiz.submits), 0, '选中后暂停必须取消后续查看答案/提交');
assert.equal(await page.evaluate(() => qaMedia.paused), true);
await host.getByTestId('global-pause').click();
await page.waitForFunction(() => qaQuiz.closes === 1 && !qaMedia.paused);
const quiz = await page.evaluate(() => qaQuiz);
assert.deepEqual(quiz.clicks, [{ label: 'C', text: '保持 MPa 单位并核对' }], '原库 A 按正确文字映射到当前 C,保留 MPa/mPa 大小写差异');
assert.deepEqual(quiz.selected, ['保持 MPa 单位并核对']);
assert(quiz.videoPausedAtSelection, '课中答题前实际暂停播放器');
assert.equal(quiz.submits, 1); assert.equal(quiz.correct, true); assert.equal(quiz.closes, 1);
assert.equal(await page.locator('.question_box,.answer_box,.question-modal-pc').count(), 0);
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 0);
assert(await page.evaluate(() => qaMedia.playCalls) > pausedPlays, '正常关闭课中题后继续播放器');
console.log('PASS 六栏不影响任务;全局暂停停止课中答题和恢复;继续后真实 portal 全文选中→查看答案→网站判正确→关闭→续播');
await page.evaluate(() => { qaMedia.currentTime = qaMedia.duration; qaMedia.ended = true; document.querySelector('.menu_courseware_left').textContent = '已学习90秒'; });
await page.waitForFunction(() => YianAssistant.getLearningState().tasks[0].status === 'waitingPlatform');
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().completed), 0, '播放到末尾但平台未确认时不得完成');
await host.getByTestId('tab-overview').click();
await page.screenshot({ path: path.join(qaDir, '01-waiting-platform.png') });
await page.evaluate(() => {
document.querySelector('app-course-courseware-video').setAttribute('data-is-learned', 'true');
document.querySelector('.menu_courseware_left').textContent = '已学习100秒';
});
await page.waitForFunction(() => YianAssistant.getLearningState().completed === 1);
assert.equal(await page.evaluate(() => YianAssistant.getLearningState().tasks[0].status), 'completed');
await page.waitForFunction(() => YianAssistant.getLearningState().status === 'finished');
await page.screenshot({ path: path.join(qaDir, '02-platform-completed.png') });
await host.getByTestId('tab-video').click();
assert(await host.getByTestId('pane-video').textContent().then(text => text.includes('已确认完成')), '视频栏必须同步平台已完成状态');
assert.equal(requests.length, 1); assert.deepEqual(blocked, []); assert.deepEqual(errors, []);
assert.equal(await page.locator('vite-error-overlay,nextjs-portal,#webpack-dev-server-client-overlay').count(), 0);
console.log('PASS 视频末尾等待平台;明确 data-is-learned 后完成任务并更新面板;零业务网络/刷新、无控制台错误');
console.log('截图:' + qaDir);
} finally { await browser.close(); }
}
main().catch(error => { console.error(error); process.exitCode = 1; });
+92
View File
@@ -0,0 +1,92 @@
/* Full userscript workflow, simulated business URL only: no business requests. */
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const root = path.resolve(__dirname, '..');
const script = fs.readFileSync(path.join(root, '谊安学助手 2.0.user.js'), 'utf8');
function fixture() {
const questions = Array.from({ length: 25 }, (_, i) => `<div app-exam-question><div class="pc_examtest_content"><div class="pc_examtest_content_question"><p>[单选] ${i + 1}. 联动验证题${i}<span class="pc_examtest_content_question_value">(4分)</span></p></div><div class="pc_examtest_content_list"><div class="pc_examtest_content_list_des"><span class="examtest_content_list_des_optionsort">A</span>错误${i}</div><div class="pc_examtest_content_list_des"><span class="examtest_content_list_des_optionsort">B</span>正确${i}</div></div></div></div>`).join('');
return `<!doctype html><meta charset="utf-8"><title>助手完整流程验证</title><style>body{font:14px system-ui}.pc_examtest_left{max-width:700px}.pc_examtest_content{padding:8px}.pc_examtest_content_list_des{padding:5px;cursor:pointer}.examtest_content_list_des_checked{background:#ded}.modal[aria-hidden=true]{display:none}</style><app-examtest><div class="pc_examtest"><div class="pc_examtest_left"><div class="pc_examtest_title">完整流程考试</div>${questions}</div><div class="pc_examtest_card">${Array.from({ length: 25 }, (_, i) => `<div class="examtestcard_count_defult">${i + 1}</div>`).join('')}</div><div class="pc_examtest_countdown"><span class="time">00:30:00</span></div><div class="pc_examtest_commit">交卷</div></div></app-examtest><div role="dialog" class="modal" aria-hidden="true"><p>仍有题目未答,是否确认交卷?</p><button>确认交卷</button><button>取消</button></div><div data-exam-result hidden>交卷成功,成绩 84</div>`;
}
(async () => {
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || 'chrome', headless: true });
try {
const memory = new Map(), errors = [], blocked = [];
const context = await browser.newContext({ viewport: { width: 1366, height: 900 } });
await context.exposeBinding('__read', (_, key, fallback) => memory.has(key) ? structuredClone(memory.get(key)) : fallback);
await context.exposeBinding('__write', (_, key, value) => memory.set(key, structuredClone(value)));
const url = 'http://10.119.67.7/clientcn/exam/workflow/questions?flag=test&totaltime=30&title=' + encodeURIComponent('完整流程考试');
await context.route('**/*', route => {
if (route.request().isNavigationRequest() && route.request().url() === url) return route.fulfill({ status: 200, contentType: 'text/html;charset=utf-8', body: fixture() });
blocked.push(route.request().url()); return route.abort();
});
const page = await context.newPage();
page.on('pageerror', error => errors.push(error.message));
await page.goto(url);
await page.evaluate(() => {
window.offset = 0; const nativeNow = Date.now.bind(Date); Date.now = () => nativeNow() + window.offset;
window.advanceExamTime = milliseconds => {
window.offset += milliseconds;
const remaining = Math.max(0, 1800 - Math.floor(window.offset / 1000));
document.querySelector('.pc_examtest_countdown .time').textContent = '00:' + String(Math.floor(remaining / 60)).padStart(2, '0') + ':' + String(remaining % 60).padStart(2, '0');
};
window.commitClicks = 0; window.confirmClicks = 0;
document.querySelectorAll('.pc_examtest_content_list_des').forEach(node => node.addEventListener('click', () => {
node.parentElement.querySelectorAll('.pc_examtest_content_list_des').forEach(n => n.classList.remove('examtest_content_list_des_checked'));
node.classList.add('examtest_content_list_des_checked');
}));
document.querySelector('.pc_examtest_commit').addEventListener('click', () => {
window.commitClicks++; document.querySelector('[role=dialog]').setAttribute('aria-hidden', 'false');
});
document.querySelector('[role=dialog] button').addEventListener('click', () => {
window.confirmClicks++; document.querySelector('[role=dialog]').setAttribute('aria-hidden', 'true'); document.querySelector('[data-exam-result]').hidden = false;
});
});
await page.addScriptTag({ content: '(function(){const GM_getValue=(k,v)=>window.__read(k,v);const GM_setValue=(k,v)=>window.__write(k,v);\n' + script + '\n})();' });
await page.evaluate(() => YianAssistant.ready);
await page.evaluate(() => { YianAssistant.getState(); });
const records = Array.from({ length: 21 }, (_, i) => ({ role: i % 2 ? '主操' : '班长', course: '流程主题库', type: '单选', number: i + 1,
question: '联动验证题' + i, options: [{ label: 'A', text: '正确' + i }, { label: 'B', text: '错误' + i }], answer: 'A' }));
await page.evaluate(async records => {
const bytes = new TextEncoder().encode(JSON.stringify(records));
await YianAssistant.importFiles([{ name: '统一流程.json', size: bytes.length, arrayBuffer: async () => bytes.buffer }]);
await YianAssistant.setLearningSetting('mode', 'exam');
await YianAssistant.setLearningSetting('answerInterval', 0);
}, records);
await page.waitForFunction(() => YianAssistant.getState().pageReady);
assert.equal(await page.evaluate(() => YianAssistant.setAutoAnswer(true)), false, '没有明确主题关联不能答题');
await page.evaluate(async () => {
await YianAssistant.setLearningSetting('associations', { 完整流程考试: ['流程主题库'] });
await YianAssistant.taskAction('start');
});
await page.waitForFunction(() => YianAssistant.getState().metrics.selected === 21, null, { timeout: 15000 });
await page.waitForFunction(() => YianAssistant.getExamState().predictedScore === 84);
assert.equal(await page.evaluate(() => window.commitClicks), 0);
await page.getByTestId('tab-bank').click();
await page.getByRole('textbox', { name: '搜索题目', exact: true }).fill('联动验证题0');
await page.getByRole('button', { name: '搜索', exact: true }).click();
assert.equal(await page.getByRole('switch', { name: '导出题库', exact: true, includeHidden: true }).isChecked(), false);
await page.getByTestId('tab-console').click();
assert.equal(await page.evaluate(() => YianAssistant.getExamState().enabled), true, '切栏不取消交卷授权');
await page.evaluate(() => window.advanceExamTime(4 * 60000));
await page.waitForTimeout(750);
assert.equal(await page.evaluate(() => window.commitClicks), 0, '4分钟不得交卷');
await page.evaluate(() => window.advanceExamTime(2 * 60000 + 500));
await page.waitForFunction(() => YianAssistant.getExamState().submitted === true, null, { timeout: 8000 }).catch(async error => {
console.error(await page.evaluate(() => ({ stage: YianAssistant.getExamState().stage, message: YianAssistant.getExamState().message,
score: YianAssistant.getExamState().predictedScore, tasks: YianAssistant.getLearningState().tasks,
commits: window.commitClicks, confirms: window.confirmClicks, ready: YianAssistant.getContext().ready })));
throw error;
});
assert.equal(await page.evaluate(() => window.commitClicks), 1);
assert.equal(await page.evaluate(() => window.confirmClicks), 1);
await page.waitForFunction(() => YianAssistant.getLearningState().completed === 1);
await page.waitForTimeout(1300);
assert.equal(await page.evaluate(() => window.commitClicks), 1);
assert.deepEqual(errors, []);
assert.deepEqual(blocked, []);
console.log('PASS 完整用户脚本:无主题阻止答题→明确关联→统一题库21题全文回读=84分→切栏不扰动→4分钟等待→6分钟正常交卷→网站确认→任务完成,零业务请求');
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });