diff --git a/README.md b/README.md index aabd4f8..a003114 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 谊安学助手 2.0 -当前发布版本 **2.1.0**。一个可直接安装的油猴脚本,集成统一题库、六栏面板、视频与考试任务、答案全文核对、复制和题库导出。题库本地保存,内置 ZIP 解析,无 CDN 依赖。 +当前发布版本 **2.1.1**。一个可直接安装的油猴脚本,集成统一题库、六栏面板、视频与考试任务、答案全文核对、复制和题库导出。题库本地保存,内置 ZIP 解析,无 CDN 依赖。 ## 安装与启动 @@ -18,7 +18,7 @@ |---|---| | 任务总览 | 四种任务模式、顺序/并行、任务清单、当前任务、总进度及预计剩余时间 | | 视频学习 | 实际倍速、播放进度、平台学习记录、课中题、局部暂停与恢复 | -| 考试答题 | 自动选择开关、答案核对、答题间隔、预计得分、考试计时和交卷状态;兼容原练习页 | +| 考试答题 | 自动选择开关、缺题人工确认开关、答案核对、答题间隔、预计得分、考试计时和交卷状态;兼容原练习页 | | 题库搜索 | 统一题库导入、关键词搜索、课程筛选、选中文字搜索、来源及主题关联 | | 控制台 | 最近 500 条运行日志、级别筛选、复制、清空显示 | | 其他设置 | 允许复制开关、默认关闭的题库导出折叠区、异常恢复参数 | @@ -56,13 +56,20 @@ 只有以下条件同时成立才自动交卷: -1. 主题关联明确,整卷及答题卡完整,所有可匹配题已处理并回读核验。 +1. 主题关联明确,网页整卷及答题卡完整,所有可匹配题均完成作答且当前选中答案全文回读核验通过;并满足下述缺题人工确认开关规则。 2. 按网页每题实际分值累计的可核验预计得分 **严格大于 80 分**;正好 80 分也等待人工。 3. 达到最早交卷时长,试卷仍有效、未暂停、未结束,交卷入口及确认流程可以唯一识别。 -未知题计 0 分且不猜答,多选必须整个集合一致才计分;题目分值或 100 分分制无法确认则等待人工。预计得分来自本地题库,不是官方成绩,也不保证真实得分。可有少量未知题,但已核验分值必须严格超过 80。 +“考试答题”栏提供 **“题目不全时手动确认交卷”开关,默认开启**(`manualConfirmIncomplete`): -提交前再次检查当前试卷、题库版本和选中全文;只走当前场次正常交卷及确认,网站显示成功结果才计完成。提交结果不明停止重复点击。暂停脚本不会暂停网站倒计时;网站剩余时间不足、身份验证或异常流程需人工处理。 +- **开启:** 每一道题都须唯一精确匹配且当前选中全文核验通过;有未匹配、冲突、未核验、无法唯一映射或不支持的题目时,即使已核验 84 分并满 6 分钟,也等待用户在网站手动确认。网站正常未答完提醒不自动确认。 +- **关闭:** 恢复部分题库自动交卷。网页整卷完整、全部可匹配题处理并全文核验、已核验预计得分严格大于 80 分且满 6 分钟,并满足其他通用条件后,可保留未知题自动交卷及确认本场正常未答完提醒。未知题仍不猜答、不计正分。 + +切换开关会暂停当前答题和交卷计划,须明确继续并重新核验后执行,关闭开关不会立即提交。设置跨页面和刷新保存。网页题目或答题卡加载不全、分值或 100 分分制不明、主题不明确、可匹配题未核验、预计得分不超过 80 分、计时未到等通用门槛,在任何开关状态下都不放行。 + +未知题计 0 分且不猜答,人工补选的未知答案不自动计为正确,多选必须整个集合一致才计分。预计得分来自本地题库,不是官方成绩,也不保证真实得分。完整 100 分且每题核验通过后,仍需满足 6 分钟等其余条件才自动交卷。 + +提交前再次检查当前试卷、题库版本、整卷完整性、选中全文和开关;只走当前场次正常交卷及确认,网站显示成功结果才计完成。提交结果不明停止重复点击。暂停脚本不会暂停网站倒计时;网站剩余时间不足、身份验证或异常流程需人工处理。 ## 导出与离线工具 @@ -78,7 +85,7 @@ npm test npm run merge:banks ``` -测试依赖 Node.js 和 Playwright/Chrome;使用本机模拟页面及中和后的保存 DOM,所有业务 URL 由测试拦截,未访问业务服务器。覆盖统一题库迁移/来源/冲突、真实 40 题全文乱序、题间隔、80/84 分与 4/6 分钟边界、交卷结果、防重复、视频恢复、并行心跳、六栏及拖拽/窄屏、复制/导出/站点范围和完整任务流程。 +测试依赖 Node.js 和 Playwright/Chrome;使用本机模拟页面及中和后的保存 DOM,所有业务 URL 由测试拦截,未访问业务服务器。验证范围包括统一题库迁移/来源/冲突、真实 40 题全文乱序、题间隔、80 分门槛与 4/6 分钟边界、缺题人工确认开关两种模式、设置保存及切换暂停、加载不全等通用门槛、整卷 100 分核验后的交卷流程、防重复、视频恢复、并行心跳、六栏及拖拽/窄屏、复制/导出/站点范围和完整任务流程;本次结果以实际测试记录为准。 源代码按职责拆分:`bank-store.js`、`answer-matcher.js`、`answer-runner.js`、`exam-controller.js`、`quiz-adapter.js`、`learning-engine.js`、`assistant-panel.js`、`practice-exporter.js`。发布仅需构建后的单个 `.user.js`。 diff --git a/assistant-panel.js b/assistant-panel.js index d7e239a..bc79736 100644 --- a/assistant-panel.js +++ b/assistant-panel.js @@ -22,7 +22,7 @@ bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别题目页面', pageMode: '', canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false, position: null, activeTab: 'overview', uniqueTotal: 0, conflictTotal: 0, - learning: { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: {}, exam: {}, logs: [], message: '' } + learning: { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: {}, exam: {}, logs: [], message: '' } }; let destroyed = false; let selectionText = ''; @@ -440,6 +440,16 @@ }); return input; } + function learningSwitch(container, key, label, fallback) { + const control = makeSwitch(label, function () { + state.learning[key] = control.input.checked; + invoke('onLearningSetting', key, control.input.checked); + refreshLearning(); + }, container); + control.input.setAttribute('data-testid', 'setting-' + key); + learningControls[key] = { input: control.input, fallback: fallback, switch: true }; + return control.input; + } function taskButton(container, label, action, className) { const button = el(doc, 'button', className || '', label); button.type = 'button'; button.setAttribute('data-testid', 'task-' + action); @@ -483,13 +493,17 @@ const examModeNote = el(doc, 'p', 'mode-note'); examIntro.appendChild(examModeNote); learningSetting(examIntro, 'answerInterval', '答题间隔(秒)', null, 4, [0, 120, 0.5]); learningSetting(examIntro, 'minSubmitMinutes', '最早交卷(分)', null, 6, [0.5, 180, 0.5]); + learningSwitch(examIntro, 'manualConfirmIncomplete', '题目不全时手动确认', true); const examInfo = el(doc, 'dl', 'info-grid'); const examMessage = el(doc, 'p', 'explanation'); + const examThreshold = el(doc, 'p', 'threshold'); + const incompleteHint = el(doc, 'p', 'hint'); + examThreshold.setAttribute('data-testid', 'exam-threshold'); + incompleteHint.setAttribute('data-testid', 'incomplete-confirmation-hint'); const examActions = el(doc, 'div', 'task-actions'); const examPause = taskButton(examActions, '暂停考试任务', 'examPause'); const examResume = taskButton(examActions, '继续考试任务', 'examResume'); - examIntro.append(examInfo, el(doc, 'p', 'threshold', '预计得分必须严格大于 80 分;正好 80 分等待人工。'), examMessage, examActions, - el(doc, 'p', 'hint', '仅已精确匹配、点击并核验完整答案文字的题目计分。进入原试卷满设定时长、核验完成且满足条件后才自动交卷;预计分数以本地题库为依据。练习与课中题不使用考试交卷门槛。')); + examIntro.append(examInfo, examThreshold, examMessage, examActions, incompleteHint); views.exam.insertBefore(examIntro, switches); const bankCounts = el(doc, 'p', 'bank-counts'); bankSection.appendChild(bankCounts); @@ -692,8 +706,17 @@ } Object.keys(learningControls).forEach(function (key) { const setting = learningControls[key]; - if (root.activeElement !== setting.input) setting.input.value = plain(learning[key] == null ? setting.fallback : learning[key]); + if (setting.switch) setting.input.checked = learning[key] == null ? setting.fallback : learning[key] !== false; + else if (root.activeElement !== setting.input) setting.input.value = plain(learning[key] == null ? setting.fallback : learning[key]); }); + const manualConfirmIncomplete = learning.manualConfirmIncomplete !== false; + setText(examThreshold, manualConfirmIncomplete + ? '必须整卷每题核验且预计得分严格大于 80 分;题目不全时由用户在原网页手动确认交卷。' + : '允许题库部分缺题时自动交卷:已核验预计得分严格大于 80 分,所有可匹配题均已核验,且已满最早交卷时间。'); + setText(incompleteHint, (manualConfirmIncomplete + ? '本开关已开启:存在未匹配、冲突或未核验题时,即使超过 80 分、已满交卷时长也不自动交卷;网页提示未答完时同样等待人工。' + : '本开关已关闭:仅已精确匹配、点击并核验完整答案文字的题目计分,其余题目不计分;满足得分、计时与当前试卷核验条件后可自动交卷。') + + '切换会暂停当前计划,请在任务总览点击继续重新核验。无论开关状态,试卷未完整加载均禁止自动交卷;预计得分正好 80 分等待人工。练习与课中题不使用考试交卷门槛。'); const videoEnabled = learning.mode !== 'exam'; setText(videoModeNote, videoEnabled ? '' : '本模式未启用视频;可查看设置与记录。'); videoModeNote.hidden = videoEnabled; const examEnabled = learning.mode === 'exam' || learning.mode === 'all' || !learning.mode; @@ -709,6 +732,8 @@ videoPause.disabled = !videoHasTask || ['paused', 'completed', 'done', 'idle'].includes(video.status || 'idle'); videoResume.disabled = !videoHasTask || !['paused', 'waiting', 'waitingHuman', 'failed', 'error'].includes(video.status); infoRows(examInfo, [['考试主题', examTitle], ['题库预检', plain(firstValue(exam, ['preflightMessage', 'themeStatus', 'preflight'], '尚未预检'))], + ['全文核验', exam.total == null ? '待确认' : plain(exam.verifiedCount || 0) + ' / ' + plain(exam.total) + ' 题'], + ['交卷确认', exam.submitted || exam.status === 'submitted' ? '网站已确认交卷' : exam.requiresManualSubmission ? '题目未全部核验或网站提示未答完,请在原网页手动确认' : manualConfirmIncomplete ? '整卷每题核验后才按条件自动交卷' : '已核验得分超过 80 分并满最早交卷时间后才按条件自动交卷'], ['预计得分', firstValue(exam, ['predictedScore', 'score'], null) == null ? '待核算' : plain(firstValue(exam, ['predictedScore', 'score'], 0)) + ' 分(题库估计)'], ['网页剩余', duration(firstValue(exam, ['remainingSeconds', 'remainingTime'], null))], ['进入试卷', duration(firstValue(exam, ['elapsedSeconds', 'elapsedTime'], null))], ['最早交卷', duration(firstValue(exam, ['submitWaitSeconds', 'waitSeconds'], null))], ['交卷状态', statusName(exam.status || 'idle')]]); diff --git a/build-userscript.mjs b/build-userscript.mjs index ab3e3c6..b7eb369 100644 --- a/build-userscript.mjs +++ b/build-userscript.mjs @@ -3,7 +3,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; const metadata = `// ==UserScript== // @name 谊安学助手 2.0 // @namespace yianxue-question-bank -// @version 2.1.0 +// @version 2.1.1 // @description 谊安学助手:统一题库、六栏面板、视频任务与考试全文核验。 // @match http://10.119.67.7/clientcn/* // @run-at document-idle diff --git a/exam-controller.js b/exam-controller.js index f282381..351cc8b 100644 --- a/exam-controller.js +++ b/exam-controller.js @@ -54,7 +54,9 @@ const complete = bank.expectedTotal > 0 && bank.expectedTotal === bank.total && boxes.length === bank.total; // The confirmed threshold is 80 raw points on the supplied 100-point paper. const scoreValid = complete && validPoints && Math.abs(totalScore - 100) < 0.000001; + const fullyVerified = complete && matchedCount === bank.total && verifiedCount === bank.total; return { title: bank.title, total: bank.total, complete, totalScore, predictedScore, matchedCount, verifiedCount, + fullyVerified, unverifiedCount: Math.max(0, bank.total - verifiedCount), missing: details.filter(item => item.status === 'missing').length, conflict: details.filter(item => item.status === 'conflict').length, scoreValid, details, signature: JSON.stringify([bank.title, bank.expectedTotal, bank.questions.map((q, index) => [q.type, q.question, q.options.map(o => [o.label, o.text]), details[index].points])]), @@ -97,6 +99,10 @@ return /"(?:attemptid|examattemptid|answerrecordid|examrecordid|paperrecordid|recordid|data-attempt-id|data-exam-attempt-id|data-answer-record-id|data-exam-record-id)"/.test(keyFor(ctx)); } function settings() { return options.getSettings ? options.getSettings() : {}; } + function manualIncomplete() { return settings().manualConfirmIncomplete !== false; } + function scopeValue(ctx, evidence, topic) { + return JSON.stringify([keyFor(ctx), ctx.revision, evidence.signature, topic?.courses, manualIncomplete()]); + } function resultVisible() { return Array.from(doc.querySelectorAll('.exam_submited_box,.exam-result,app-exam-result,[data-exam-result]')) .some(node => visible(node) && /提交成功|交卷成功|考试结束|得分|成绩/.test(node.textContent)); @@ -152,20 +158,29 @@ enabled = false; await observe(); if (token !== generation || !sameContext(requested, context()) || !entry || ['submitting', 'confirming', 'unknown'].includes(stage) || destroyed || resultVisible()) return false; + if (entry.manualRequired && stage !== 'awaitingAttempt' && manualIncomplete()) { + stage = 'waiting'; error = '网站提示存在未作答题,请在原网页手动确认交卷'; + emit({ requiresManualSubmission: true, message: error }); return false; + } const ctx = context(), topic = options.getTopic(); if (!topic || topic.status !== 'matched') { error = topic?.reason || '请先确认考试主题与题库关联'; emit({ message: error }); return false; } const evidence = readPaper(doc, ctx, options.getSnapshot(), topic); if (!evidence.complete || !evidence.scoreValid || !ctx.ready || !ctx.exam || ctx.submitted) { emit({ message: evidence.reason || '等待整卷加载完成后启动考试任务' }); return false; } + if (Array.from(doc.querySelectorAll('.dialog_box,[role="dialog"],.modal')).some(visible)) { + emit({ message: '请先在网页取消或关闭当前交卷对话框,再继续任务' }); return false; + } if (stage === 'awaitingAttempt') { const timer = timerEvidence(ctx); entry = { startedAt: timer.startedAt, initialRemaining: timer.remain, lastRemaining: timer.remain, lastObservedAt: timer.stamp, stage: 'idle' }; } - scope = JSON.stringify([currentKey, ctx.revision, evidence.signature, topic.courses]); + if (!manualIncomplete() && entry.manualRequired) { entry.manualRequired = false; entry.confirmed = false; delete entry.submitAt; } + scope = scopeValue(ctx, evidence, topic); enabled = true; stage = 'answering'; error = ''; entry.stage = stage; entry.paperSignature = evidence.signature; await persist(); if (token !== generation || destroyed || !enabled || !sameContext(ctx, context()) || !context().ready || options.getTopic()?.status !== 'matched' || scope !== scopeFor(context())) { if (token === generation) disable('启动过程中页面、题库或任务状态已变化,请重新启动'); return false; } - log('当前考试已授权:全文核验后严格大于 80 分且达到最早交卷时间才交卷'); + log(manualIncomplete() ? '当前考试已授权:题目不全时由用户在网页确认;整卷核验后按得分和时长交卷' + : '题目不全时手动确认已关闭:全部可匹配题核验后,严格大于 80 分且达到最早时长才交卷'); return true; } function disable(reason) { @@ -177,7 +192,7 @@ } function scopeFor(ctx) { const topic = options.getTopic(), evidence = readPaper(doc, ctx, options.getSnapshot(), topic); - return JSON.stringify([keyFor(ctx), ctx.revision, evidence.signature, topic?.courses]); + return scopeValue(ctx, evidence, topic); } function canClick(token, ctx, confirmation) { if (token !== generation || destroyed || !enabled || !sameContext(ctx, context())) return false; @@ -185,8 +200,9 @@ const configured = Number(settings().minSubmitMinutes), minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360; const pendingTooLong = confirmation && now() - entry.submitAt > 12000; return current.mode === 'test' && current.root?.isConnected && current.ready && topic?.status === 'matched' - && scope === JSON.stringify([keyFor(current), current.revision, evidence.signature, topic.courses]) - && evidence.scoreValid && evidence.matchedCount === evidence.verifiedCount && evidence.predictedScore > 80 + && scope === scopeValue(current, evidence, topic) + && evidence.scoreValid && evidence.matchedCount === evidence.verifiedCount + && (!manualIncomplete() || evidence.fullyVerified && !entry.manualRequired) && evidence.predictedScore > 80 && options.getRunnerState() === 'complete' && (now() - entry.startedAt) / 1000 >= minimum && remainingSeconds(doc) !== 0 && !pendingTooLong && (confirmation || current.exam && !current.submitted); } @@ -194,6 +210,9 @@ const dialogs = Array.from(doc.querySelectorAll('.dialog_box,[role="dialog"],.modal')).filter(visible); const containers = dialogs.filter(node => !dialogs.some(other => other !== node && other.contains(node))); if (!containers.length) return { pending: true }; + if (manualIncomplete() && containers.some(node => /未答|未作答|没有作答|尚未作答|漏答|未完成.*题|题.*未完成/.test(clean(node.textContent)))) { + return { manualRequired: true, reason: '网站提示存在未作答题,请在原网页手动确认交卷' }; + } const matches = containers.filter(node => /(?:确认|确定|是否).*交卷|交卷.*(?:确认|确定)|未答.*(?:提交|交卷)|(?:提交|交卷).*未答/.test(clean(node.textContent))); if (matches.length !== 1 || /验证|人脸|异常|失败|重新考试/.test(matches[0].textContent)) return { reason: '交卷对话框无法唯一确认,请人工检查' }; const buttons = Array.from(matches[0].querySelectorAll('button,[role="button"],.btn,.submited_btn')) @@ -212,7 +231,7 @@ if (resultVisible()) { stage = 'submitted'; entry.stage = stage; enabled = false; await persist(); if (token !== generation || destroyed || !sameContext(ctx, context())) return last; - return emit({ message: '网站已显示考试结果', submitted: true }); + return emit({ message: '网站已显示考试结果', submitted: true, requiresManualSubmission: false }); } const topic = options.getTopic(), evidence = readPaper(doc, ctx, options.getSnapshot(), topic); const elapsedSeconds = Math.max(0, (now() - entry.startedAt) / 1000); @@ -220,11 +239,14 @@ const minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360; const waitSeconds = Math.max(0, Math.ceil(minimum - elapsedSeconds)); const remaining = remainingSeconds(doc); + const runner = options.getRunnerState(); entry.lastObservedAt = now(); entry.lastRemaining = remaining; emit(Object.assign({}, evidence, { elapsedSeconds: Math.floor(elapsedSeconds), waitSeconds, remainingSeconds: remaining, + manualConfirmIncomplete: manualIncomplete(), + requiresManualSubmission: manualIncomplete() && (Boolean(entry.manualRequired) || runner === 'complete' && !evidence.fullyVerified), predictedScore: Math.round(evidence.predictedScore * 100) / 100, submitted: false, message: error || evidence.reason })); if (!enabled) return last; - if (scope !== JSON.stringify([currentKey, ctx.revision, evidence.signature, topic?.courses]) || topic?.status !== 'matched') { + if (scope !== scopeValue(ctx, evidence, topic) || topic?.status !== 'matched') { disable('试卷、题库或主题关联已变更,请重新启动核验'); return last; } if (remaining === 0 || ctx.submitted && !['submitting', 'confirming'].includes(stage)) { disable('网站考试已经结束或提交'); return last; } @@ -233,7 +255,6 @@ if (token !== generation || destroyed || !sameContext(ctx, context())) return last; return emit({ message: '提交结果未确认,已停止重复提交,请检查网站状态' }); } - const runner = options.getRunnerState(); const allVerified = evidence.matchedCount === evidence.verifiedCount; if (!evidence.scoreValid || !allVerified || runner !== 'complete') { if (stage === 'confirming' || stage === 'submitting') { @@ -241,14 +262,28 @@ if (token !== generation || destroyed || !sameContext(ctx, context())) return last; return emit({ message: '交卷过程中核验条件变化,等待人工检查' }); } + if (runner === 'complete') { + enabled = false; stage = 'waiting'; error = evidence.reason || '题目未全部核验,请在网页检查未核验题并手动确认交卷'; + return emit({ requiresManualSubmission: true, message: error }); + } stage = 'answering'; return emit({ message: evidence.reason || '等待所有可匹配题处理并回读核验' }); } + if (manualIncomplete() && !evidence.fullyVerified) { + enabled = false; stage = 'waiting'; + error = '题目未全部核验(' + evidence.verifiedCount + '/' + evidence.total + '),请在网页检查未核验题并手动确认交卷'; + log(error); return emit({ requiresManualSubmission: true, message: error }); + } if (!(evidence.predictedScore > 80)) { stage = 'waiting'; return emit({ message: '预计得分小于或等于 80 分,等待人工操作' }); } if (remaining !== null && remaining <= waitSeconds) { stage = 'waiting'; return emit({ message: '网站截止时间早于最早交卷时间,等待人工操作' }); } if (waitSeconds) { stage = 'waitingSubmit'; return emit({ message: '核验完成,等待最早交卷时间' }); } if (stage === 'confirming' || stage === 'submitting') { const target = confirmationButton(); if (target.pending) return last; + if (target.manualRequired) { + generation++; enabled = false; stage = 'waiting'; entry.stage = 'manualRequired'; entry.manualRequired = true; + error = target.reason; await persist(); log(error); + return emit({ requiresManualSubmission: true, message: error }); + } if (target.reason) { disable(target.reason); return last; } if (!entry.confirmed) { entry.confirmed = true; entry.stage = stage = 'confirming'; await persist(); diff --git a/learning-engine.js b/learning-engine.js index ef3908e..58439c1 100644 --- a/learning-engine.js +++ b/learning-engine.js @@ -168,8 +168,9 @@ let lastTime = null; let lastAdvancedAt = now(); let attemptedAt = 0; let playStarted = false; let interactionAt = -Infinity; let lastAutoPlayAt = -Infinity; let manualVideoPause = false; let watchedDocument = null; let sharedPaused = false; - const config = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, associations: {}, stallSeconds: 60, recoverySeconds: 15, maxRefreshes: 3, minRefreshSeconds: 30, videoPaused: false, examPaused: false }; - const state = { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: null, exam: null, logs: [], message: '先识别当前学习计划,再开始本轮任务' }; + let manualConfirmRevision = 0; let pendingManualConfirm = null; + const config = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, associations: {}, stallSeconds: 60, recoverySeconds: 15, maxRefreshes: 3, minRefreshSeconds: 30, videoPaused: false, examPaused: false }; + const state = { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: null, exam: null, logs: [], message: '先识别当前学习计划,再开始本轮任务' }; function snapshot() { const given = typeof options.getSnapshot === 'function' ? options.getSnapshot() || {} : {}; @@ -182,7 +183,7 @@ if (event.target && event.target.tagName === 'VIDEO' && interactionAt > lastAutoPlayAt && now() - interactionAt < 2000) manualVideoPause = true; } function notify() { - Object.assign(state, { mode: config.mode, execution: config.execution, rate: config.rate, answerInterval: config.answerInterval, minSubmitMinutes: config.minSubmitMinutes, videoPaused: config.videoPaused, examPaused: config.examPaused }); + Object.assign(state, { mode: config.mode, execution: config.execution, rate: config.rate, answerInterval: config.answerInterval, minSubmitMinutes: config.minSubmitMinutes, manualConfirmIncomplete: config.manualConfirmIncomplete, videoPaused: config.videoPaused, examPaused: config.examPaused }); const counted = state.tasks.filter(t => t.status !== 'expanded' && allows(t.kind)); state.total = counted.length; state.completed = counted.filter(t => t.status === 'completed').length; state.skipped = counted.filter(t => t.status === 'skipped').length; state.waiting = counted.filter(t => t.status === 'waiting').length; state.failed = counted.filter(t => t.status === 'failed').length; @@ -227,6 +228,7 @@ } const currentControl = await storage.get(CONTROL_KEY); if (currentControl && currentControl.sessionId !== session.id) return; + syncManualConfirmation(currentControl); session.config = clone(config); const signature = JSON.stringify([session.tasks, session.config, session.activeTaskId, session.status, session.accountId, session.planId, session.bankVersion]); if (signature !== saveSignature) { await storage.set(SESSION_KEY, clone(session)); saveSignature = signature; } } @@ -325,13 +327,30 @@ } else { state.message = '账号、学习计划或题库版本已变化,未恢复旧任务'; log('warn', state.message); } } if (worker && session) { const checkpoint = await storage.get('task:' + session.id + ':' + activeTaskId); const task = state.tasks.find(t => t.id === activeTaskId); if (checkpoint && task) Object.assign(task, checkpoint); } + if (session) syncManualConfirmation(await storage.get(CONTROL_KEY)); mergeTasks(discoverTasks(snap.document, snap.url)); notify(); } - async function control(status, force, explicitWorkerAction) { + function syncManualConfirmation(shared) { + if (!session || !shared || shared.sessionId !== session.id || !shared.config || pendingManualConfirm) return; + const revision = Number(shared.manualConfirmRevision) || 0; + if (revision < manualConfirmRevision || typeof shared.config.manualConfirmIncomplete !== 'boolean') return; + config.manualConfirmIncomplete = shared.config.manualConfirmIncomplete; state.manualConfirmIncomplete = config.manualConfirmIncomplete; manualConfirmRevision = revision; + session.config = Object.assign({}, session.config, { manualConfirmIncomplete: config.manualConfirmIncomplete }); + } + async function control(status, force, explicitWorkerAction, manualPatch) { if (!session || worker && !explicitWorkerAction) return; const current = await storage.get(CONTROL_KEY); if (!force && current && current.sessionId !== session.id) return; - await storage.set(CONTROL_KEY, { sessionId: session.id, status, config: clone(worker && current ? current.config : config), updatedAt: now(), owner: worker && current ? current.owner : owner }); + const sameSession = current && current.sessionId === session.id; + if (manualPatch) { + config.manualConfirmIncomplete = manualPatch.value; + manualConfirmRevision = Math.max(manualConfirmRevision, sameSession ? Number(current.manualConfirmRevision) || 0 : 0) + 1; + } else syncManualConfirmation(current); + const sharedConfig = clone(worker && sameSession ? current.config : config); + sharedConfig.manualConfirmIncomplete = config.manualConfirmIncomplete; + // 设置修改只发布策略,不得把另一页已经暂停的任务重新改成 running。 + await storage.set(CONTROL_KEY, { sessionId: session.id, status: manualPatch && sameSession ? current.status : status, config: sharedConfig, + manualConfirmRevision, updatedAt: now(), owner: worker && sameSession ? current.owner : owner }); } async function configure(patch) { await initialize(); patch = patch || {}; const wasRunning = state.status === 'running'; @@ -347,11 +366,15 @@ if (patch.associations && typeof patch.associations === 'object') config.associations = clone(patch.associations); if (typeof patch.videoPaused === 'boolean') config.videoPaused = patch.videoPaused; if (typeof patch.examPaused === 'boolean') config.examPaused = patch.examPaused; + const manualPatch = typeof patch.manualConfirmIncomplete === 'boolean' ? { value: patch.manualConfirmIncomplete } : null; + if (manualPatch) { pendingManualConfirm = manualPatch; config.manualConfirmIncomplete = manualPatch.value; } if (patch.clearLogs) state.logs = []; if (modeChanged && wasRunning) await pause('执行模式已改变,请核对任务后继续'); if (config.videoPaused) { const snap = snapshot(); const v = snap.document && describePage(snap.document, snap.url).video; if (v) v.pause(); } if (config.examPaused && typeof options.onExamPause === 'function') options.onExamPause('考试任务已局部暂停'); - await control(state.status); await persist(); notify(); return getState(); + try { await control(state.status, false, Boolean(manualPatch), manualPatch); } + finally { if (pendingManualConfirm === manualPatch) pendingManualConfirm = null; } + await persist(); notify(); return getState(); } async function start() { await initialize(); if (destroyed) return getState(); @@ -479,8 +502,26 @@ attemptedAt = 0; lastAdvancedAt = now(); } } + async function observeExamState(task, readOnly) { + // 仅观察已经交给本场控制器的考试,避免将旧场次结果附到尚未开考的任务。 + if (!task.examRequested) return; + const exam = typeof options.getExamState === 'function' ? options.getExamState() : null; + if (!exam) return; + state.exam = Object.assign({ taskId: task.id, title: task.title }, clone(exam)); + state.exam.total = exam.total == null && exam.details ? exam.details.length : exam.total; + state.exam.verified = exam.verifiedCount == null ? exam.verified : exam.verifiedCount; + const stage = exam.stage || exam.status; + if (stage === 'completed' || stage === 'submitted' || exam.submitted === true && exam.confirmed === true) { task.progress = 1; taskState(task, 'completed', '网站已确认本场考试提交完成'); activeTaskId = ''; await release(); } + else if (!readOnly && ['waiting', 'paused', 'failed', 'unknown', 'awaitingAttempt'].includes(stage)) taskState(task, 'waiting', exam.reason || exam.message || '考试等待人工处理'); + else if (!readOnly && stage === 'waitingSubmit') taskState(task, 'waitingSubmit', exam.reason || '已核验,等待最早交卷时间'); + } async function examTick(task, snap, page) { - if (config.examPaused) { taskState(task, 'waiting', '考试任务已局部暂停'); return; } + // 人工交卷后仍需接收网站确认;等待/局部暂停期间不得重新开考或启动答题。 + if (task.status === 'waiting' || config.examPaused) { + await observeExamState(task, true); + if (task.status !== 'completed' && config.examPaused) taskState(task, 'waiting', '考试任务已局部暂停'); + return; + } if (!['exam', 'paper'].includes(page.kind)) { const node = resolveNode(task, snap); if (node && !task.examLinkClickedAt) { @@ -511,16 +552,7 @@ } else taskState(task, 'waiting', '考试控制器尚未接入,请人工处理'); } - const exam = typeof options.getExamState === 'function' ? options.getExamState() : null; - if (exam) { - state.exam = Object.assign({ taskId: task.id, title: task.title }, clone(exam)); - state.exam.total = exam.total == null && exam.details ? exam.details.length : exam.total; - state.exam.verified = exam.verifiedCount == null ? exam.verified : exam.verifiedCount; - const stage = exam.stage || exam.status; - if (stage === 'completed' || stage === 'submitted' || exam.submitted === true && exam.confirmed === true) { task.progress = 1; taskState(task, 'completed', '网站已确认本场考试提交完成'); activeTaskId = ''; await release(); } - else if (['waiting', 'paused', 'failed', 'unknown', 'awaitingAttempt'].includes(stage)) taskState(task, 'waiting', exam.reason || exam.message || '考试等待人工处理'); - else if (stage === 'waitingSubmit') taskState(task, 'waitingSubmit', exam.reason || '已核验,等待最早交卷时间'); - } + await observeExamState(task, false); } async function parallelTick(snap, page) { if (config.execution !== 'parallel' || worker || !session) return; @@ -586,10 +618,13 @@ await initialize(); const snap = snapshot(); if (!snap.document) return getState(); const page = describePage(snap.document, snap.url); if (!safeURL(snap.url, snap.url)) { if (state.status === 'running') await pause('离开已授权站点,任务已暂停'); return getState(); } - if (session && (state.status === 'running' || sharedPaused)) { + if (session) { const controlValue = await storage.get(CONTROL_KEY); if (await observeControl(controlValue)) return getState(); - if (worker && controlValue && controlValue.config) Object.assign(config, controlValue.config); + if (worker && controlValue && controlValue.sessionId === session.id && controlValue.config) { + const sharedConfig = Object.assign({}, controlValue.config); delete sharedConfig.manualConfirmIncomplete; + Object.assign(config, sharedConfig); + } if (session.accountId && session.accountId !== account(snap, page) || session.bankVersion && snap.bankVersion && session.bankVersion !== snap.bankVersion) { await pause('账号或题库版本改变,已暂停旧任务'); return getState(); } } if (pageKey !== page.url) { pageKey = page.url; playStarted = false; lastTime = null; attemptedAt = 0; lastAdvancedAt = now(); } @@ -598,7 +633,7 @@ if (task.title !== page.title && canonical(task.url || task.pageUrl) !== page.url) taskState(task, 'waiting', '当前课程与已领取任务不同,请核对后继续'); else { mergeTasks(discoverTasks(snap.document, snap.url), task); taskState(task, 'expanded', '已读取课程目录;课件须逐一等待平台确认完成'); activeTaskId = ''; await release(); task = null; } } else mergeTasks(discoverTasks(snap.document, snap.url)); - if (state.status !== 'running') { notify(); return getState(); } + if (state.status !== 'running') { await persist(); notify(); return getState(); } if (task && TERMINAL.includes(task.status)) { activeTaskId = ''; await release(); task = null; } if (!task) { const candidates = state.tasks.filter(t => t.status === 'pending' && allows(t.kind) && (!worker || t.id === worker.taskId)); @@ -611,7 +646,7 @@ const held = await heartbeat(task) || await claim(task); if (!held) state.message = '等待同类任务租约释放,暂不操作当前页面'; else if (task.status !== 'waiting' && task.kind === 'video') await videoTick(task, snap, page, epoch); - else if (task.status !== 'waiting' && task.kind === 'exam') await examTick(task, snap, page); + else if (task.kind === 'exam') await examTick(task, snap, page); } if (epoch === generation) await parallelTick(snap, page); if (worker && session) { const request = await storage.get('worker:' + worker.nonce); if (request) { const own = task || state.tasks.find(t => t.id === worker.taskId); request.heartbeatAt = now(); request.status = own && own.status || 'idle'; request.video = state.video; request.exam = state.exam; await storage.set('worker:' + worker.nonce, request); } } @@ -623,8 +658,10 @@ finally { busy = false; } } async function observeControl(value) { - if (!session || state.status !== 'running' && !sharedPaused) return false; + if (!session) return false; const shared = value === undefined ? await storage.get(CONTROL_KEY) : value; + syncManualConfirmation(shared); + if (state.status !== 'running' && !sharedPaused) return false; if (sharedPaused && shared && shared.sessionId === session.id && shared.status === 'running') { sharedPaused = false; generation++; state.status = 'running'; session.status = 'running'; state.message = '已收到继续全部任务,重新核验当前页面'; playStarted = false; manualVideoPause = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now(); const active = state.tasks.find(t => t.id === activeTaskId); if (active) { active.examRequested = false; if (active.status === 'waiting') active.status = 'pending'; } diff --git a/package.json b/package.json index f27646c..0d91fd4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "yianxue-question-bank-exporter", - "version": "2.1.0", + "version": "2.1.1", "private": true, "description": "谊安学助手:统一题库、六栏面板、视频任务与考试全文核验", "scripts": { diff --git a/practice-exporter.js b/practice-exporter.js index acaeec5..9d60f39 100644 --- a/practice-exporter.js +++ b/practice-exporter.js @@ -16,7 +16,7 @@ let positionRevision = 0; let learning = null, examController = null, learningState = {}, examState = {}, runnerState = 'idle'; let actionEpoch = 0; - let learningSettings = { mode: 'videoQuiz', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, associations: {} }; + let learningSettings = { mode: 'videoQuiz', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, associations: {} }; const inScope = () => YianSiteScope.isAllowedURL(location.href); function topicPreflight() { @@ -195,10 +195,11 @@ }); async function changeLearningSetting(key, value) { - const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', 'associations', 'stallSeconds', 'retryDelaySeconds', 'maxRefresh']; + const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', 'manualConfirmIncomplete', 'associations', 'stallSeconds', 'retryDelaySeconds', 'maxRefresh']; if (!allowed.includes(key)) return; if (key === 'mode' && !['video', 'videoQuiz', 'exam', 'all'].includes(value)) throw new Error('请选择有效任务模式'); if (key === 'execution' && !['sequential', 'parallel'].includes(value)) throw new Error('请选择顺序或并行'); + if (key === 'manualConfirmIncomplete' && typeof value !== 'boolean') throw new Error('题目不全时手动确认须为开或关'); const ranges = { rate: [1, 2], answerInterval: [0, 120], minSubmitMinutes: [0.5, 180], stallSeconds: [1, 600], retryDelaySeconds: [1, 120], maxRefresh: [1, 3] }; if (ranges[key]) { const range = ranges[key]; value = Number(value); @@ -210,7 +211,7 @@ || Object.values(value).some(courses => !Array.isArray(courses) || courses.some(course => typeof course !== 'string' || !course.trim())))) { throw new Error('主题关联须使用完整标题及题库课程名称列表'); } - if (['mode', 'execution', 'associations'].includes(key)) { + if (['mode', 'execution', 'associations', 'manualConfirmIncomplete'].includes(key)) { actionEpoch++; config.autoAnswer = false; runner.stop('执行模式或主题关联变更'); examController.disable('设置变更,须重新启动核验'); await learning.pause(); @@ -232,6 +233,9 @@ } else if (action === 'videoPause' || action === 'videoResume') await learning.configure({ videoPaused: action === 'videoPause' }); else if (action === 'examPause') { await learning.configure({ examPaused: true }); setAutoAnswer(false); } else if (action === 'examResume') { + if (learning.getState().status !== 'running') { + panel.setNotice('当前计划已暂停,请先在任务总览点击“继续上次任务”重新核验。', 'warning'); return; + } await learning.configure({ examPaused: false }); const epoch = actionEpoch; if (await examController.enable() && epoch === actionEpoch) setAutoAnswer(true); @@ -240,7 +244,9 @@ } const learningStorage = { get: (key, fallback) => store.getSetting('learning:' + key, fallback), set: (key, value) => store.setSetting('learning:' + key, value) }; examController = window.YianExamController.create({ getContext, getSnapshot: () => snapshot, - getTopic: topicPreflight, getSettings: () => learningSettings, getRunnerState: () => runnerState, + getTopic: topicPreflight, getSettings: () => Object.assign({}, learningSettings, { + manualConfirmIncomplete: learning ? learning.getState().manualConfirmIncomplete !== false : learningSettings.manualConfirmIncomplete !== false + }), getRunnerState: () => runnerState, storage: learningStorage, onLog: (level, message) => learning?.log(level, message), onState: state => { examState = state; renderLearning(); } }); learning = window.YianLearningEngine.create({ @@ -273,7 +279,13 @@ if (typeof GM_openInTab === 'function') return GM_openInTab(url, { active: false, insert: true, setParent: true }); const child = window.open(url, '_blank'); if (!child) throw new Error('浏览器阻止并行标签页,请允许打开后重试'); return child; }, - onState: state => { learningState = state; renderLearning(); } + onState: state => { + learningState = state; + if (!loading && typeof state.manualConfirmIncomplete === 'boolean') { + learningSettings.manualConfirmIncomplete = state.manualConfirmIncomplete; + } + renderLearning(); + } }); function tick() { diff --git a/tests/exam-controller.cjs b/tests/exam-controller.cjs index a4090a6..ebbd625 100644 --- a/tests/exam-controller.cjs +++ b/tests/exam-controller.cjs @@ -25,8 +25,8 @@ function paperHTML(total = 25) { 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 }) => { + async function setup({ matched = 25, unknownPoint = false, confirm = false, showResult = false, unansweredWarning = false, manualConfirmIncomplete, total = 25 } = {}) { + await page.evaluate(({ html, matched, unknownPoint, confirm, showResult, unansweredWarning, manualConfirmIncomplete }) => { window.qaExam?.destroy(); window.qaRunner?.destroy(); document.documentElement.innerHTML = html.replace(/^/, '').replace(/^/, '').replace(/<\/html>$/, ''); window.qaClock = { now: 0 }; @@ -36,7 +36,7 @@ function paperHTML(total = 25) { 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.qaModel = { commitClicks: 0, confirmClicks: 0, optionClicks: [], confirm, showResult, unansweredWarning }; 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' }; @@ -47,6 +47,7 @@ function paperHTML(total = 25) { })) }; window.qaRunnerState = 'complete'; window.qaSettings = { minSubmitMinutes: 6 }; + if (manualConfirmIncomplete !== undefined) qaSettings.manualConfirmIncomplete = manualConfirmIncomplete; 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', () => { @@ -58,8 +59,9 @@ function paperHTML(total = 25) { qaModel.commitClicks++; if (!qaModel.confirm) return; const dialog = document.createElement('div'); dialog.className = 'dialog_box'; - dialog.innerHTML = '

