Fix playback recovery after switching course videos
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
'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=[];
|
||||
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 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;
|
||||
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:view==='pass'?'100%':'33%'}:null});
|
||||
if(s==='.quiz-result__exhausted'&&view==='fail'&&exhaust)return {};
|
||||
if(s==='button.quiz-footer__submit'&&view==='form') return submit;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(s){if(s==='article.quiz-question')return view==='form'?nodes:[];if(s==='button')return view==='idle'?[start]:view==='fail'?[retry]:[];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);
|
||||
});
|
||||
console.log(`${passed} quiz tests passed.`);
|
||||
Reference in New Issue
Block a user