feat: add Huayi question bank assistant v1.1.0
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { extractQuestions, create } = require('../src/adapter.js');
|
||||
|
||||
const single = () => ({ id: 'q1', quesType: 0, content: '<p>升温时应选择?</p>', choiceItems: [
|
||||
{ id: 'a1', content: '降低温度', selected: 1, answer: 0 },
|
||||
{ id: 'a2', content: '提高温度', selected: 0, answer: 1 }
|
||||
] });
|
||||
|
||||
test('extracts confirmed choices atomically and ignores student selection', () => {
|
||||
const [q] = extractQuestions({ data: { praId: 'p1', praName: '单选题库', listQues: [single()] } });
|
||||
assert.equal(q.bankId, 'p1');
|
||||
assert.equal(q.bankName, '单选题库');
|
||||
assert.equal(q.stem, '升温时应选择?');
|
||||
assert.deepEqual(q.correctOptionIds, ['a2']);
|
||||
assert.deepEqual(q.answerTexts, ['提高温度']);
|
||||
});
|
||||
|
||||
test('selected, submitContent, userAnswer and top-level answer/answers are not standard answers', () => {
|
||||
const raw = single();
|
||||
raw.choiceItems.forEach(o => delete o.answer);
|
||||
Object.assign(raw, { answer: 'a1', answers: ['a1'], userAnswer: 'a1', submitQuesAnswers: ['a1'], corrected: true });
|
||||
const [q] = extractQuestions({ questionList: [raw] });
|
||||
assert.equal(q.answerStatus, 'missing');
|
||||
assert.deepEqual(q.answerTexts, []);
|
||||
});
|
||||
|
||||
test('judge zero and string zero mean incorrect, never truthy correct', () => {
|
||||
for (const judgeAnswer of [0, '0', 1, '1', null, '-1']) {
|
||||
const [q] = extractQuestions({ listQues: [{ id: 'j1', quesType: 2, content: '压力越高越安全。', judgeAnswer }] });
|
||||
const valid = judgeAnswer === 0 || judgeAnswer === '0' || judgeAnswer === 1 || judgeAnswer === '1';
|
||||
assert.equal(q.answerStatus, valid ? 'confirmed' : 'missing');
|
||||
assert.deepEqual(q.answerTexts, valid ? [String(judgeAnswer) === '1' ? '正确' : '错误'] : []);
|
||||
}
|
||||
});
|
||||
|
||||
test('multiple answers use ids and follow each response option order without letter assumptions', () => {
|
||||
const raw = single(); raw.quesType = 1;
|
||||
raw.choiceItems.push({ id: 'a3', content: '保持压力', answer: 1 });
|
||||
raw.choiceItems.reverse();
|
||||
const [q] = extractQuestions([raw]);
|
||||
assert.deepEqual(q.correctOptionIds, ['a3', 'a2']);
|
||||
assert.deepEqual(q.answerTexts, ['保持压力', '提高温度']);
|
||||
assert.equal(q.options.find(o => o.id === 'a2').label, 'B');
|
||||
});
|
||||
|
||||
test('answer-only responses require an identified known question', () => {
|
||||
const raw = single(); raw.choiceItems.forEach(o => delete o.answer);
|
||||
const [base] = extractQuestions(raw, { bankId: 'p1' });
|
||||
const response = [{ answers: ['a2'], corrected: false, explainText: '<b>标准解释</b>' }];
|
||||
assert.deepEqual(extractQuestions(response, { answerResponse: true, questionId: 'other', knownQuestions: [base], bankId: 'p1' }), []);
|
||||
const [q] = extractQuestions(response, { answerResponse: true, questionId: 'q1', knownQuestions: [base], bankId: 'p1' });
|
||||
assert.equal(q.questionId, 'q1');
|
||||
assert.deepEqual(q.answerTexts, ['提高温度']);
|
||||
assert.equal(q.explanation, '标准解释');
|
||||
assert.deepEqual(extractQuestions([response[0], response[0]], { answerResponse: true, questionId: 'q1', knownQuestions: [base], bankId: 'p1' }), []);
|
||||
});
|
||||
|
||||
test('cross-bank id collisions cannot attach an answer to another bank', () => {
|
||||
const [base] = extractQuestions(single(), { bankId: 'p1' });
|
||||
assert.deepEqual(extractQuestions([{ answers: ['a2'] }], { answerResponse: true, questionId: 'q1', knownQuestions: [base], bankId: 'p2' }), []);
|
||||
});
|
||||
|
||||
test('known questions support explicit correctAnswers but not invalid option ids', () => {
|
||||
const raw = single(); raw.choiceItems.forEach(o => delete o.answer);
|
||||
raw.correctAnswers = ['a2'];
|
||||
assert.deepEqual(extractQuestions(raw)[0].answerTexts, ['提高温度']);
|
||||
raw.correctAnswers = ['a1', 'a2'];
|
||||
assert.equal(extractQuestions(raw)[0].answerStatus, 'missing');
|
||||
raw.correctAnswers = ['unknown'];
|
||||
assert.equal(extractQuestions(raw)[0].answerStatus, 'missing');
|
||||
});
|
||||
|
||||
test('hidden standard answers and unrelated business objects stay unconfirmed', () => {
|
||||
const raw = single(); raw.allowViewSingleQuesAnswer = 0;
|
||||
assert.equal(extractQuestions(raw)[0].answerStatus, 'missing');
|
||||
assert.deepEqual(extractQuestions({ id: 'x', type: 0, content: '普通通知', selected: true }), []);
|
||||
});
|
||||
|
||||
test('resultInfo.answers is explicit standard-answer context on a full question', () => {
|
||||
const raw = single(); raw.choiceItems.forEach(o => delete o.answer);
|
||||
raw.answers = ['a1']; raw.resultInfo = { answers: ['a2'], explainText: '说明' };
|
||||
assert.deepEqual(extractQuestions(raw)[0].answerTexts, ['提高温度']);
|
||||
});
|
||||
|
||||
test('markup and entities preserve comparisons, units and negations', () => {
|
||||
const raw = single(); raw.content = '<p>温度<100℃,压力>2MPa时不正确的是?</p>';
|
||||
assert.equal(extractQuestions(raw)[0].stem, '温度<100℃,压力>2MPa时不正确的是?');
|
||||
});
|
||||
|
||||
test('malformed/unsupported types, circular and deeply nested payloads are bounded', () => {
|
||||
const circular = { data: null }; circular.data = circular;
|
||||
assert.deepEqual(extractQuestions(circular), []);
|
||||
assert.deepEqual(extractQuestions({ quesType: 3, id: 'x', content: '填空' }), []);
|
||||
assert.deepEqual(extractQuestions(null), []);
|
||||
assert.deepEqual(extractQuestions('not json'), []);
|
||||
assert.deepEqual(extractQuestions({ ...single(), choiceItems: [null, {}] }), []);
|
||||
const deeply = {}; let at = deeply; for (let i = 0; i < 30; i++) at = at.data = {};
|
||||
at.questionList = [single()];
|
||||
assert.deepEqual(extractQuestions(deeply), []);
|
||||
});
|
||||
|
||||
function fakePage(fetchImpl = async () => new Response('{}', { headers: { 'content-type': 'application/json' } })) {
|
||||
class XHR extends EventTarget {
|
||||
open(method, url) { this.method = method; this.url = url; }
|
||||
send(body) { this.sentBody = body; }
|
||||
getResponseHeader(name) { return name === 'content-type' ? 'application/json' : null; }
|
||||
respond(value) { this.status = 200; this.responseType = ''; this.responseText = JSON.stringify(value); this.dispatchEvent(new Event('load')); }
|
||||
}
|
||||
const doc = { title: '题库一', querySelector: () => null, querySelectorAll: () => [] };
|
||||
const win = { fetch: fetchImpl, XMLHttpRequest: XHR, location: { href: 'https://www.shhuayi-elearning.cn/o2o/' }, setTimeout, clearTimeout };
|
||||
const received = [];
|
||||
const adapter = create({ window: win, document: doc, onQuestions: values => received.push(...values) });
|
||||
return { win, doc, adapter, received };
|
||||
}
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
test('fetch observation keeps original response, snapshots bank name and restores original fetch', async () => {
|
||||
let release;
|
||||
const original = () => new Promise(resolve => { release = resolve; });
|
||||
const { win, doc, adapter, received } = fakePage(original);
|
||||
adapter.start();
|
||||
const promise = win.fetch('https://api-ulcd.yunxuetang.cn/v2/upm/start?praId=p1&token=secret');
|
||||
doc.title = '题库二';
|
||||
const response = new Response(JSON.stringify({ listQues: [single()] }), { headers: { 'content-type': 'application/json' } });
|
||||
release(response);
|
||||
assert.equal(await promise, response);
|
||||
await flush(); await flush();
|
||||
assert.equal(received.length, 1);
|
||||
assert.equal(received[0].bankName, '题库一');
|
||||
assert.equal(received[0].bankId, 'p1');
|
||||
assert.equal(received[0].source.url, 'https://api-ulcd.yunxuetang.cn/v2/upm/start');
|
||||
assert.equal(response.bodyUsed, false);
|
||||
adapter.stop();
|
||||
assert.equal(win.fetch, original);
|
||||
});
|
||||
|
||||
test('responses from a previous capture session are ignored after pause and resume', async () => {
|
||||
let release;
|
||||
const { win, adapter, received } = fakePage(() => new Promise(resolve => { release = resolve; }));
|
||||
adapter.start();
|
||||
const promise = win.fetch('/upm/start?praId=p1');
|
||||
adapter.stop(); adapter.start();
|
||||
release(new Response(JSON.stringify({ listQues: [single()] }), { headers: { 'content-type': 'application/json' } }));
|
||||
await promise; await flush(); await flush();
|
||||
assert.equal(received.length, 0);
|
||||
adapter.stop();
|
||||
});
|
||||
|
||||
test('XHR observes standard result by request question id even after another question loads', () => {
|
||||
const { win, adapter, received } = fakePage();
|
||||
const originalOpen = win.XMLHttpRequest.prototype.open;
|
||||
adapter.start();
|
||||
const initial = new win.XMLHttpRequest();
|
||||
initial.open('GET', '/upm/start?praId=p1'); initial.send();
|
||||
const raw = single(); raw.choiceItems.forEach(o => delete o.answer);
|
||||
initial.respond({ listQues: [raw, { id: 'q2', quesType: 2, content: '另一题' }] });
|
||||
const answer = new win.XMLHttpRequest();
|
||||
answer.open('PUT', '/upm/p1/users/u1/answer');
|
||||
answer.send(JSON.stringify({ answers: [{ quesId: 'q1', answer: ['a1'] }] }));
|
||||
answer.respond([{ answers: ['a2'], corrected: false }]);
|
||||
const result = received.at(-1);
|
||||
assert.equal(result.questionId, 'q1');
|
||||
assert.equal(result.type, 'single');
|
||||
assert.deepEqual(result.answerTexts, ['提高温度']);
|
||||
adapter.stop();
|
||||
assert.equal(win.XMLHttpRequest.prototype.open, originalOpen);
|
||||
});
|
||||
|
||||
test('unrelated API paths and unrelated hosts do not get captured', async () => {
|
||||
const { win, adapter, received } = fakePage(async () => new Response(JSON.stringify({ listQues: [single()] }), { headers: { 'content-type': 'application/json' } }));
|
||||
adapter.start();
|
||||
await win.fetch('https://unknown.example/upm/start?praId=p1');
|
||||
await win.fetch('/ordinary/notifications');
|
||||
await flush();
|
||||
assert.equal(received.length, 0);
|
||||
adapter.stop();
|
||||
});
|
||||
|
||||
function bankPage(fetchImpl) {
|
||||
const page = fakePage(fetchImpl);
|
||||
const events = new EventTarget();
|
||||
page.win.addEventListener = events.addEventListener.bind(events);
|
||||
page.win.removeEventListener = events.removeEventListener.bind(events);
|
||||
page.win.dispatchEvent = events.dispatchEvent.bind(events);
|
||||
const state = { sidebar: '判断题库1', player: '判断题库1', exam: '判断题库1', taskId: 'task-a', containerTask: 'task-a', stem: '升温时应选择?', form: true };
|
||||
page.win.location.href = 'https://www.shhuayi-elearning.cn/o2o/#/playinfo?taskId=task-a';
|
||||
page.doc.title = '不会随切换更新的旧标题';
|
||||
const item = { getAttribute: key => key === 'data-task-id' ? state.taskId : null };
|
||||
const sidebar = { get textContent() { return state.sidebar; }, closest: () => item };
|
||||
const player = { get textContent() { return state.player; } };
|
||||
const exam = { get textContent() { return state.exam; } };
|
||||
const container = { getAttribute: key => key === 'task-id' ? state.containerTask : null };
|
||||
const main = { querySelectorAll: () => [] };
|
||||
const stem = { get textContent() { return state.stem; }, matches: selector => selector === '[data-rich-text]' };
|
||||
const options = ['降低温度', '提高温度'].map((value, index) => ({
|
||||
querySelector: selector => selector === 'input' ? { value: `a${index + 1}` } : selector === '.yxtf-radio__label, .yxtf-checkbox__label' ? { textContent: `${String.fromCharCode(65 + index)}. ${value}`, querySelector: () => null } : null,
|
||||
textContent: value
|
||||
}));
|
||||
const form = {
|
||||
get textContent() { return state.stem + options.map(option => option.textContent).join(''); },
|
||||
closest: selector => selector === ROOT_SELECTOR_FOR_TEST ? main : null,
|
||||
getAttribute: () => null, style: {},
|
||||
querySelector: selector => selector === '.yxtulcdsdk-form__content' ? { children: [stem] } : null,
|
||||
querySelectorAll: () => options
|
||||
};
|
||||
page.doc.querySelector = selector => {
|
||||
if (selector === '.task-content-active .group-title, .task-content-active .task-content-color-active') return state.sidebar ? sidebar : null;
|
||||
if (selector === 'header.o2oPlayFrame-right .title .yxtf-tooltip') return state.player ? player : null;
|
||||
if (selector === '.yxtulcdsdk-exam-header .yxtf-tooltip') return state.exam ? exam : null;
|
||||
if (selector === '.yxtulcdsdk-exam-container[task-id]') return container;
|
||||
return null;
|
||||
};
|
||||
page.doc.querySelectorAll = selector => selector === '.yxtulcdsdk-pc-marking-main .yxtulcdsdk-form' && state.form ? [form] : [];
|
||||
const changes = [];
|
||||
page.adapter = create({ window: page.win, document: page.doc, onQuestions: values => page.received.push(...values), onBankChange: bank => changes.push(bank) });
|
||||
return { ...page, state, changes, navigate(name, taskId) {
|
||||
state.sidebar = name; state.taskId = taskId;
|
||||
page.win.location.href = `https://www.shhuayi-elearning.cn/o2o/#/playinfo?taskId=${taskId}`;
|
||||
page.win.dispatchEvent(new Event('hashchange'));
|
||||
} };
|
||||
}
|
||||
const ROOT_SELECTOR_FOR_TEST = '.yxtulcdsdk-pc-marking-main';
|
||||
|
||||
test('current bank follows actual active sidebar instead of stale document title, even without questions', () => {
|
||||
const { adapter, state, navigate, doc, changes } = bankPage();
|
||||
adapter.start();
|
||||
assert.equal(adapter.getCurrentBank().bankName, '判断题库1');
|
||||
navigate('单选题库1', 'task-b');
|
||||
assert.equal(doc.title, '不会随切换更新的旧标题');
|
||||
assert.equal(adapter.getCurrentBank().bankName, '单选题库1');
|
||||
assert.equal(adapter.getCurrentBank().loading, true);
|
||||
assert.equal(adapter.getCurrentQuestion(), null, '旧判断题DOM仍在时不能采到新的单选库');
|
||||
state.player = state.exam = '单选题库1'; state.containerTask = 'task-b';
|
||||
assert.equal(adapter.getCurrentQuestion(), null, '只更新标题和task-id也不能把旧正文移入新库');
|
||||
state.stem = '新的单选题目?';
|
||||
const question = adapter.getCurrentQuestion();
|
||||
assert.equal(question.bankName, '单选题库1');
|
||||
assert.equal(question.stem, '新的单选题目?');
|
||||
assert.equal(adapter.getCurrentBank().loading, false);
|
||||
state.form = false;
|
||||
assert.equal(adapter.getCurrentBank().bankName, '单选题库1', '未出现题目时仍能识别库名用于导出');
|
||||
assert.ok(changes.some(bank => bank.bankName === '单选题库1'));
|
||||
adapter.stop();
|
||||
});
|
||||
|
||||
test('late old-bank response retains source name and cannot change the new active bank', async () => {
|
||||
const pending = new Map();
|
||||
const page = bankPage(url => new Promise(resolve => pending.set(url, resolve)));
|
||||
page.adapter.start();
|
||||
const first = page.win.fetch('/upm/start?praId=bank-a');
|
||||
page.navigate('单选题库1', 'task-b');
|
||||
page.state.player = page.state.exam = '单选题库1'; page.state.containerTask = 'task-b'; page.state.stem = '新的单选题目?';
|
||||
const second = page.win.fetch('/upm/start?praId=bank-b');
|
||||
const response = raw => new Response(JSON.stringify({ listQues: [raw] }), { headers: { 'content-type': 'application/json' } });
|
||||
pending.get('/upm/start?praId=bank-b')(response({ ...single(), id: 'q-b', content: '新的单选题目?' }));
|
||||
await second; await flush(); await flush();
|
||||
assert.equal(page.adapter.getCurrentBank().bankId, 'bank-b');
|
||||
pending.get('/upm/start?praId=bank-a')(response(single()));
|
||||
await first; await flush(); await flush();
|
||||
assert.equal(page.adapter.getCurrentBank().bankName, '单选题库1');
|
||||
assert.equal(page.adapter.getCurrentBank().bankId, 'bank-b');
|
||||
assert.equal(page.received.find(record => record.bankId === 'bank-a').bankName, '判断题库1');
|
||||
page.adapter.stop();
|
||||
});
|
||||
|
||||
test('returning A to B to A does not adopt late B results or stale document title', async () => {
|
||||
let releaseB;
|
||||
const page = bankPage(url => String(url).includes('bank-b') ? new Promise(resolve => { releaseB = resolve; }) : Promise.resolve(new Response(JSON.stringify({ listQues: [single()] }), { headers: { 'content-type': 'application/json' } })));
|
||||
page.adapter.start();
|
||||
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
|
||||
page.navigate('单选题库1', 'task-b');
|
||||
page.state.player = page.state.exam = '单选题库1'; page.state.containerTask = 'task-b'; page.state.stem = '新的单选题目?';
|
||||
const pending = page.win.fetch('/upm/start?praId=bank-b');
|
||||
page.navigate('判断题库1', 'task-a');
|
||||
page.state.player = page.state.exam = '判断题库1'; page.state.containerTask = 'task-a'; page.state.stem = '升温时应选择?';
|
||||
releaseB(new Response(JSON.stringify({ listQues: [{ ...single(), content: '新的单选题目?' }] }), { headers: { 'content-type': 'application/json' } }));
|
||||
await pending; await flush(); await flush();
|
||||
assert.equal(page.adapter.getCurrentBank().bankName, '判断题库1');
|
||||
assert.equal(page.adapter.getCurrentBank().bankId, 'bank-a');
|
||||
assert.equal(page.received.find(record => record.bankId === 'bank-b').bankName, '单选题库1');
|
||||
page.adapter.stop();
|
||||
});
|
||||
|
||||
test('different named response in same task never changes selected bank name or id', async () => {
|
||||
let payload = { praName: '判断题库1', listQues: [single()] };
|
||||
const page = bankPage(async () => new Response(JSON.stringify(payload), { headers: { 'content-type': 'application/json' } }));
|
||||
page.adapter.start();
|
||||
await page.win.fetch('/upm/start?praId=bank-a'); await flush(); await flush();
|
||||
assert.equal(page.adapter.getCurrentBank().bankId, 'bank-a');
|
||||
payload = { praName: '接口验收库', listQues: [single()] };
|
||||
await page.win.fetch('/upm/start?praId=unrelated-bank'); await flush(); await flush();
|
||||
assert.equal(page.adapter.getCurrentBank().bankName, '判断题库1');
|
||||
assert.equal(page.adapter.getCurrentBank().bankId, 'bank-a');
|
||||
page.adapter.stop();
|
||||
});
|
||||
|
||||
test('bank identity keeps updating while capture and copy are both paused', () => {
|
||||
const page = bankPage();
|
||||
page.adapter.start(); page.adapter.stop(); page.adapter.setCopyEnabled(false);
|
||||
const before = page.received.length;
|
||||
page.navigate('多选题库', 'task-c');
|
||||
assert.equal(page.changes.at(-1).bankName, '多选题库');
|
||||
assert.equal(page.received.length, before);
|
||||
});
|
||||
|
||||
test('initial HTML parsing and atomic full SPA render do not block a new form', () => {
|
||||
const page = bankPage();
|
||||
page.state.sidebar = page.state.player = page.state.exam = ''; page.state.form = false;
|
||||
page.adapter.start();
|
||||
page.state.sidebar = page.state.player = page.state.exam = '判断题库1'; page.state.form = true;
|
||||
assert.ok(page.adapter.getCurrentQuestion(), '首次解析出正文不能当成上一题残留');
|
||||
page.state.sidebar = page.state.player = page.state.exam = '单选题库1';
|
||||
page.state.taskId = page.state.containerTask = 'task-b'; page.state.stem = '同步渲染的新题目?';
|
||||
page.win.location.href = 'https://www.shhuayi-elearning.cn/o2o/#/playinfo?taskId=task-b';
|
||||
const question = page.adapter.getCurrentQuestion();
|
||||
assert.equal(question.bankName, '单选题库1');
|
||||
assert.equal(question.stem, '同步渲染的新题目?');
|
||||
page.adapter.stop();
|
||||
});
|
||||
Reference in New Issue
Block a user