'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.`);