还有未答题,是否确认交卷?

'; + dialog.innerHTML = '

' + (qaModel.unansweredWarning ? '尚有 1 题未答,是否确认交卷?' : '是否确认交卷?') + '

'; document.body.appendChild(dialog); + dialog.querySelector('.qa-cancel').addEventListener('click', () => dialog.remove()); dialog.querySelector('.qa-confirm').addEventListener('click', () => { qaModel.confirmClicks++; dialog.remove(); @@ -77,7 +79,7 @@ function paperHTML(total = 25) { onState: state => qaStates.push(state), onLog: (level, message) => qaLogs.push({ level, message }) }); window.qaExam = qaMakeController(); - }, { html: paperHTML(total), matched, unknownPoint, confirm, showResult }); + }, { html: paperHTML(total), matched, unknownPoint, confirm, showResult, unansweredWarning, manualConfirmIncomplete }); return page.evaluate(async () => { await qaExam.observe(); const enabled = await qaExam.enable(); await qaExam.tick(); return { enabled, ...qaExam.getState() }; }); } async function tickAt(seconds) { @@ -95,18 +97,79 @@ function paperHTML(total = 25) { let result = await tickAt(360); assert.equal(result.model.commitClicks, 0, '正好 80 分不得自动交卷'); assert.equal(result.state.stage, 'waiting'); - assert.match(result.state.message, /小于或等于 80/); + assert.equal(result.state.requiresManualSubmission, true); + assert.match(result.state.message, /人工|手动/); initial = await setup({ matched: 21 }); assert.equal(initial.predictedScore, 84); assert.equal(initial.verifiedCount, 21); assert.equal(initial.missing, 4, '部分题目缺失仍可用已核验题目的实际分值计算'); + assert.equal(initial.requiresManualSubmission, true); + assert.equal(initial.unverifiedCount, 4); + assert.equal(initial.stage, 'waiting', '题库缺题立即转为人工交卷等待'); result = await tickAt(240); assert.equal(result.model.commitClicks, 0, '84 分但只满 4 分钟不得提交'); assert.equal(result.state.waitSeconds, 120); + assert.equal(result.state.stage, 'waiting'); + result = await tickAt(360); + assert.equal(result.model.commitClicks, 0, '84 分且满 6 分钟,缺失 4 题仍须原网页人工交卷'); + assert.equal(result.model.confirmClicks, 0); + assert.match(result.state.message, /人工|手动/); + await page.evaluate(async () => { qaExam.disable('用户暂停'); await qaExam.enable(); }); + result = await tickAt(600); + assert.equal(result.model.commitClicks, 0, '重新启用不能作为部分缺题的人工确认或绕过'); + assert.equal(result.model.confirmClicks, 0); + assert.equal(result.state.requiresManualSubmission, true); + const replenished = await page.evaluate(async () => { + qaSnapshot.records = QuestionBankCore.extract(document).questions.map(question => YianBankStore.validateRecord({ + ...question, role: '', course: '安全知识题库', sourceFile: '补全题库.json', answer: 'A', correctTexts: undefined + })); + qaSnapshot.revision++; qaContext.revision++; + const enabled = await qaExam.enable(); await qaExam.tick(); + return { enabled, state: qaExam.getState(), model: qaModel }; + }); + assert.equal(replenished.enabled, true, '补全题库后仍须明确重新启用'); + assert.equal(replenished.state.predictedScore, 100); + assert.equal(replenished.state.verifiedCount, 25); + assert.equal(replenished.state.requiresManualSubmission, false); + assert.equal(replenished.model.commitClicks, 1, '新 revision 全卷匹配核验后才恢复正常交卷'); + console.log('PASS 80 分与 84 分缺题均等待原网页人工交卷,反复启用不能绕过;补全全卷并明确启用可恢复'); + + initial = await setup({ matched: 21, manualConfirmIncomplete: false, confirm: true, unansweredWarning: true, showResult: true }); + assert.equal(initial.predictedScore, 84); + assert.equal(initial.requiresManualSubmission, false, '关闭缺题手动确认后恢复部分题自动交卷'); + assert.equal(initial.stage, 'waitingSubmit'); + result = await tickAt(240); + assert.equal(result.model.commitClicks, 0, '关闭开关后仍须满足 6 分钟'); + result = await tickAt(360); + assert.equal(result.model.commitClicks, 1, '关闭开关后 84 分且满 6 分钟允许交卷'); + result = await tickAt(361); + assert.equal(result.model.confirmClicks, 1, '关闭开关后正常未答提醒可按旧规则确认'); + result = await tickAt(362); + assert.equal(result.state.submitted, true); + await setup({ matched: 20, manualConfirmIncomplete: false }); + result = await tickAt(600); + assert.equal(result.state.predictedScore, 80); + assert.equal(result.state.stage, 'waiting'); + assert.equal(result.model.commitClicks, 0, '关闭开关也必须严格大于 80 分'); + assert.equal(result.model.confirmClicks, 0); + await setup({ matched: 22, manualConfirmIncomplete: false }); + await page.evaluate(() => document.querySelector('.pc_examtest_content_list_des').classList.remove('examtest_content_list_des_checked')); + result = await tickAt(600); + assert.equal(result.state.verifiedCount, 21); + assert.equal(result.state.predictedScore, 84, '高于 80 分也不能跳过其余已匹配题的核验'); + assert.equal(result.model.commitClicks, 0, '关闭开关仍不能跳过可匹配题的答案全文回读'); + console.log('PASS 缺题手动确认默认开启;关闭后部分 84 分可交卷和确认,80 分或可匹配题未核验仍禁止'); + + initial = await setup(); + assert.equal(initial.predictedScore, 100); + assert.equal(initial.requiresManualSubmission, false); + result = await tickAt(240); + assert.equal(result.model.commitClicks, 0, '全卷 100 分但只满 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 分钟点击一次真实交卷入口'); + assert.equal(result.model.commitClicks, 1, '全卷核验 100 分且满 6 分钟点击一次真实交卷入口'); await tickAt(365); assert.equal((await tickAt(372)).model.commitClicks, 1, '提交后等待结果时不得重复点击'); result = await tickAt(373); @@ -115,28 +178,69 @@ function paperHTML(total = 25) { 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 分钟门槛、未知结果有界停止并禁止重复提交'); + console.log('PASS 全卷覆盖后的 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 setup(); 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.state.verifiedCount, 24); + assert.equal(result.state.predictedScore, 96); + assert.equal(result.state.requiresManualSubmission, true); + assert.equal(result.state.unverifiedCount, 1); + assert.equal(result.state.stage, 'waiting'); assert.equal(result.model.commitClicks, 0, '匹配记录存在但实际选中内容未回读核验时不计分'); + assert.equal(result.model.confirmClicks, 0); console.log('PASS 分值未知与实际选中证据变化均阻止自动交卷'); - await setup({ matched: 21 }); + for (const incomplete of ['conflict', 'unsupported']) { + await setup(); + const state = await page.evaluate(async incomplete => { + if (incomplete === 'conflict') { + qaSnapshot.records.push(YianBankStore.validateRecord({ ...qaSnapshot.records[0], id: undefined, answer: 'B', correctTexts: undefined })); + } else { + document.querySelectorAll('.examtest_content_list_des_optionsort')[1].textContent = 'A'; + } + qaSnapshot.revision++; qaContext.revision++; + await qaExam.enable(); qaSetClock(360000); await qaExam.tick(); + return { state: qaExam.getState(), model: qaModel }; + }, incomplete); + assert.equal(state.state.predictedScore, 96, incomplete + ' 的题目不计入预计得分'); + assert.equal(state.state.requiresManualSubmission, true, incomplete + ' 也需要人工交卷'); + assert.equal(state.state.unverifiedCount, 1); + assert.equal(state.state.stage, 'waiting'); + assert.equal(state.model.commitClicks, 0); + assert.equal(state.model.confirmClicks, 0); + assert.ok(state.state.details.some(item => item.status === incomplete)); + } + for (const manualConfirmIncomplete of [true, false]) for (const incomplete of ['missingCard', 'missingCardEntry', 'missingQuestion']) { + await setup({ manualConfirmIncomplete }); + const blocked = await page.evaluate(async incomplete => { + if (incomplete === 'missingCard') document.querySelector('.pc_examtest_card').remove(); + if (incomplete === 'missingCardEntry') document.querySelector('.examtestcard_count_defult').remove(); + if (incomplete === 'missingQuestion') document.querySelector('[app-exam-question]').remove(); + const enabled = await qaExam.enable(); qaSetClock(360000); await qaExam.tick(); + return { enabled, state: qaExam.getState(), model: qaModel }; + }, incomplete); + assert.equal(blocked.enabled, false, incomplete + ' 在开关为 ' + manualConfirmIncomplete + ' 时不允许将部分 DOM 当作完整试卷启动'); + assert.equal(blocked.state.complete, false); + assert.equal(blocked.state.scoreValid, false); + assert.equal(blocked.model.commitClicks, 0); + assert.equal(blocked.model.confirmClicks, 0); + } + console.log('PASS 冲突、不支持、未全文核验及答题卡/题目 DOM 不完整均不能自动交卷'); + + await setup(); 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.match(result.state.message, /人工|手动/); assert.equal(result.model.commitClicks, 0, '可信人工点击立即取消交卷计划'); - await setup({ matched: 21 }); + await setup(); await page.evaluate(() => { qaContext.revision = 2; qaSnapshot.revision = 2; }); result = await tickAt(360); assert.equal(result.state.enabled, false); @@ -144,7 +248,7 @@ function paperHTML(total = 25) { assert.match(result.state.message, /题库|变更/); console.log('PASS 人工作答取消与题库 revision 变化取消旧交卷授权'); - await setup({ matched: 21, confirm: true, showResult: true }); + await setup({ confirm: true, showResult: true }); result = await tickAt(360); assert.equal(result.model.commitClicks, 1); assert.equal(result.model.confirmClicks, 0); @@ -157,7 +261,60 @@ function paperHTML(total = 25) { assert.equal((await tickAt(363)).model.confirmClicks, 1); console.log('PASS 交卷确认弹窗及网站成功结果,不从点击动作推断成功'); - await setup({ matched: 21 }); + await setup({ confirm: true, unansweredWarning: true, showResult: true }); + result = await tickAt(360); + assert.equal(result.model.commitClicks, 1, '本地完整核验后可点击初始交卷入口'); + result = await tickAt(361); + assert.equal(result.model.confirmClicks, 0, '网站明确提示未答题时必须让用户人工确认'); + assert.equal(result.state.requiresManualSubmission, true); + assert.equal(result.state.stage, 'waiting'); + assert.equal(result.state.enabled, false); + assert.match(result.state.message, /人工|手动/); + assert.equal(await page.evaluate(() => qaExam.enable()), false, '网站未答题提醒不得通过重新启动绕过'); + assert.equal((await tickAt(600)).model.confirmClicks, 0); + const restoredManualWarning = await page.evaluate(async () => { + qaExam.destroy(); qaExam = qaMakeController(); + await qaExam.observe(); const enabled = await qaExam.enable(); await qaExam.tick(); + return { enabled, state: qaExam.getState(), model: qaModel }; + }); + assert.equal(restoredManualWarning.enabled, false, '网站未答提醒在 controller 重建后仍禁止自动确认'); + assert.equal(restoredManualWarning.state.requiresManualSubmission, true); + assert.equal(restoredManualWarning.state.stage, 'waiting'); + assert.equal(restoredManualWarning.model.commitClicks, 1); + assert.equal(restoredManualWarning.model.confirmClicks, 0); + await page.locator('.qa-confirm').click(); + result = await tickAt(601); + assert.equal(result.model.confirmClicks, 1, '用户仍能在网站弹窗完成真实人工确认'); + assert.equal(result.state.submitted, true); + console.log('PASS 网站未答题提醒优先于本地全卷核验,自动确认停止且只能原网页人工确认'); + + await setup({ matched: 25, confirm: true, unansweredWarning: true, showResult: true }); + await tickAt(360); await tickAt(361); + const unlockedWarning = await page.evaluate(async () => { + qaExam.destroy(); qaExam = qaMakeController(); await qaExam.observe(); + const before = await qaExam.enable(); + qaSettings.manualConfirmIncomplete = false; + qaExam.disable('设置已变更,请明确继续'); + await qaExam.tick(); const blocked = qaModel.confirmClicks; + const withDialog = await qaExam.enable(); await qaExam.tick(); + return { before, blocked, withDialog, state: qaExam.getState(), model: qaModel }; + }); + assert.equal(unlockedWarning.before, false, '已持久化的网站未答提醒在默认开启时仍阻止自动确认'); + assert.equal(unlockedWarning.blocked, 0, '切换开关本身不会自动点击确认'); + assert.equal(unlockedWarning.withDialog, false, '旧网站弹窗仍打开时拒绝重新启用或接管'); + assert.equal(unlockedWarning.model.confirmClicks, 0); + assert.equal(unlockedWarning.model.commitClicks, 1, '旧弹窗仍在时不能重复点击初始交卷入口'); + await page.locator('.qa-cancel').click(); + const newPlan = await page.evaluate(async () => { const enabled = await qaExam.enable(); await qaExam.tick(); return { enabled, model: qaModel }; }); + assert.equal(newPlan.enabled, true, '用户先取消原网页弹窗再明确继续可按关闭后的策略重新核验'); + assert.equal(newPlan.model.commitClicks, 2, '新明确启动的计划可重新发起一次交卷'); + assert.equal(newPlan.model.confirmClicks, 0); + result = await tickAt(362); + assert.equal(result.model.confirmClicks, 1); + assert.equal((await tickAt(363)).state.submitted, true); + console.log('PASS 网站提醒锁定恢复后,关闭开关→取消旧弹窗→明确继续,才重新核验并发起新交卷'); + + await setup(); await tickAt(240); const restored = await page.evaluate(async () => { qaExam.destroy(); qaExam = qaMakeController(); @@ -170,7 +327,7 @@ function paperHTML(total = 25) { await page.evaluate(() => qaExam.enable()); assert.equal((await tickAt(360)).model.commitClicks, 1, '同场恢复后仍按原始入卷 6 分钟交卷'); - await setup({ matched: 21 }); + await setup(); await page.evaluate(() => { qaExam.destroy(); qaStorage.clear(); qaClock.now = 240000; qaContext.sourceUrl += '&totaltime=30'; @@ -182,8 +339,8 @@ function paperHTML(total = 25) { assert.equal(inferred.waitSeconds, 120); console.log('PASS 原始入卷时间持久化、0 时间值和明确倒计时反推'); - for (const mutation of ['ready', 'route', 'revision', 'answers', 'points', 'topic', 'pause']) { - await setup({ matched: 21 }); + for (const mutation of ['ready', 'route', 'revision', 'answers', 'points', 'topic', 'pause', 'manualConfirmIncomplete']) { + await setup(); const blocked = await page.evaluate(async mutation => { qaSetClock(360000); qaStorageHook = async (_key, value) => { @@ -198,14 +355,15 @@ function paperHTML(total = 25) { } if (mutation === 'topic') qaTopic = { status: 'matched', courses: ['其他主题'] }; if (mutation === 'pause') qaExam.disable('用户已暂停'); + if (mutation === 'manualConfirmIncomplete') qaSettings.manualConfirmIncomplete = false; }; 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 }); + for (const mutation of ['revision', 'duplicateButton', 'pause', 'manualConfirmIncomplete']) { + await setup({ confirm: true }); await tickAt(360); const blocked = await page.evaluate(async mutation => { qaStorageHook = async (_key, value) => { @@ -213,15 +371,28 @@ function paperHTML(total = 25) { 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('用户已暂停'); + if (mutation === 'manualConfirmIncomplete') qaSettings.manualConfirmIncomplete = false; }; await qaExam.tick(); return { model: qaModel, state: qaExam.getState() }; }, mutation); assert.equal(blocked.model.confirmClicks, 0, '二次确认保存期间发生 ' + mutation + ' 时禁止旧确认点击'); assert.equal(blocked.state.enabled, false); } + for (const persistedStage of ['submitting', 'confirming']) { + await setup({ matched: 21, manualConfirmIncomplete: false, confirm: true }); + if (persistedStage === 'confirming') await tickAt(360); + const blocked = await page.evaluate(async persistedStage => { + qaSetClock(360000); + qaStorageHook = async (_key, value) => { if (value.stage === persistedStage) qaSettings.manualConfirmIncomplete = true; }; + await qaExam.tick(); return { state: qaExam.getState(), model: qaModel }; + }, persistedStage); + assert.equal(blocked.model.commitClicks, persistedStage === 'confirming' ? 1 : 0, '保存期间重新开启缺题手动确认,禁止继续原部分题交卷计划'); + assert.equal(blocked.model.confirmClicks, 0, '保存期间重新开启缺题手动确认,禁止自动确认'); + assert.equal(blocked.state.enabled, false); + } console.log('PASS 最后点击前复核:异步保存期间页面、题库、选中集合、主题、暂停及确认入口变化均阻止旧动作'); - await setup({ matched: 21 }); + await setup(); const interruptedRead = await page.evaluate(async () => { qaExam.destroy(); qaStorageReads = 0; let release; qaReadGate = new Promise(resolve => { release = resolve; }); @@ -237,7 +408,7 @@ function paperHTML(total = 25) { assert.equal(interruptedRead.reads, 1); assert.equal(interruptedRead.returned, false); assert.equal(interruptedRead.state.enabled, false, 'storage.get 返回后不得重新启用已暂停的考试'); - await setup({ matched: 21 }); + await setup(); 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; } }; @@ -248,7 +419,7 @@ function paperHTML(total = 25) { assert.equal(interruptedWrite.state.enabled, false); console.log('PASS observe 共享异步读取及 enable 在读写期间被暂停后不复活'); - await setup({ matched: 21 }); + await setup(); const retake = await page.evaluate(async () => { qaExam.destroy(); const key = Array.from(qaStorage.keys())[0], saved = qaStorage.get(key); @@ -268,7 +439,7 @@ function paperHTML(total = 25) { assert.equal(retake.model.commitClicks, 0); assert.equal((await tickAt(360)).model.commitClicks, 1); - await setup({ matched: 21 }); + await setup(); const missingIdentity = await page.evaluate(async () => { qaExam.destroy(); const key = Array.from(qaStorage.keys())[0], saved = qaStorage.get(key); @@ -282,7 +453,7 @@ function paperHTML(total = 25) { assert.equal(missingIdentity.state.waitSeconds, 360); assert.equal(missingIdentity.model.commitClicks, 0); - await setup({ matched: 21 }); + await setup(); const equalCountdownRetake = await page.evaluate(async () => { qaExam.destroy(); qaClock.now = 360000; // Both old checkpoint and new attempt start at 1800. Compare elapsed wall time, @@ -299,7 +470,7 @@ function paperHTML(total = 25) { assert.equal(equalCountdownRetake.after.waitSeconds, 360); assert.equal(equalCountdownRetake.model.commitClicks, 0); - await setup({ matched: 21 }); + await setup(); const noClockEvidence = await page.evaluate(async () => { qaExam.destroy(); qaClock.now = 360000; document.querySelector('.pc_examtest_countdown').remove(); @@ -315,7 +486,7 @@ function paperHTML(total = 25) { assert.equal(noClockEvidence.after.waitSeconds, 360); assert.equal(noClockEvidence.model.commitClicks, 0); - await setup({ matched: 21 }); + await setup(); const pendingAttempt = await page.evaluate(async () => { qaExam.destroy(); const key = Array.from(qaStorage.keys())[0], saved = qaStorage.get(key); diff --git a/tests/learning-engine.cjs b/tests/learning-engine.cjs index 53f95b0..97d9921 100644 --- a/tests/learning-engine.cjs +++ b/tests/learning-engine.cjs @@ -169,6 +169,35 @@ async function main() { 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++; + // 缺题改由用户在网站交卷,等待与局部暂停只读同步最终结果,不能重启控制器。 + for (const locallyPaused of [false, true]) { + await setup(examDetail, { mode: 'exam' }); + await page.evaluate(() => { + qaEngine.destroy(); window.qaExamPauseCalls = 0; + qaEngine = qaCreate({ onExamPause: () => { qaExamPauseCalls++; qaExam = { stage: 'paused', message: '已交给用户处理' }; } }); + }); + await page.evaluate(() => qaEngine.start()); + await page.evaluate(() => { document.body.innerHTML = '
测试课程
'; }); + await tick(); assert.equal(await page.evaluate(() => qaExamCalls), 1); + await page.evaluate(() => { qaExam = { stage: 'waiting', predictedScore: 84, total: 40, verifiedCount: 32, requiresManualSubmission: true, enabled: false, message: '题目不全,请在网站手动确认交卷' }; }); + state = await tick(); assert.equal(state.tasks[0].status, 'waiting'); + if (locallyPaused) { + await page.evaluate(() => qaEngine.configure({ examPaused: true })); + assert.equal(await page.evaluate(() => qaExamPauseCalls), 1); + } + for (const stage of ['waiting', 'unknown', 'submitting']) { + await page.evaluate(stage => { qaExam = { stage, submitted: true, confirmed: false, requiresManualSubmission: true, enabled: false }; }, stage); + state = await tick(600000); + assert.equal(state.tasks[0].status, 'waiting', '等待/未知/仅点击提交都不能当作网站确认完成'); + assert.equal(state.completed, 0); assert.equal(await page.evaluate(() => qaExamCalls), 1); + } + await page.evaluate(flagOnly => { qaExam = flagOnly ? { stage: 'unknown', submitted: true, confirmed: true } : { stage: 'submitted', submitted: true, confirmed: true }; }, locallyPaused); + state = await tick(); assert.equal(state.tasks[0].status, 'completed'); assert.equal(state.completed, 1); + assert.equal(await page.evaluate(() => qaExamCalls), 1, '人工交卷完成只读同步,不得重新启动答题'); + assert.equal(await page.evaluate(() => qaReloads), 0); assert.equal(await page.evaluate(() => qaNavigations.length), 0); + checks++; + } + await setup('
没有题库的考试
开始答题
', { 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++; @@ -186,6 +215,27 @@ async function main() { 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++; + await setup(videoHTML, { manualConfirmIncomplete: false }); + await page.evaluate(() => qaEngine.start()); await page.evaluate(() => qaEngine.pause()); + 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.equal(state.manualConfirmIncomplete, false, '单页检查点恢复关闭策略且保持暂停'); + await page.evaluate(() => qaEngine.configure({ manualConfirmIncomplete: true })); + assert.equal(await page.evaluate(async () => (await qaStorage.get('session.v1')).config.manualConfirmIncomplete), true); + assert.equal((await read()).status, 'paused'); checks++; + + const stalePolicyRead = await page.evaluate(async () => { + qaEngine.destroy(); await new Promise(r => setTimeout(r, 0)); + const previous = qaStorage; let release; let held; let holdNext = false; + const gate = new Promise(resolve => { release = resolve; }); const captured = new Promise(resolve => { held = resolve; }); + qaStorage = { get: async key => { const value = await previous.get(key); if (key === 'control.v1' && holdNext) { holdNext = false; held(); await gate; } return value; }, set: previous.set }; + qaEngine = qaCreate(); await qaEngine.tick(); + holdNext = true; const oldTick = qaEngine.tick(); await captured; + await qaEngine.configure({ manualConfirmIncomplete: false }); release(); await oldTick; + return { state: qaEngine.getState(), shared: await qaStorage.get('control.v1') }; + }); + assert.equal(stalePolicyRead.state.manualConfirmIncomplete, false, '迟到的旧共享读数不得覆盖刚发布的开关'); + assert.equal(stalePolicyRead.shared.config.manualConfirmIncomplete, false); assert.equal(stalePolicyRead.state.status, 'paused'); checks++; + // 两个实际标签通过同一GM式存储确认工作页,暂停信号同步,完全隔离真实业务网络。 const shared = new Map(); await context.exposeBinding('__learningGet', (_, key) => shared.has(key) ? JSON.parse(JSON.stringify(shared.get(key))) : null); @@ -214,6 +264,18 @@ async function main() { 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.configure({ manualConfirmIncomplete: false })); + workerState = await workerPage.evaluate(() => fixtureEngine.tick()); state = await tick(); + assert.equal(state.manualConfirmIncomplete, false); assert.equal(workerState.manualConfirmIncomplete, false, '主页面关闭开关同步工作页'); + assert.equal(state.status, 'paused'); assert.equal(workerState.status, 'paused'); + await workerPage.evaluate(() => fixtureEngine.configure({ manualConfirmIncomplete: true })); + state = await tick(); workerState = await workerPage.evaluate(() => fixtureEngine.tick()); + assert.equal(state.manualConfirmIncomplete, true, '工作页开启开关反向同步主页面'); assert.equal(workerState.manualConfirmIncomplete, true); + assert.equal(state.status, 'paused'); assert.equal(workerState.status, 'paused', '策略同步不能自动恢复任务'); + assert.equal(await workerPage.evaluate(() => fixtureExamCalls), 1, '修改共享策略期间不得重启答题'); + const synchronizedControl = shared.get('control.v1'); + assert.equal(synchronizedControl.status, 'paused'); assert.equal(synchronizedControl.config.manualConfirmIncomplete, true); + assert.equal(shared.get('session.v1').config.manualConfirmIncomplete, true, '主页面暂停时同步保存工作页策略'); checks++; 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', '工作页的全局暂停同步主页面'); diff --git a/tests/panel-tabs.cjs b/tests/panel-tabs.cjs index 8c505f2..b4084c3 100644 --- a/tests/panel-tabs.cjs +++ b/tests/panel-tabs.cjs @@ -58,7 +58,7 @@ async function main() { 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 screenshot(name) { await page.screenshot({ path: path.join(qaDir, name), fullPage: false, animations: 'disabled' }); } async function visible() { await page.waitForFunction(() => { const box = qaPanel.host.getBoundingClientRect(); @@ -118,6 +118,26 @@ async function main() { 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 分(题库估计)'))); + const incompleteSwitch = host.getByTestId('setting-manualConfirmIncomplete'); + assert.equal(await incompleteSwitch.getAttribute('role'), 'switch'); + assert.equal(await incompleteSwitch.getAttribute('aria-label'), '题目不全时手动确认'); + assert(await incompleteSwitch.isChecked(), '缺题手动确认默认开启'); + assert(await host.getByTestId('exam-threshold').textContent().then(text => text.includes('题目不全时由用户在原网页手动确认'))); + assert(await host.getByTestId('incomplete-confirmation-hint').textContent().then(text => text.includes('试卷未完整加载均禁止自动交卷'))); + await incompleteSwitch.uncheck(); + assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['manualConfirmIncomplete', false]); + assert(await host.getByTestId('exam-threshold').textContent().then(text => text.includes('允许题库部分缺题时自动交卷') && text.includes('严格大于 80 分') && text.includes('所有可匹配题均已核验'))); + await screenshot('02a-desktop-incomplete-switch-off.png'); + const callsAfterIncompleteOff = await page.evaluate(() => JSON.stringify(qaCalls)); + await tab('overview').click(); await page.evaluate(() => qaPanel.setState({ learning: { etaSeconds: 360 } })); await tab('exam').click(); + assert(!await incompleteSwitch.isChecked(), '切栏及部分状态更新保留关闭状态'); + assert.equal(await page.evaluate(() => JSON.stringify(qaCalls)), callsAfterIncompleteOff, '切栏不能修改缺题开关、自动答题或交卷'); + await incompleteSwitch.check(); + assert.deepEqual(await page.evaluate(() => qaCalls.settings.at(-1)), ['manualConfirmIncomplete', true]); + assert.deepEqual(await page.evaluate(() => qaCalls.settings.filter(([key]) => key === 'manualConfirmIncomplete')), [['manualConfirmIncomplete', false], ['manualConfirmIncomplete', true]]); + assert.deepEqual(await page.evaluate(() => qaCalls.auto), [], '缺题开关与自动选择答案独立'); + assert.deepEqual(await page.evaluate(() => qaCalls.actions), [], '缺题开关不启动或提交考试'); + console.log('PASS incomplete-question switch defaults on, boolean setting callbacks, persistent tab state and independent answer controls'); 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(); @@ -130,6 +150,17 @@ async function main() { 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 page.evaluate(() => qaPanel.setState({ learning: { exam: { title: '安全考试', status: 'waiting', total: 25, + verifiedCount: 21, unverifiedCount: 4, predictedScore: 84, requiresManualSubmission: true, + elapsedSeconds: 360, remainingSeconds: 1000, message: '题目未全部核验,请在网页手动确认交卷' } } })); + assert(await pane('exam').textContent().then(text => text.includes('84 分(题库估计)') && text.includes('21 / 25 题') && text.includes('原网页手动确认'))); + await page.evaluate(() => qaPanel.setState({ learning: { manualConfirmIncomplete: false } })); + assert(!await incompleteSwitch.isChecked()); + assert(await pane('exam').textContent().then(text => text.includes('题目未全部核验或网站提示未答完,请在原网页手动确认')), '控制器要求人工时优先显示其提示'); + await page.evaluate(() => qaPanel.setState({ learning: { manualConfirmIncomplete: true } })); + assert(await incompleteSwitch.isChecked(), '面板反映外部恢复的开关配置'); + assert.equal(await pane('exam').getByRole('button', { name: '确认交卷', exact: true }).count(), 0, '面板不能提供跳过缺题的交卷确认入口'); + await host.locator('.panes').evaluate(node => { node.scrollTop = 0; }); await screenshot('02-desktop-exam.png'); console.log('PASS exam defaults/score/full answer evidence, partial learning updates and persistent switch/details state'); @@ -182,6 +213,9 @@ async function main() { 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('exam').click(); await incompleteSwitch.scrollIntoViewIfNeeded(); + assert(await incompleteSwitch.isVisible()); assert(await incompleteSwitch.isChecked()); + await screenshot('03a-mobile-incomplete-switch-on.png'); 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'); diff --git a/tests/workflow.cjs b/tests/workflow.cjs index da5542d..833f7f0 100644 --- a/tests/workflow.cjs +++ b/tests/workflow.cjs @@ -6,87 +6,130 @@ 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() { +function fixture(partial) { const questions = Array.from({ length: 25 }, (_, i) => `

[单选] ${i + 1}. 联动验证题${i}(4分)

A错误${i}
B正确${i}
`).join(''); - return `助手完整流程验证
完整流程考试
${questions}
${Array.from({ length: 25 }, (_, i) => `
${i + 1}
`).join('')}
00:30:00
交卷
`; + return `助手完整流程验证
完整流程考试
${questions}
${Array.from({ length: 25 }, (_, i) => `
${i + 1}
`).join('')}
00:30:00
交卷
`; } (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'); + for (const { matched, manualConfirmIncomplete = true } of [{ matched: 21 }, { matched: 25 }, { matched: 21, manualConfirmIncomplete: false }]) { + const partial = matched < 25; + const manual = partial && manualConfirmIncomplete; + 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&attemptId=workflow-' + matched + '-' + manualConfirmIncomplete + '&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(partial) }); + blocked.push(route.request().url()); return route.abort(); }); - document.querySelector('[role=dialog] button').addEventListener('click', () => { - window.confirmClicks++; document.querySelector('[role=dialog]').setAttribute('aria-hidden', 'true'); document.querySelector('[data-exam-result]').hidden = false; + const page = await context.newPage(); + page.on('pageerror', error => errors.push(error.message)); + await page.goto(url); + async function mountPage() { + 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 mountPage(); + await page.evaluate(() => { YianAssistant.getState(); }); + await page.getByTestId('tab-exam').click(); + const manualSwitch = page.getByRole('switch', { name: '题目不全时手动确认', exact: true }); + assert.equal(await manualSwitch.isChecked(), true, '新设置默认开启缺题手动确认'); + if (!manualConfirmIncomplete) { + await manualSwitch.click(); + await page.waitForFunction(() => YianAssistant.getLearningState().manualConfirmIncomplete === false); + await page.waitForFunction(async () => (await window.__read('yianxue.assistant.setting.v2.learningSettings', null))?.manualConfirmIncomplete === false); + assert.equal(await manualSwitch.isChecked(), false); + await page.reload(); await mountPage(); + await page.getByTestId('tab-exam').click(); + assert.equal(await manualSwitch.isChecked(), false, '刷新后记住已明确关闭的缺题确认开关'); + assert.equal(await page.evaluate(() => YianAssistant.getLearningState().manualConfirmIncomplete), false); + assert.equal(await page.evaluate(() => YianAssistant.getState().autoAnswer), false, '设置恢复不会自行开始答题'); + assert.equal(await page.evaluate(() => YianAssistant.getExamState().enabled), false, '刷新仍需明确开始交卷计划'); + } + const records = Array.from({ length: matched }, (_, 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.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分钟正常交卷→网站确认→任务完成,零业务请求'); + await page.waitForFunction(matched => YianAssistant.getState().metrics.selected === matched, matched, { timeout: 15000 }); + await page.waitForFunction(matched => YianAssistant.getExamState().predictedScore === matched * 4, matched); + if (manual) { + await page.waitForFunction(() => YianAssistant.getExamState().stage === 'waiting' && YianAssistant.getExamState().requiresManualSubmission === true); + assert.equal(await page.evaluate(() => YianAssistant.getExamState().unverifiedCount), 4); + assert.match(await page.evaluate(() => YianAssistant.getExamState().message), /人工|手动/); + } + assert.equal(await page.evaluate(() => YianAssistant.getExamState().requiresManualSubmission), manual); + 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), !manual, '切栏保留开关决定的交卷策略和当前授权状态'); + 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)); + if (manual) { + await page.waitForTimeout(1300); + assert.equal(await page.evaluate(() => window.commitClicks), 0, '84 分且满 6 分钟,缺题仍不得自动点击交卷'); + assert.equal(await page.evaluate(() => window.confirmClicks), 0); + assert.equal(await page.evaluate(() => YianAssistant.getExamState().requiresManualSubmission), true); + assert.equal(await page.evaluate(() => YianAssistant.getExamState().stage), 'waiting'); + await page.locator('.pc_examtest_commit').click(); + await page.getByRole('button', { name: '确认交卷', exact: true }).click(); + } + 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); + assert.equal(await page.evaluate(() => YianAssistant.getExamState().requiresManualSubmission), false, '网站确认结果后清除待人工交卷提示'); + 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(manual + ? 'PASS 完整用户脚本:21/25 题全文回读=84分→切栏不扰动→满6分钟仍无自动交卷→原网页真实人工交卷和确认→网站结果→任务完成,零业务请求' + : partial + ? 'PASS 完整用户脚本:真实界面关闭缺题确认开关→21/25题=84分→4分钟等待→满6分钟自动交卷并确认未答提醒→网站结果→任务完成,零业务请求' + : 'PASS 完整用户脚本:25/25题全文回读=100分→切栏不扰动→4分钟等待→满6分钟自动交卷和确认→网站结果→任务完成,零业务请求'); + await context.close(); + } } finally { await browser.close(); } })().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/实施开发方案.md b/实施开发方案.md index 56fc962..dee0254 100644 --- a/实施开发方案.md +++ b/实施开发方案.md @@ -10,7 +10,7 @@ | 六栏面板 | 总览、视频、考试、题库、控制台、其他设置;保留拖拽与折叠 | 窄屏可操作;全局暂停常驻;切栏不启动任务或导出 | | 学习调度 | 从实际页面发现任务,按稳定标识排队;四种模式,顺序与并行 | 不猜业务接口;已完成不重做;受前置条件约束;独立视频与考试任务不重复领取 | | 视频与恢复 | 真实播放器倍速、平台记录、课中题检测、受限恢复及检查点 | 播放结束不误判完成;主动暂停/弹题不刷新;重试有上限;人工重开默认暂停 | -| 考试流程 | 主题关联预检、整卷预检、逐题间隔、分值读取、选中全文核验、交卷 | 严格大于 80 分;至少 6 分钟;80 分等待人工;真实提交结果确认 | +| 考试流程 | 主题关联预检、整卷预检、逐题间隔、分值读取、选中全文核验、交卷、缺题人工确认开关 | 网页题目与答题卡完整;严格大于 80 分;至少 6 分钟;默认缺题人工确认,关闭后按部分题库规则交卷;真实提交结果确认 | | 集成发布 | 既有复制/搜索/导出/范围/拖拽回归,更新文档与用户脚本 | 本地模拟业务页面完成交互测试;无外网业务请求;提交并推送远程 | ## 2. 模块边界 @@ -29,6 +29,8 @@ - 课中题归视频任务;考试交卷规则不应用到课中题或练习。 - 考试先核对主题关联;仅相似词不能授权自动答题。完整题干预检发生在试卷加载后,不能将已进入试卷的场次称为未开考。 - 预计得分仅计入答案唯一、当前选中全文一致的题目实际分值。未知、冲突及分值不明不算正分。 +- “题目不全时手动确认交卷”开关默认开启:任意题目未匹配、冲突、未核验或不支持,以及正常未答完提醒均等待人工在网站确认。关闭时允许网页整卷完整、全部可匹配题处理并全文核验、预计得分严格大于 80 分且满 6 分钟的部分题库试卷自动交卷及确认正常未答完提醒;未知题不猜答、不计正分。 +- 网页整卷和答题卡完整、主题关联明确、分值与分制可核对、可匹配题均已全文核验、预计得分严格大于 80 分和达到最早交卷时长等通用门槛,在任何开关状态下均生效。 - 用户手动作答、关停自动选择、暂停、题库/主题变更均取消旧交卷计划;继续前重新核验。 - 提交过程保存状态;结果不明先等待检查,不重复点击。网站倒计时独立于脚本暂停。 - 同一任务只由一个有效执行页持有;并行受阻时显示原因,不显示虚假的运行成功。 @@ -57,3 +59,12 @@ - 补充回归保护异步保存期间的暂停/题库/页面/分值变化、重考计时隔离、未知提交防重复、课中题选中后立即暂停、视频先结束后的并行汇总及平台完成状态展示。 代码交付并不替代第 5 节的真实账号环境验收;未识别的页面结构、身份验证、结果或后台限制会停止受影响任务并提示人工处理。 + +## 7. 2.1.1 调整:缺题人工确认开关 + +- “考试答题”栏新增“题目不全时手动确认交卷”开关(`manualConfirmIncomplete`),默认开启,设置跨页面及刷新保存。 +- 开启时要求整卷每题唯一精确匹配且当前选中全文核验通过,题库缺题、冲突、未核验、无法唯一映射或不支持时等待人工;网站正常未答完提醒也由用户确认。关闭时恢复部分题库自动交卷,保留题库主题预检、全部可匹配题已处理并全文核验、预计得分严格大于 80 分及默认 6 分钟等条件,允许自动确认本场正常未答完提醒。 +- 网页题目或答题卡加载不全、分值或分制不明、主题关联不明确、可匹配题未处理或核验失败、80 分及以下、计时未到、暂停、身份验证及结果不明等条件始终阻止自动交卷。开关不能覆盖这些通用门槛。 +- 在入口、提交前复查及确认阶段统一核对开关和通用门槛。切换开关立即暂停当前答题与交卷计划,须用户明确继续后重验,不能关闭时立即提交。未知题不猜答,人工补选的未知答案不自动计正分。 +- 本次验收重点:网页整卷完整、已核验 84 分但有 4 题未匹配时,开关开启则满 6 分钟仍不点击提交/确认,关闭并明确继续重验后可按原规则交卷及确认正常未答完提醒;完整 100 分且每题核验通过,达到时长并满足其他条件后可正常自动交卷。另覆盖任何开关状态下的加载不全、分值不明、80 分、主题不明,以及开关默认值、持久化和切换中止。 +- 本节记录本次实施与验收范围,测试结果以本次实际执行记录为准。 diff --git a/谊安学助手 2.0.user.js b/谊安学助手 2.0.user.js index e0d1586..04cc207 100644 --- a/谊安学助手 2.0.user.js +++ b/谊安学助手 2.0.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name 谊安学助手 2.0 // @namespace yianxue-question-bank -// @version 2.1.0 +// @version 2.1.1 // @description 谊安学助手:统一题库、六栏面板、视频任务与考试全文核验。 // @match http://10.119.67.7/clientcn/* // @run-at document-idle @@ -1132,7 +1132,7 @@ window.JSZip = module.exports; bankTotal: 0, roleCounts: {}, storageMode: 'userscript', pageStatus: '等待识别题目页面', pageMode: '', canAnswer: false, canExport: false, metrics: {}, exportEnabled: false, expanded: false, loading: false, position: null, activeTab: 'overview', uniqueTotal: 0, conflictTotal: 0, - learning: { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: {}, exam: {}, logs: [], message: '' } + learning: { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: {}, exam: {}, logs: [], message: '' } }; let destroyed = false; let selectionText = ''; @@ -1550,6 +1550,16 @@ window.JSZip = module.exports; }); return input; } + function learningSwitch(container, key, label, fallback) { + const control = makeSwitch(label, function () { + state.learning[key] = control.input.checked; + invoke('onLearningSetting', key, control.input.checked); + refreshLearning(); + }, container); + control.input.setAttribute('data-testid', 'setting-' + key); + learningControls[key] = { input: control.input, fallback: fallback, switch: true }; + return control.input; + } function taskButton(container, label, action, className) { const button = el(doc, 'button', className || '', label); button.type = 'button'; button.setAttribute('data-testid', 'task-' + action); @@ -1593,13 +1603,17 @@ window.JSZip = module.exports; const examModeNote = el(doc, 'p', 'mode-note'); examIntro.appendChild(examModeNote); learningSetting(examIntro, 'answerInterval', '答题间隔(秒)', null, 4, [0, 120, 0.5]); learningSetting(examIntro, 'minSubmitMinutes', '最早交卷(分)', null, 6, [0.5, 180, 0.5]); + learningSwitch(examIntro, 'manualConfirmIncomplete', '题目不全时手动确认', true); const examInfo = el(doc, 'dl', 'info-grid'); const examMessage = el(doc, 'p', 'explanation'); + const examThreshold = el(doc, 'p', 'threshold'); + const incompleteHint = el(doc, 'p', 'hint'); + examThreshold.setAttribute('data-testid', 'exam-threshold'); + incompleteHint.setAttribute('data-testid', 'incomplete-confirmation-hint'); const examActions = el(doc, 'div', 'task-actions'); const examPause = taskButton(examActions, '暂停考试任务', 'examPause'); const examResume = taskButton(examActions, '继续考试任务', 'examResume'); - examIntro.append(examInfo, el(doc, 'p', 'threshold', '预计得分必须严格大于 80 分;正好 80 分等待人工。'), examMessage, examActions, - el(doc, 'p', 'hint', '仅已精确匹配、点击并核验完整答案文字的题目计分。进入原试卷满设定时长、核验完成且满足条件后才自动交卷;预计分数以本地题库为依据。练习与课中题不使用考试交卷门槛。')); + examIntro.append(examInfo, examThreshold, examMessage, examActions, incompleteHint); views.exam.insertBefore(examIntro, switches); const bankCounts = el(doc, 'p', 'bank-counts'); bankSection.appendChild(bankCounts); @@ -1802,8 +1816,17 @@ window.JSZip = module.exports; } Object.keys(learningControls).forEach(function (key) { const setting = learningControls[key]; - if (root.activeElement !== setting.input) setting.input.value = plain(learning[key] == null ? setting.fallback : learning[key]); + if (setting.switch) setting.input.checked = learning[key] == null ? setting.fallback : learning[key] !== false; + else if (root.activeElement !== setting.input) setting.input.value = plain(learning[key] == null ? setting.fallback : learning[key]); }); + const manualConfirmIncomplete = learning.manualConfirmIncomplete !== false; + setText(examThreshold, manualConfirmIncomplete + ? '必须整卷每题核验且预计得分严格大于 80 分;题目不全时由用户在原网页手动确认交卷。' + : '允许题库部分缺题时自动交卷:已核验预计得分严格大于 80 分,所有可匹配题均已核验,且已满最早交卷时间。'); + setText(incompleteHint, (manualConfirmIncomplete + ? '本开关已开启:存在未匹配、冲突或未核验题时,即使超过 80 分、已满交卷时长也不自动交卷;网页提示未答完时同样等待人工。' + : '本开关已关闭:仅已精确匹配、点击并核验完整答案文字的题目计分,其余题目不计分;满足得分、计时与当前试卷核验条件后可自动交卷。') + + '切换会暂停当前计划,请在任务总览点击继续重新核验。无论开关状态,试卷未完整加载均禁止自动交卷;预计得分正好 80 分等待人工。练习与课中题不使用考试交卷门槛。'); const videoEnabled = learning.mode !== 'exam'; setText(videoModeNote, videoEnabled ? '' : '本模式未启用视频;可查看设置与记录。'); videoModeNote.hidden = videoEnabled; const examEnabled = learning.mode === 'exam' || learning.mode === 'all' || !learning.mode; @@ -1819,6 +1842,8 @@ window.JSZip = module.exports; videoPause.disabled = !videoHasTask || ['paused', 'completed', 'done', 'idle'].includes(video.status || 'idle'); videoResume.disabled = !videoHasTask || !['paused', 'waiting', 'waitingHuman', 'failed', 'error'].includes(video.status); infoRows(examInfo, [['考试主题', examTitle], ['题库预检', plain(firstValue(exam, ['preflightMessage', 'themeStatus', 'preflight'], '尚未预检'))], + ['全文核验', exam.total == null ? '待确认' : plain(exam.verifiedCount || 0) + ' / ' + plain(exam.total) + ' 题'], + ['交卷确认', exam.submitted || exam.status === 'submitted' ? '网站已确认交卷' : exam.requiresManualSubmission ? '题目未全部核验或网站提示未答完,请在原网页手动确认' : manualConfirmIncomplete ? '整卷每题核验后才按条件自动交卷' : '已核验得分超过 80 分并满最早交卷时间后才按条件自动交卷'], ['预计得分', firstValue(exam, ['predictedScore', 'score'], null) == null ? '待核算' : plain(firstValue(exam, ['predictedScore', 'score'], 0)) + ' 分(题库估计)'], ['网页剩余', duration(firstValue(exam, ['remainingSeconds', 'remainingTime'], null))], ['进入试卷', duration(firstValue(exam, ['elapsedSeconds', 'elapsedTime'], null))], ['最早交卷', duration(firstValue(exam, ['submitWaitSeconds', 'waitSeconds'], null))], ['交卷状态', statusName(exam.status || 'idle')]]); @@ -2688,8 +2713,9 @@ window.JSZip = module.exports; let lastTime = null; let lastAdvancedAt = now(); let attemptedAt = 0; let playStarted = false; let interactionAt = -Infinity; let lastAutoPlayAt = -Infinity; let manualVideoPause = false; let watchedDocument = null; let sharedPaused = false; - const config = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, associations: {}, stallSeconds: 60, recoverySeconds: 15, maxRefreshes: 3, minRefreshSeconds: 30, videoPaused: false, examPaused: false }; - const state = { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: null, exam: null, logs: [], message: '先识别当前学习计划,再开始本轮任务' }; + let manualConfirmRevision = 0; let pendingManualConfirm = null; + const config = { mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, associations: {}, stallSeconds: 60, recoverySeconds: 15, maxRefreshes: 3, minRefreshSeconds: 30, videoPaused: false, examPaused: false }; + const state = { status: 'idle', mode: 'all', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, tasks: [], total: 0, completed: 0, skipped: 0, waiting: 0, failed: 0, etaSeconds: null, video: null, exam: null, logs: [], message: '先识别当前学习计划,再开始本轮任务' }; function snapshot() { const given = typeof options.getSnapshot === 'function' ? options.getSnapshot() || {} : {}; @@ -2702,7 +2728,7 @@ window.JSZip = module.exports; if (event.target && event.target.tagName === 'VIDEO' && interactionAt > lastAutoPlayAt && now() - interactionAt < 2000) manualVideoPause = true; } function notify() { - Object.assign(state, { mode: config.mode, execution: config.execution, rate: config.rate, answerInterval: config.answerInterval, minSubmitMinutes: config.minSubmitMinutes, videoPaused: config.videoPaused, examPaused: config.examPaused }); + Object.assign(state, { mode: config.mode, execution: config.execution, rate: config.rate, answerInterval: config.answerInterval, minSubmitMinutes: config.minSubmitMinutes, manualConfirmIncomplete: config.manualConfirmIncomplete, videoPaused: config.videoPaused, examPaused: config.examPaused }); const counted = state.tasks.filter(t => t.status !== 'expanded' && allows(t.kind)); state.total = counted.length; state.completed = counted.filter(t => t.status === 'completed').length; state.skipped = counted.filter(t => t.status === 'skipped').length; state.waiting = counted.filter(t => t.status === 'waiting').length; state.failed = counted.filter(t => t.status === 'failed').length; @@ -2747,6 +2773,7 @@ window.JSZip = module.exports; } const currentControl = await storage.get(CONTROL_KEY); if (currentControl && currentControl.sessionId !== session.id) return; + syncManualConfirmation(currentControl); session.config = clone(config); const signature = JSON.stringify([session.tasks, session.config, session.activeTaskId, session.status, session.accountId, session.planId, session.bankVersion]); if (signature !== saveSignature) { await storage.set(SESSION_KEY, clone(session)); saveSignature = signature; } } @@ -2845,13 +2872,30 @@ window.JSZip = module.exports; } else { state.message = '账号、学习计划或题库版本已变化,未恢复旧任务'; log('warn', state.message); } } if (worker && session) { const checkpoint = await storage.get('task:' + session.id + ':' + activeTaskId); const task = state.tasks.find(t => t.id === activeTaskId); if (checkpoint && task) Object.assign(task, checkpoint); } + if (session) syncManualConfirmation(await storage.get(CONTROL_KEY)); mergeTasks(discoverTasks(snap.document, snap.url)); notify(); } - async function control(status, force, explicitWorkerAction) { + function syncManualConfirmation(shared) { + if (!session || !shared || shared.sessionId !== session.id || !shared.config || pendingManualConfirm) return; + const revision = Number(shared.manualConfirmRevision) || 0; + if (revision < manualConfirmRevision || typeof shared.config.manualConfirmIncomplete !== 'boolean') return; + config.manualConfirmIncomplete = shared.config.manualConfirmIncomplete; state.manualConfirmIncomplete = config.manualConfirmIncomplete; manualConfirmRevision = revision; + session.config = Object.assign({}, session.config, { manualConfirmIncomplete: config.manualConfirmIncomplete }); + } + async function control(status, force, explicitWorkerAction, manualPatch) { if (!session || worker && !explicitWorkerAction) return; const current = await storage.get(CONTROL_KEY); if (!force && current && current.sessionId !== session.id) return; - await storage.set(CONTROL_KEY, { sessionId: session.id, status, config: clone(worker && current ? current.config : config), updatedAt: now(), owner: worker && current ? current.owner : owner }); + const sameSession = current && current.sessionId === session.id; + if (manualPatch) { + config.manualConfirmIncomplete = manualPatch.value; + manualConfirmRevision = Math.max(manualConfirmRevision, sameSession ? Number(current.manualConfirmRevision) || 0 : 0) + 1; + } else syncManualConfirmation(current); + const sharedConfig = clone(worker && sameSession ? current.config : config); + sharedConfig.manualConfirmIncomplete = config.manualConfirmIncomplete; + // 设置修改只发布策略,不得把另一页已经暂停的任务重新改成 running。 + await storage.set(CONTROL_KEY, { sessionId: session.id, status: manualPatch && sameSession ? current.status : status, config: sharedConfig, + manualConfirmRevision, updatedAt: now(), owner: worker && sameSession ? current.owner : owner }); } async function configure(patch) { await initialize(); patch = patch || {}; const wasRunning = state.status === 'running'; @@ -2867,11 +2911,15 @@ window.JSZip = module.exports; if (patch.associations && typeof patch.associations === 'object') config.associations = clone(patch.associations); if (typeof patch.videoPaused === 'boolean') config.videoPaused = patch.videoPaused; if (typeof patch.examPaused === 'boolean') config.examPaused = patch.examPaused; + const manualPatch = typeof patch.manualConfirmIncomplete === 'boolean' ? { value: patch.manualConfirmIncomplete } : null; + if (manualPatch) { pendingManualConfirm = manualPatch; config.manualConfirmIncomplete = manualPatch.value; } if (patch.clearLogs) state.logs = []; if (modeChanged && wasRunning) await pause('执行模式已改变,请核对任务后继续'); if (config.videoPaused) { const snap = snapshot(); const v = snap.document && describePage(snap.document, snap.url).video; if (v) v.pause(); } if (config.examPaused && typeof options.onExamPause === 'function') options.onExamPause('考试任务已局部暂停'); - await control(state.status); await persist(); notify(); return getState(); + try { await control(state.status, false, Boolean(manualPatch), manualPatch); } + finally { if (pendingManualConfirm === manualPatch) pendingManualConfirm = null; } + await persist(); notify(); return getState(); } async function start() { await initialize(); if (destroyed) return getState(); @@ -2999,8 +3047,26 @@ window.JSZip = module.exports; attemptedAt = 0; lastAdvancedAt = now(); } } + async function observeExamState(task, readOnly) { + // 仅观察已经交给本场控制器的考试,避免将旧场次结果附到尚未开考的任务。 + if (!task.examRequested) return; + const exam = typeof options.getExamState === 'function' ? options.getExamState() : null; + if (!exam) return; + state.exam = Object.assign({ taskId: task.id, title: task.title }, clone(exam)); + state.exam.total = exam.total == null && exam.details ? exam.details.length : exam.total; + state.exam.verified = exam.verifiedCount == null ? exam.verified : exam.verifiedCount; + const stage = exam.stage || exam.status; + if (stage === 'completed' || stage === 'submitted' || exam.submitted === true && exam.confirmed === true) { task.progress = 1; taskState(task, 'completed', '网站已确认本场考试提交完成'); activeTaskId = ''; await release(); } + else if (!readOnly && ['waiting', 'paused', 'failed', 'unknown', 'awaitingAttempt'].includes(stage)) taskState(task, 'waiting', exam.reason || exam.message || '考试等待人工处理'); + else if (!readOnly && stage === 'waitingSubmit') taskState(task, 'waitingSubmit', exam.reason || '已核验,等待最早交卷时间'); + } async function examTick(task, snap, page) { - if (config.examPaused) { taskState(task, 'waiting', '考试任务已局部暂停'); return; } + // 人工交卷后仍需接收网站确认;等待/局部暂停期间不得重新开考或启动答题。 + if (task.status === 'waiting' || config.examPaused) { + await observeExamState(task, true); + if (task.status !== 'completed' && config.examPaused) taskState(task, 'waiting', '考试任务已局部暂停'); + return; + } if (!['exam', 'paper'].includes(page.kind)) { const node = resolveNode(task, snap); if (node && !task.examLinkClickedAt) { @@ -3031,16 +3097,7 @@ window.JSZip = module.exports; } else taskState(task, 'waiting', '考试控制器尚未接入,请人工处理'); } - const exam = typeof options.getExamState === 'function' ? options.getExamState() : null; - if (exam) { - state.exam = Object.assign({ taskId: task.id, title: task.title }, clone(exam)); - state.exam.total = exam.total == null && exam.details ? exam.details.length : exam.total; - state.exam.verified = exam.verifiedCount == null ? exam.verified : exam.verifiedCount; - const stage = exam.stage || exam.status; - if (stage === 'completed' || stage === 'submitted' || exam.submitted === true && exam.confirmed === true) { task.progress = 1; taskState(task, 'completed', '网站已确认本场考试提交完成'); activeTaskId = ''; await release(); } - else if (['waiting', 'paused', 'failed', 'unknown', 'awaitingAttempt'].includes(stage)) taskState(task, 'waiting', exam.reason || exam.message || '考试等待人工处理'); - else if (stage === 'waitingSubmit') taskState(task, 'waitingSubmit', exam.reason || '已核验,等待最早交卷时间'); - } + await observeExamState(task, false); } async function parallelTick(snap, page) { if (config.execution !== 'parallel' || worker || !session) return; @@ -3106,10 +3163,13 @@ window.JSZip = module.exports; await initialize(); const snap = snapshot(); if (!snap.document) return getState(); const page = describePage(snap.document, snap.url); if (!safeURL(snap.url, snap.url)) { if (state.status === 'running') await pause('离开已授权站点,任务已暂停'); return getState(); } - if (session && (state.status === 'running' || sharedPaused)) { + if (session) { const controlValue = await storage.get(CONTROL_KEY); if (await observeControl(controlValue)) return getState(); - if (worker && controlValue && controlValue.config) Object.assign(config, controlValue.config); + if (worker && controlValue && controlValue.sessionId === session.id && controlValue.config) { + const sharedConfig = Object.assign({}, controlValue.config); delete sharedConfig.manualConfirmIncomplete; + Object.assign(config, sharedConfig); + } if (session.accountId && session.accountId !== account(snap, page) || session.bankVersion && snap.bankVersion && session.bankVersion !== snap.bankVersion) { await pause('账号或题库版本改变,已暂停旧任务'); return getState(); } } if (pageKey !== page.url) { pageKey = page.url; playStarted = false; lastTime = null; attemptedAt = 0; lastAdvancedAt = now(); } @@ -3118,7 +3178,7 @@ window.JSZip = module.exports; if (task.title !== page.title && canonical(task.url || task.pageUrl) !== page.url) taskState(task, 'waiting', '当前课程与已领取任务不同,请核对后继续'); else { mergeTasks(discoverTasks(snap.document, snap.url), task); taskState(task, 'expanded', '已读取课程目录;课件须逐一等待平台确认完成'); activeTaskId = ''; await release(); task = null; } } else mergeTasks(discoverTasks(snap.document, snap.url)); - if (state.status !== 'running') { notify(); return getState(); } + if (state.status !== 'running') { await persist(); notify(); return getState(); } if (task && TERMINAL.includes(task.status)) { activeTaskId = ''; await release(); task = null; } if (!task) { const candidates = state.tasks.filter(t => t.status === 'pending' && allows(t.kind) && (!worker || t.id === worker.taskId)); @@ -3131,7 +3191,7 @@ window.JSZip = module.exports; const held = await heartbeat(task) || await claim(task); if (!held) state.message = '等待同类任务租约释放,暂不操作当前页面'; else if (task.status !== 'waiting' && task.kind === 'video') await videoTick(task, snap, page, epoch); - else if (task.status !== 'waiting' && task.kind === 'exam') await examTick(task, snap, page); + else if (task.kind === 'exam') await examTick(task, snap, page); } if (epoch === generation) await parallelTick(snap, page); if (worker && session) { const request = await storage.get('worker:' + worker.nonce); if (request) { const own = task || state.tasks.find(t => t.id === worker.taskId); request.heartbeatAt = now(); request.status = own && own.status || 'idle'; request.video = state.video; request.exam = state.exam; await storage.set('worker:' + worker.nonce, request); } } @@ -3143,8 +3203,10 @@ window.JSZip = module.exports; finally { busy = false; } } async function observeControl(value) { - if (!session || state.status !== 'running' && !sharedPaused) return false; + if (!session) return false; const shared = value === undefined ? await storage.get(CONTROL_KEY) : value; + syncManualConfirmation(shared); + if (state.status !== 'running' && !sharedPaused) return false; if (sharedPaused && shared && shared.sessionId === session.id && shared.status === 'running') { sharedPaused = false; generation++; state.status = 'running'; session.status = 'running'; state.message = '已收到继续全部任务,重新核验当前页面'; playStarted = false; manualVideoPause = false; attemptedAt = 0; lastTime = null; lastAdvancedAt = now(); const active = state.tasks.find(t => t.id === activeTaskId); if (active) { active.examRequested = false; if (active.status === 'waiting') active.status = 'pending'; } @@ -3412,7 +3474,9 @@ window.JSZip = module.exports; const complete = bank.expectedTotal > 0 && bank.expectedTotal === bank.total && boxes.length === bank.total; // The confirmed threshold is 80 raw points on the supplied 100-point paper. const scoreValid = complete && validPoints && Math.abs(totalScore - 100) < 0.000001; + const fullyVerified = complete && matchedCount === bank.total && verifiedCount === bank.total; return { title: bank.title, total: bank.total, complete, totalScore, predictedScore, matchedCount, verifiedCount, + fullyVerified, unverifiedCount: Math.max(0, bank.total - verifiedCount), missing: details.filter(item => item.status === 'missing').length, conflict: details.filter(item => item.status === 'conflict').length, scoreValid, details, signature: JSON.stringify([bank.title, bank.expectedTotal, bank.questions.map((q, index) => [q.type, q.question, q.options.map(o => [o.label, o.text]), details[index].points])]), @@ -3455,6 +3519,10 @@ window.JSZip = module.exports; return /"(?:attemptid|examattemptid|answerrecordid|examrecordid|paperrecordid|recordid|data-attempt-id|data-exam-attempt-id|data-answer-record-id|data-exam-record-id)"/.test(keyFor(ctx)); } function settings() { return options.getSettings ? options.getSettings() : {}; } + function manualIncomplete() { return settings().manualConfirmIncomplete !== false; } + function scopeValue(ctx, evidence, topic) { + return JSON.stringify([keyFor(ctx), ctx.revision, evidence.signature, topic?.courses, manualIncomplete()]); + } function resultVisible() { return Array.from(doc.querySelectorAll('.exam_submited_box,.exam-result,app-exam-result,[data-exam-result]')) .some(node => visible(node) && /提交成功|交卷成功|考试结束|得分|成绩/.test(node.textContent)); @@ -3510,20 +3578,29 @@ window.JSZip = module.exports; enabled = false; await observe(); if (token !== generation || !sameContext(requested, context()) || !entry || ['submitting', 'confirming', 'unknown'].includes(stage) || destroyed || resultVisible()) return false; + if (entry.manualRequired && stage !== 'awaitingAttempt' && manualIncomplete()) { + stage = 'waiting'; error = '网站提示存在未作答题,请在原网页手动确认交卷'; + emit({ requiresManualSubmission: true, message: error }); return false; + } const ctx = context(), topic = options.getTopic(); if (!topic || topic.status !== 'matched') { error = topic?.reason || '请先确认考试主题与题库关联'; emit({ message: error }); return false; } const evidence = readPaper(doc, ctx, options.getSnapshot(), topic); if (!evidence.complete || !evidence.scoreValid || !ctx.ready || !ctx.exam || ctx.submitted) { emit({ message: evidence.reason || '等待整卷加载完成后启动考试任务' }); return false; } + if (Array.from(doc.querySelectorAll('.dialog_box,[role="dialog"],.modal')).some(visible)) { + emit({ message: '请先在网页取消或关闭当前交卷对话框,再继续任务' }); return false; + } if (stage === 'awaitingAttempt') { const timer = timerEvidence(ctx); entry = { startedAt: timer.startedAt, initialRemaining: timer.remain, lastRemaining: timer.remain, lastObservedAt: timer.stamp, stage: 'idle' }; } - scope = JSON.stringify([currentKey, ctx.revision, evidence.signature, topic.courses]); + if (!manualIncomplete() && entry.manualRequired) { entry.manualRequired = false; entry.confirmed = false; delete entry.submitAt; } + scope = scopeValue(ctx, evidence, topic); enabled = true; stage = 'answering'; error = ''; entry.stage = stage; entry.paperSignature = evidence.signature; await persist(); if (token !== generation || destroyed || !enabled || !sameContext(ctx, context()) || !context().ready || options.getTopic()?.status !== 'matched' || scope !== scopeFor(context())) { if (token === generation) disable('启动过程中页面、题库或任务状态已变化,请重新启动'); return false; } - log('当前考试已授权:全文核验后严格大于 80 分且达到最早交卷时间才交卷'); + log(manualIncomplete() ? '当前考试已授权:题目不全时由用户在网页确认;整卷核验后按得分和时长交卷' + : '题目不全时手动确认已关闭:全部可匹配题核验后,严格大于 80 分且达到最早时长才交卷'); return true; } function disable(reason) { @@ -3535,7 +3612,7 @@ window.JSZip = module.exports; } function scopeFor(ctx) { const topic = options.getTopic(), evidence = readPaper(doc, ctx, options.getSnapshot(), topic); - return JSON.stringify([keyFor(ctx), ctx.revision, evidence.signature, topic?.courses]); + return scopeValue(ctx, evidence, topic); } function canClick(token, ctx, confirmation) { if (token !== generation || destroyed || !enabled || !sameContext(ctx, context())) return false; @@ -3543,8 +3620,9 @@ window.JSZip = module.exports; const configured = Number(settings().minSubmitMinutes), minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360; const pendingTooLong = confirmation && now() - entry.submitAt > 12000; return current.mode === 'test' && current.root?.isConnected && current.ready && topic?.status === 'matched' - && scope === JSON.stringify([keyFor(current), current.revision, evidence.signature, topic.courses]) - && evidence.scoreValid && evidence.matchedCount === evidence.verifiedCount && evidence.predictedScore > 80 + && scope === scopeValue(current, evidence, topic) + && evidence.scoreValid && evidence.matchedCount === evidence.verifiedCount + && (!manualIncomplete() || evidence.fullyVerified && !entry.manualRequired) && evidence.predictedScore > 80 && options.getRunnerState() === 'complete' && (now() - entry.startedAt) / 1000 >= minimum && remainingSeconds(doc) !== 0 && !pendingTooLong && (confirmation || current.exam && !current.submitted); } @@ -3552,6 +3630,9 @@ window.JSZip = module.exports; const dialogs = Array.from(doc.querySelectorAll('.dialog_box,[role="dialog"],.modal')).filter(visible); const containers = dialogs.filter(node => !dialogs.some(other => other !== node && other.contains(node))); if (!containers.length) return { pending: true }; + if (manualIncomplete() && containers.some(node => /未答|未作答|没有作答|尚未作答|漏答|未完成.*题|题.*未完成/.test(clean(node.textContent)))) { + return { manualRequired: true, reason: '网站提示存在未作答题,请在原网页手动确认交卷' }; + } const matches = containers.filter(node => /(?:确认|确定|是否).*交卷|交卷.*(?:确认|确定)|未答.*(?:提交|交卷)|(?:提交|交卷).*未答/.test(clean(node.textContent))); if (matches.length !== 1 || /验证|人脸|异常|失败|重新考试/.test(matches[0].textContent)) return { reason: '交卷对话框无法唯一确认,请人工检查' }; const buttons = Array.from(matches[0].querySelectorAll('button,[role="button"],.btn,.submited_btn')) @@ -3570,7 +3651,7 @@ window.JSZip = module.exports; if (resultVisible()) { stage = 'submitted'; entry.stage = stage; enabled = false; await persist(); if (token !== generation || destroyed || !sameContext(ctx, context())) return last; - return emit({ message: '网站已显示考试结果', submitted: true }); + return emit({ message: '网站已显示考试结果', submitted: true, requiresManualSubmission: false }); } const topic = options.getTopic(), evidence = readPaper(doc, ctx, options.getSnapshot(), topic); const elapsedSeconds = Math.max(0, (now() - entry.startedAt) / 1000); @@ -3578,11 +3659,14 @@ window.JSZip = module.exports; const minimum = Number.isFinite(configured) && configured > 0 ? configured * 60 : 360; const waitSeconds = Math.max(0, Math.ceil(minimum - elapsedSeconds)); const remaining = remainingSeconds(doc); + const runner = options.getRunnerState(); entry.lastObservedAt = now(); entry.lastRemaining = remaining; emit(Object.assign({}, evidence, { elapsedSeconds: Math.floor(elapsedSeconds), waitSeconds, remainingSeconds: remaining, + manualConfirmIncomplete: manualIncomplete(), + requiresManualSubmission: manualIncomplete() && (Boolean(entry.manualRequired) || runner === 'complete' && !evidence.fullyVerified), predictedScore: Math.round(evidence.predictedScore * 100) / 100, submitted: false, message: error || evidence.reason })); if (!enabled) return last; - if (scope !== JSON.stringify([currentKey, ctx.revision, evidence.signature, topic?.courses]) || topic?.status !== 'matched') { + if (scope !== scopeValue(ctx, evidence, topic) || topic?.status !== 'matched') { disable('试卷、题库或主题关联已变更,请重新启动核验'); return last; } if (remaining === 0 || ctx.submitted && !['submitting', 'confirming'].includes(stage)) { disable('网站考试已经结束或提交'); return last; } @@ -3591,7 +3675,6 @@ window.JSZip = module.exports; if (token !== generation || destroyed || !sameContext(ctx, context())) return last; return emit({ message: '提交结果未确认,已停止重复提交,请检查网站状态' }); } - const runner = options.getRunnerState(); const allVerified = evidence.matchedCount === evidence.verifiedCount; if (!evidence.scoreValid || !allVerified || runner !== 'complete') { if (stage === 'confirming' || stage === 'submitting') { @@ -3599,14 +3682,28 @@ window.JSZip = module.exports; if (token !== generation || destroyed || !sameContext(ctx, context())) return last; return emit({ message: '交卷过程中核验条件变化,等待人工检查' }); } + if (runner === 'complete') { + enabled = false; stage = 'waiting'; error = evidence.reason || '题目未全部核验,请在网页检查未核验题并手动确认交卷'; + return emit({ requiresManualSubmission: true, message: error }); + } stage = 'answering'; return emit({ message: evidence.reason || '等待所有可匹配题处理并回读核验' }); } + if (manualIncomplete() && !evidence.fullyVerified) { + enabled = false; stage = 'waiting'; + error = '题目未全部核验(' + evidence.verifiedCount + '/' + evidence.total + '),请在网页检查未核验题并手动确认交卷'; + log(error); return emit({ requiresManualSubmission: true, message: error }); + } if (!(evidence.predictedScore > 80)) { stage = 'waiting'; return emit({ message: '预计得分小于或等于 80 分,等待人工操作' }); } if (remaining !== null && remaining <= waitSeconds) { stage = 'waiting'; return emit({ message: '网站截止时间早于最早交卷时间,等待人工操作' }); } if (waitSeconds) { stage = 'waitingSubmit'; return emit({ message: '核验完成,等待最早交卷时间' }); } if (stage === 'confirming' || stage === 'submitting') { const target = confirmationButton(); if (target.pending) return last; + if (target.manualRequired) { + generation++; enabled = false; stage = 'waiting'; entry.stage = 'manualRequired'; entry.manualRequired = true; + error = target.reason; await persist(); log(error); + return emit({ requiresManualSubmission: true, message: error }); + } if (target.reason) { disable(target.reason); return last; } if (!entry.confirmed) { entry.confirmed = true; entry.stage = stage = 'confirming'; await persist(); @@ -3665,7 +3762,7 @@ window.JSZip = module.exports; let positionRevision = 0; let learning = null, examController = null, learningState = {}, examState = {}, runnerState = 'idle'; let actionEpoch = 0; - let learningSettings = { mode: 'videoQuiz', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, associations: {} }; + let learningSettings = { mode: 'videoQuiz', execution: 'sequential', rate: 1, answerInterval: 4, minSubmitMinutes: 6, manualConfirmIncomplete: true, associations: {} }; const inScope = () => YianSiteScope.isAllowedURL(location.href); function topicPreflight() { @@ -3844,10 +3941,11 @@ window.JSZip = module.exports; }); async function changeLearningSetting(key, value) { - const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', 'associations', 'stallSeconds', 'retryDelaySeconds', 'maxRefresh']; + const allowed = ['mode', 'execution', 'rate', 'answerInterval', 'minSubmitMinutes', 'manualConfirmIncomplete', 'associations', 'stallSeconds', 'retryDelaySeconds', 'maxRefresh']; if (!allowed.includes(key)) return; if (key === 'mode' && !['video', 'videoQuiz', 'exam', 'all'].includes(value)) throw new Error('请选择有效任务模式'); if (key === 'execution' && !['sequential', 'parallel'].includes(value)) throw new Error('请选择顺序或并行'); + if (key === 'manualConfirmIncomplete' && typeof value !== 'boolean') throw new Error('题目不全时手动确认须为开或关'); const ranges = { rate: [1, 2], answerInterval: [0, 120], minSubmitMinutes: [0.5, 180], stallSeconds: [1, 600], retryDelaySeconds: [1, 120], maxRefresh: [1, 3] }; if (ranges[key]) { const range = ranges[key]; value = Number(value); @@ -3859,7 +3957,7 @@ window.JSZip = module.exports; || Object.values(value).some(courses => !Array.isArray(courses) || courses.some(course => typeof course !== 'string' || !course.trim())))) { throw new Error('主题关联须使用完整标题及题库课程名称列表'); } - if (['mode', 'execution', 'associations'].includes(key)) { + if (['mode', 'execution', 'associations', 'manualConfirmIncomplete'].includes(key)) { actionEpoch++; config.autoAnswer = false; runner.stop('执行模式或主题关联变更'); examController.disable('设置变更,须重新启动核验'); await learning.pause(); @@ -3881,6 +3979,9 @@ window.JSZip = module.exports; } else if (action === 'videoPause' || action === 'videoResume') await learning.configure({ videoPaused: action === 'videoPause' }); else if (action === 'examPause') { await learning.configure({ examPaused: true }); setAutoAnswer(false); } else if (action === 'examResume') { + if (learning.getState().status !== 'running') { + panel.setNotice('当前计划已暂停,请先在任务总览点击“继续上次任务”重新核验。', 'warning'); return; + } await learning.configure({ examPaused: false }); const epoch = actionEpoch; if (await examController.enable() && epoch === actionEpoch) setAutoAnswer(true); @@ -3889,7 +3990,9 @@ window.JSZip = module.exports; } const learningStorage = { get: (key, fallback) => store.getSetting('learning:' + key, fallback), set: (key, value) => store.setSetting('learning:' + key, value) }; examController = window.YianExamController.create({ getContext, getSnapshot: () => snapshot, - getTopic: topicPreflight, getSettings: () => learningSettings, getRunnerState: () => runnerState, + getTopic: topicPreflight, getSettings: () => Object.assign({}, learningSettings, { + manualConfirmIncomplete: learning ? learning.getState().manualConfirmIncomplete !== false : learningSettings.manualConfirmIncomplete !== false + }), getRunnerState: () => runnerState, storage: learningStorage, onLog: (level, message) => learning?.log(level, message), onState: state => { examState = state; renderLearning(); } }); learning = window.YianLearningEngine.create({ @@ -3922,7 +4025,13 @@ window.JSZip = module.exports; if (typeof GM_openInTab === 'function') return GM_openInTab(url, { active: false, insert: true, setParent: true }); const child = window.open(url, '_blank'); if (!child) throw new Error('浏览器阻止并行标签页,请允许打开后重试'); return child; }, - onState: state => { learningState = state; renderLearning(); } + onState: state => { + learningState = state; + if (!loading && typeof state.manualConfirmIncomplete === 'boolean') { + learningSettings.manualConfirmIncomplete = state.manualConfirmIncomplete; + } + renderLearning(); + } }); function tick() { diff --git a/需求.md b/需求.md index 2faa3bc..04f16de 100644 --- a/需求.md +++ b/需求.md @@ -20,6 +20,7 @@ | 答题间隔 | 默认 4 秒,定义为相邻两题之间的等待;多选内部选项调整不分别等待 4 秒 | 默认值来自原需求,间隔口径为补充建议 | | 交卷计时 | 从本次实际进入试卷、开始考试起计算,默认至少 6 分钟;页面刷新或续作不重新起算 | 用户已确认 | | 得分门槛 | 预计得分严格大于 80 分才允许自动交卷;正好 80 分或更低,等待用户操作 | 用户已确认 | +| 缺题人工确认 | “考试答题”栏提供“题目不全时手动确认交卷”开关(`manualConfirmIncomplete`),默认开启;开启时任意题库缺题或未答完提醒都由用户在网站确认,关闭时允许满足原得分、计时及全文核验条件的部分题库试卷自动交卷 | 用户最新确认,默认开启 | | 题库结构 | 原主操、副操、班长题库统一合并使用,取消岗位选择及岗位隔离 | 用户已确认 | | 题库对应 | 考试前检查当前考试主题与统一题库中课程/主题是否对应;没有对应题库则跳过该考试 | 用户已明确 | | 执行方式 | 顺序、并行可切换,默认顺序 | 用户已确认 | @@ -109,22 +110,27 @@ - 无法核对试卷总分、题数或分值合理性时暂停自动交卷,等待人工检查。 - 展示得分构成:哪些题已核验并计分,哪些题未计分及原因,便于用户检查。 -### 7.3 自动交卷条件(须同时满足) +### 7.3 自动交卷条件与缺题人工确认开关 -1. 考试主题与题库对应关系已确认,整卷加载和预检完成。 -2. 所有可自动匹配的题目已经处理,并完成当前选中内容的回读核对。 +以下条件在开关开启或关闭时都必须满足: + +1. 考试主题与题库对应关系已确认,网页题目及答题卡总数完整,整卷加载和预检完成,各题分值与试卷分制可核对。 +2. 所有可自动匹配的题目均已唯一精确匹配、完成作答,并通过当前选中答案全文集合的回读核对;可匹配题尚未处理或核验失败时不能交卷。 3. 可核验预计得分严格大于 80 分;正好 80 分不自动提交。 4. 从本次实际进入试卷起至少经过 6 分钟,或达到用户设置的其他最早交卷时长。 5. 当前试卷仍有效、未提交、未结束,且本任务没有暂停或等待人工操作。 -- 可存在未匹配题,只要已核验预计得分严格大于 80 分,且以上其他条件满足,才可自动交卷;未匹配题仍不猜答。 +- **开关开启(默认):** 还要求整卷每一道题均唯一精确匹配且当前选中全文核验通过。存在未匹配、冲突、未核验、无法唯一映射或不支持的题目时,进入“等待人工”;即使预计得分严格大于 80 分且已满 6 分钟,也不得自动点击交卷或确认,由用户在网站正常流程中手动确认交卷。网站显示未答完提醒时停止自动确认。 +- **开关关闭:** 恢复部分题库的自动交卷规则。网页整卷已完整加载、全部可匹配题已处理并全文核验、已核验预计得分严格大于 80 分且达到最早交卷时长后,可保留未匹配、冲突或不支持的未知题并自动交卷;未知题仍不猜答、不计正分。允许确认本场正常的未答完提醒。 +- 开关只决定题库缺题及正常未答完提醒是否由人工确认,不能放行网页题目或答题卡加载不全、分值或分制不明、主题关联不明确、得分小于或等于 80 分、计时未到等情况。题目或答题卡加载不全时始终不得自动交卷。 +- 用户补选的未知答案仍不自动计为正确。开关开启时,须题库补全或消歧并重新核验整卷,才可重新评估自动交卷条件;也可由用户自行在网站确认交卷。 - 预计得分小于或等于 80 分时进入“等待人工”,即使已满 6 分钟也不自动提交、不自动刷新。用户处理后须重新核验;不确定的人工答案仍不能自动计为正确。 -- 自动交卷只操作当前场次的正常交卷及确认流程,包括正常的未答完提醒;出现异常、身份验证或结果不明确时停止并提示,不能盲目重复确认。 +- 自动交卷只操作通过当前开关规则及全部通用条件的当前场次正常交卷及确认流程;异常、身份验证或结果不明确时始终停止并提示人工处理,不能盲目重复确认。 - 以网站显示提交成功或成绩结果确认完成,不能仅凭点击交卷按钮就标记完成。提交结果不明时先检查状态,避免重复提交。 -- 用户取消交卷、手动修改答案或暂停任务时,取消原自动交卷计划,并使相应旧核验结果失效。 +- 用户取消交卷、手动修改答案、暂停任务或切换缺题人工确认开关时,取消原自动交卷计划,并使相应旧核验结果失效。切换开关同时暂停当前答题流程,不能直接触发交卷;须用户明确继续后重新预检和核验。 - 脚本暂停不会暂停网站考试倒计时;若脚本最早交卷时长与网站截止时间冲突,提前提示并等待用户处理。网站自身到时自动交卷仍可能生效。 -示例:满分 100 分时,预计 84 分但只经过 4 分钟,应继续等待;预计 84 分且满 6 分钟、核验完成,可以自动交卷;预计 80 分或 78 分,满 6 分钟后仍等待人工。 +示例:满分 100 分且网页整卷完整时,已核验 84 分但仍有 4 题未匹配,开关开启则满 6 分钟仍等待人工,关闭则可在全部可匹配题核验、满 6 分钟并满足其他条件后自动交卷及确认正常未答完提醒。整卷全部题目核验通过、预计 100 分时,经过 4 分钟应继续等待,满 6 分钟且其他条件满足才可自动交卷;预计 80 分或 78 分,以及网页题目/答题卡未加载完整,在任何开关状态下都不自动交卷。 ## 8. 异常恢复、刷新和后台运行 @@ -148,7 +154,7 @@ |---|---| | 任务总览 | 学习计划与任务清单、四种任务模式、顺序/并行、开始/停止本轮、总进度、完成/跳过/失败/等待人工统计、预计剩余时间;显示当前视频与考试摘要,点击可进入对应功能栏 | | 视频学习 | 当前课程和课件、播放位置/总时长、平台已记录学习时长与完成状态、最高 2X 的倍速设置、视频任务暂停/继续、课中题匹配及处理状态、卡顿恢复状态 | -| 考试答题 | 当前考试主题与题库预检结果、自动选择答案开关、默认 4 秒的答题间隔、默认 6 分钟的最早交卷时长、网站剩余时间、预计得分与严格大于 80 分的门槛、交卷等待原因、答案全文核对与未处理题清单 | +| 考试答题 | 当前考试主题与题库预检结果、自动选择答案开关、默认开启的“题目不全时手动确认交卷”开关、默认 4 秒的答题间隔、默认 6 分钟的最早交卷时长、网站剩余时间、预计得分与严格大于 80 分的门槛、交卷等待原因、答案全文核对与未处理题清单 | | 题库搜索 | 统一题库导入与状态、来源记录数/题目数/冲突数、关键词搜索、课程/主题筛选、选中文字搜索、答案全文与来源、主题关联查看和维护;不显示岗位选择 | | 控制台 | 带时间和任务名称的运行日志、状态/警告/错误筛选、复制日志、清空显示、恢复次数及交卷结果 | | 其他设置 | 允许复制开关、默认关闭的题库导出开关及折叠区、异常恢复参数、后台运行状态说明 | @@ -160,6 +166,7 @@ - 切换功能栏只改变可见内容,不停止或重启任务,不重新开始考试计时,不触发导出,也不改变开关。面板折叠后继续保留当前运行状态,并显示简要进度与需人工处理提示。 - 首次展开默认进入“任务总览”;同一会话内记住当前功能栏、搜索词、筛选项及滚动位置,切回来不清空内容。 - 视频与考试的局部暂停只影响对应任务,全局暂停影响所有任务。关闭“自动选择答案”应停止当前后续选项点击,并取消该场考试的自动交卷计划;切换功能栏不能再次启用。 +- “题目不全时手动确认交卷”开关默认开启,设置跨页面和刷新保存。切换时暂停当前考试的答题与交卷计划,须明确继续并重新核验后才可执行,不能因关闭开关立即提交。 - 各设置仅在所属栏编辑,总览展示摘要并提供跳转,避免多处控件的值不一致。未启动或不适用的功能显示原因,不能隐藏正在等待人工处理的问题。 - 当前模式未启用的视频或考试栏仍可查看设置和记录,并显示“本模式未启用”;打开该栏不能启动对应任务。共用的答题间隔只保存一份,在课中题与考试中保持一致。 - “考试答题”栏保留现有练习页的自动选择与答案核对入口,进入练习页时显示练习状态;练习和课中题不套用考试交卷倒计时与得分门槛。 @@ -186,7 +193,11 @@ | 旧题库迁移与兼容 | 已存题库自动迁移,原 ZIP/CSV 与无岗位字段的新题库均可导入;重复迁移或导入不增生重复记录 | | 题目乱序和分值标记 | 按正确全文映射,不依赖旧字母;分值和难度不污染题干 | | 不同题型分值 | 按实际分值累计,仅完整核验通过的题得分,未知题不计正分 | -| 预计得分超过 80 | 例如 84 分,满 6 分钟并完成核验后才提交,可保留少量未知题 | +| 开关开启且题库缺题 | 例如已核验 84 分、缺少 4 题,满 6 分钟后仍等待用户在网站手动确认,脚本不点击任何交卷或确认按钮;正常未答完提醒也不自动确认 | +| 开关关闭且题库缺题 | 网页整卷完整、全部可匹配题处理并核验、预计 84 分且满 6 分钟并满足其他通用条件后,可自动交卷及确认正常未答完提醒,未知题仍不猜答、不计正分 | +| 整卷完整且全部核验 | 例如 100 分,整卷及答题卡完整、每题唯一精确匹配且当前选中全文核验通过,满 6 分钟并满足其他条件后才自动提交 | +| 通用门槛不满足 | 网页题目或答题卡加载不全、分值不明、主题不明确或预计分值不超过 80 等情况,在任何开关状态下都不自动交卷 | +| 切换缺题人工确认开关 | 默认开启,跨页和刷新保存;切换暂停答题/交卷计划,明确继续后重验,关闭时不立即提交 | | 预计得分等于/低于 80 | 进入等待人工,不交卷、不因等待而刷新 | | 交卷计时与恢复 | 从同一场考试原始开始时间计时,恢复不清零,不产生重复提交 | | 卡顿和正常暂停 | 异常按次数上限恢复;用户暂停、课中题与等待交卷不误刷新 |