Files
icampus-learning-assistant/quiz-helper.test.cjs

135 lines
8.7 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const core = require('./icampus-study-helper.user.js');
const {harness, ID} = require('./study-helper.test.cjs');
let passed=0;
function test(name, fn) { fn(); ++passed; console.log(`PASS ${name}`); }
const q = (id='q1',type='single') => ({id,title:`题目 ${id}`,type,group:'',options:[{key:'A',text:'Alpha'},{key:'B',text:'Beta'}],chosen:['Alpha']});
test('question identity and remembered choices survive option reorder', () => {
const a=q(), b={...a,options:[{key:'A',text:'Beta'},{key:'B',text:'Alpha'}]};
assert.equal(core.questionKey(a),core.questionKey(b));
assert.deepEqual(core.chooseAnswer(b,{correct:['Alpha'],wrong:[]}),['Alpha']);
});
test('different options or grouped context do not share a remembered answer', () => {
const a=q(), b={...a,group:'另一段材料'}; assert.notEqual(core.questionKey(a),core.questionKey(b));
assert.notEqual(core.questionKey(a),core.questionKey({...a,options:[{key:'A',text:'different'}]}));
});
test('single choice excludes confirmed incorrect answers and stops at exhaustion', () => {
assert.deepEqual(core.chooseAnswer(q(),{wrong:['["Alpha"]']}),['Beta']);
assert.equal(core.chooseAnswer(q(),{wrong:['["Alpha"]','["Beta"]']}),null);
});
test('multi choice explores combinations, including all choices', () => {
const a=q('multi','multi'), wrong=[];
for(let i=0;i<3;i++) { const choice=core.chooseAnswer(a,{wrong}); assert.ok(choice); wrong.push(core.selectionKey(choice)); }
assert.equal(new Set(wrong).size,3); assert.ok(wrong.includes('["Alpha","Beta"]'));
assert.equal(core.chooseAnswer(a,{wrong}),null);
});
test('ambiguous options and non-option questions require manual handling', () => {
assert.equal(core.chooseAnswer({...q(),options:[{text:'same'},{text:'same'}]},{}),null);
assert.equal(core.chooseAnswer({...q(),type:'essay'},{}),null);
});
test('only explicit, matching per-question feedback teaches the bank', () => {
const bank={}, snapshot={questions:[q()]};
assert.equal(core.learnCard(bank,snapshot,[{questionId:'q1',selectedOptionKey:'A'}]),0);
assert.equal(core.learnCard(bank,snapshot,[{questionId:'q1',selectedOptionKey:'B',correct:true}]),0);
assert.equal(core.learnCard(bank,snapshot,[{questionId:'different',selectedOptionKey:'A',correct:true}]),0);
assert.equal(core.learnCard(bank,snapshot,[{questionId:'q1',selectedOptionKey:'A',correct:true}]),1);
assert.deepEqual(bank[core.questionKey(q())].correct,['Alpha']);
});
test('quiz observer scopes exact endpoints and origins', () => {
const base='https://icampus.yunyinsh.com/';
assert.equal(core.quizEndpoint(`/smtp/api/my-courses/${ID}/exam`,base).kind,'exam');
assert.equal(core.quizEndpoint('/smtp/api/my-courses/exam-papers/999/answer-card',base).kind,'card');
assert.equal(core.quizEndpoint('/smtp/api/my-courses/exams/999/submit',base).kind,'submit');
assert.equal(core.quizEndpoint('https://foreign.test/smtp/api/my-courses/exams/999/submit',base),null);
});
// Simulates the observed Vue DOM and its normal request/response sequence.
// The userscript must click choices, submit, confirm, read the answer card and retry.
function simulator({feedback=true,exhaust=false,threshold=100}={}) {
const h=harness(); let view='idle', dialog=false, attempt=0, submissions=0;
let answers={}, selected={}, lastCard=[],scorePercent=0;
const definitions=[q('q1'),q('q2'),q('q3','multi')];
const expected={q1:['A'],q2:['B'],q3:['A','B']};
const clicks=[];
const element = extra => ({getClientRects:()=>[{}],...extra});
const button = (text,click) => element({textContent:text,disabled:false,click});
const begin = () => {
++attempt; view='form'; selected={};
for(const d of definitions) selected[d.id]=attempt===1?[]:[...(answers[d.id]||[])];
h.api.receive({kind:'exam',id:ID},{bizCode:0,data:{examId:String(900+attempt),paperId:'800',status:'IN_PROGRESS',maxAttempts:null,
questions:definitions.map(d=>({questionId:d.id}))}},200);
};
const retry = button('重新测验',() => {
if(feedback) h.api.receive({kind:'card',paperId:'800'},{bizCode:0,data:lastCard},200);
begin();
});
const start=button('开始测验',begin);
const submit=button('交卷',()=>{dialog=true;});
const hiddenFooter=button('交卷',()=>{throw new Error('Must not click hidden mobile footer');});
hiddenFooter.getClientRects=()=>[];
const confirm=button('确定交卷',()=>{
dialog=false; ++submissions; answers=structuredClone(selected);
const body={answers:definitions.map(d=>d.type==='multi'?{questionId:d.id,selectedOptionKeys:answers[d.id]}:{questionId:d.id,selectedOptionKey:answers[d.id][0]})};
lastCard=body.answers.map(a=>({...a,correct:JSON.stringify([...(answers[a.questionId]||[])].sort())===JSON.stringify(expected[a.questionId])}));
const score=lastCard.filter(a=>a.correct).length/definitions.length*100;
scorePercent=score;
const examId=String(900+attempt);
h.api.receive({kind:'answers',examId,body},{bizCode:0,data:{}},200);
h.api.receive({kind:'submit',examId},{bizCode:0,data:{scorePercent:score,passed:score>=threshold,canRetry:!exhaust,attemptsExhausted:exhaust}},200);
view=score>=threshold?'pass':'fail';
});
const dialogNode=element({querySelector:s=>s==='.van-dialog__header'?{textContent:'确认交卷'}:null,querySelectorAll:s=>s==='button'?[confirm]:[]});
const nodes=definitions.map(d=>element({getAttribute:()=>d.id,
querySelector:s=>s==='.quiz-question__title'?{textContent:d.title}:s==='.quiz-options--multi'&&d.type==='multi'?{}:null,
querySelectorAll:s=>s==='button.quiz-option'?d.options.map(o=>element({
classList:{contains:()=>selected[d.id]?.includes(o.key)},
querySelector:s=>s==='.quiz-option__key'?{textContent:o.key}:s==='.quiz-option__text'?{textContent:o.text}:null,
click(){clicks.push({attempt,id:d.id,key:o.key});if(d.type==='single')selected[d.id]=[o.key];else selected[d.id]=selected[d.id].includes(o.key)?selected[d.id].filter(k=>k!==o.key):[...selected[d.id],o.key];}
})):[]}));
const root=element({
querySelector(s){
if(s==='.quiz-result'&&['pass','fail'].includes(view)) return element({querySelector:s=>s==='.quiz-result__status.is-pass'&&view==='pass'?{}:s==='.quiz-result__score strong'?{textContent:scorePercent+'%'}:null});
if(s==='.quiz-result__exhausted'&&view==='fail'&&exhaust)return {};
if(s==='button.quiz-footer__submit'&&view==='form') return hiddenFooter;
return null;
},
querySelectorAll(s){if(s==='article.quiz-question')return view==='form'?nodes:[];if(s==='button')return view==='idle'?[start]:view==='fail'?[retry]:view==='form'?[hiddenFooter,submit]:[];return [];}
});
h.selectors.set('.course-quiz',root);
const original=h.context.document.querySelectorAll;
h.context.document.querySelectorAll=s=>s==='.van-dialog'?(dialog?[dialogNode]:[]):original(s);
h.api.begin();
return {h,clicks,get submissions(){return submissions;},get view(){return view;},
run(){for(let i=0;i<150&&h.api.state.running&&view!=='pass';i++){h.advance(1000);h.api.tickQuiz(ID);}if(view==='pass')h.api.tickQuiz(ID);}};
}
test('full DOM workflow reaches 100%, learns correct questions and changes only wrong ones', () => {
const s=simulator();s.run();assert.equal(s.view,'pass');assert.equal(s.submissions,3);
assert.equal(s.clicks.filter(c=>c.id==='q1').length,1);
assert.ok(Object.values(s.h.api.state.bank).every(e=>e.correct?.length));
assert.equal(Object.keys(s.h.api.state.bank).length,3);
assert.ok(s.h.storage.has('icampus.study.v1.answers'));
});
test('missing per-question feedback stops before duplicate submission', () => {
const s=simulator({feedback:false});s.run();assert.equal(s.submissions,1);assert.equal(s.h.api.state.running,false);
assert.match(s.h.api.state.status,/逐题反馈/);
});
test('attempt exhaustion stops without clicking retry', () => {
const s=simulator({exhaust:true});s.run();assert.equal(s.submissions,1);assert.equal(s.h.api.state.running,false);
assert.match(s.h.api.state.status,/次数已用完/);
});
test('passing below 100 does not falsely teach the whole paper', () => {
const s=simulator({threshold:30});s.run();assert.equal(s.submissions,1);assert.equal(s.view,'pass');
assert.equal(Object.keys(s.h.api.state.bank).length,0);
});
test('foreign exam submission cannot teach this attempt', () => {
const s=simulator({feedback:false});s.run();
s.h.api.receive({kind:'submit',examId:'123456'},{bizCode:0,data:{scorePercent:100,passed:true}},200);
assert.equal(Object.keys(s.h.api.state.bank).length,0);
});
test('unlimited retakes are not stopped by the old 256-round cap',()=>{
const s=simulator();s.h.api.tickQuiz(ID);s.h.api.state.quiz.round=300;s.run();
assert.equal(s.view,'pass');assert.equal(s.submissions,3);assert.ok(s.h.api.state.armed);
});
console.log(`${passed} quiz tests passed.`);