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();
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const core = require('../src/core.js');
|
||||
|
||||
const fixture = (overrides = {}) => ({
|
||||
bankId: 'bank-1', bankName: '单选题库', questionId: '2114891149151932417', type: 'single', stem: '温度过低,应如何操作?',
|
||||
options: [{ id: 'x', text: '降低温度', label: 'A' }, { id: 'y', text: '提高温度', label: 'B' }, { id: 'z', text: '保持不变', label: 'C' }],
|
||||
correctOptionIds: ['y'], answerStatus: 'confirmed', explanation: '保持合适温度。', source: { kind: 'answer-response', url: 'https://example.test/practice', observedAt: '2026-10-29T00:00:00Z' }, ...overrides,
|
||||
});
|
||||
const reorder = (record) => ({ ...record, options: [record.options[2], record.options[0], record.options[1]].map((option, index) => ({ ...option, label: 'ABC'[index] })) });
|
||||
|
||||
test('single answers map to current labels after options reorder', () => {
|
||||
const record = core.normalizeRecord(fixture());
|
||||
assert.deepEqual(record.answerTexts, ['提高温度']);
|
||||
assert.deepEqual(core.mapAnswers(record, reorder(record)), { status: 'matched', labels: ['C'], answerTexts: ['提高温度'] });
|
||||
});
|
||||
|
||||
test('multiple answers map all correct options after reorder', () => {
|
||||
const record = core.normalizeRecord(fixture({ type: 'multiple', correctOptionIds: ['x', 'z'] }));
|
||||
assert.deepEqual(core.mapAnswers(record, reorder(record)).labels, ['B', 'A']);
|
||||
});
|
||||
|
||||
test('different option IDs can map by unique answer content', () => {
|
||||
const record = core.normalizeRecord(fixture());
|
||||
const current = reorder(record);
|
||||
current.options = current.options.map((option, index) => ({ ...option, id: `new-${index}` }));
|
||||
assert.equal(core.mapAnswers(record, current).status, 'matched');
|
||||
});
|
||||
|
||||
test('same ID with different content is not treated as the answer', () => {
|
||||
const record = core.normalizeRecord(fixture());
|
||||
const current = reorder(record);
|
||||
current.options[0].id = 'y'; current.options[2].id = 'new-y';
|
||||
assert.equal(core.mapAnswers(record, current).status, 'unmapped');
|
||||
});
|
||||
|
||||
test('duplicate option content is ambiguous even when ID exists', () => {
|
||||
const record = core.normalizeRecord(fixture({ options: [{ id: 'x', text: '提高温度', label: 'A' }, { id: 'y', text: '提高温度', label: 'B' }] }));
|
||||
assert.equal(core.mapAnswers(record, record).status, 'ambiguous');
|
||||
});
|
||||
|
||||
test('same stem but different option sets never auto-map', () => {
|
||||
const record = core.normalizeRecord(fixture());
|
||||
const current = reorder(record);
|
||||
current.options[0].text = '无法判断';
|
||||
assert.equal(core.mapAnswers(record, current).status, 'unmapped');
|
||||
});
|
||||
|
||||
test('missing answer is not inferred from student selection or truthiness', () => {
|
||||
const record = core.normalizeRecord(fixture({ correctOptionIds: [], answerStatus: undefined, selectedOptionIds: ['y'], userAnswer: 'B' }));
|
||||
assert.equal(record.answerStatus, 'missing');
|
||||
assert.deepEqual(record.answerTexts, []);
|
||||
const falseRecord = core.normalizeRecord(fixture({ type: 'judge', options: [{ id: 'a', text: '正确' }, { id: 'b', text: '错误' }], correctOptionIds: [], answerTexts: ['false'] }));
|
||||
assert.deepEqual(falseRecord.answerTexts, ['错误']);
|
||||
const zeroRecord = core.normalizeRecord(fixture({ type: 'judge', options: [], correctOptionIds: [], answerTexts: ['0'] }));
|
||||
assert.deepEqual(zeroRecord.answerTexts, ['0']);
|
||||
});
|
||||
|
||||
test('explicit missing status suppresses accidentally supplied answers', () => {
|
||||
const record = core.normalizeRecord(fixture({ answerStatus: 'missing', answerTexts: ['提高温度'] }));
|
||||
assert.equal(record.answerStatus, 'missing');
|
||||
assert.deepEqual(record.correctOptionIds, []);
|
||||
assert.deepEqual(record.answerTexts, []);
|
||||
});
|
||||
|
||||
test('inconsistent correct ID and answer content becomes conflict', () => {
|
||||
const record = core.normalizeRecord(fixture({ answerTexts: ['降低温度'] }));
|
||||
assert.equal(record.answerStatus, 'conflict');
|
||||
assert.equal(core.mapAnswers(record, record).status, 'ambiguous');
|
||||
});
|
||||
|
||||
test('missing answer is filled and later DOM-only observation preserves confirmed answer', () => {
|
||||
const missing = fixture({ answerStatus: 'missing', correctOptionIds: [] });
|
||||
const filled = core.mergeRecord(missing, fixture());
|
||||
assert.equal(filled.answerStatus, 'confirmed');
|
||||
const later = { ...reorder(missing), source: { kind: 'dom', observedAt: '2026-10-29T01:00:00Z' } };
|
||||
const merged = core.mergeRecord(filled, later);
|
||||
assert.equal(merged.answerStatus, 'confirmed');
|
||||
assert.deepEqual(merged.answerTexts, ['提高温度']);
|
||||
assert.equal(merged.answerSource.kind, 'answer-response');
|
||||
});
|
||||
|
||||
test('conflicting answers are retained without silently overwriting', () => {
|
||||
const merged = core.mergeRecord(fixture(), fixture({ correctOptionIds: ['x'] }));
|
||||
assert.equal(merged.answerStatus, 'conflict');
|
||||
assert.equal(merged.conflicts.length, 2);
|
||||
assert.equal(core.mapAnswers(merged, fixture()).status, 'ambiguous');
|
||||
assert.equal(core.mergeRecord(merged, fixture()).answerStatus, 'conflict');
|
||||
});
|
||||
|
||||
test('changed content under an existing ID marks stale answer as conflict', () => {
|
||||
const changed = fixture({ answerStatus: 'missing', correctOptionIds: [], stem: '温度过高,应如何操作?' });
|
||||
const merged = core.mergeRecord(fixture(), changed);
|
||||
assert.equal(merged.answerStatus, 'conflict');
|
||||
assert.equal(core.mapAnswers(merged, changed).status, 'ambiguous');
|
||||
});
|
||||
|
||||
test('fingerprints are order independent, bank aware and keep full option content', () => {
|
||||
const first = fixture({ questionId: '' });
|
||||
const second = reorder(first);
|
||||
assert.equal(core.upsert([], [first, second]).length, 1);
|
||||
assert.equal(core.upsert([], [first, { ...second, bankId: 'other' }]).length, 2);
|
||||
assert.equal(core.upsert([], [first, { ...second, stem: '不同题目' }]).length, 2);
|
||||
});
|
||||
|
||||
test('text comparison preserves negation, inequality and superscripts', () => {
|
||||
const records = ['P水 > P气', 'P水 < P气', 'P水 ≥ P气', '正确的操作', '不正确的操作', '面积为2m²', '面积为2m2'].map((stem, index) => core.normalizeRecord(fixture({ questionId: String(index), stem })));
|
||||
assert.equal(core.search(records, 'P水 > P气')[0].record.stem, 'P水 > P气');
|
||||
assert.equal(core.search(records, '不正确的操作')[0].record.stem, '不正确的操作');
|
||||
assert.equal(core.search(records, '面积为2m²')[0].record.stem, '面积为2m²');
|
||||
assert.equal(core.search(records, '温').length, 0);
|
||||
assert.equal(core.search(records, '62. P水 > P气')[0].match, 'exact');
|
||||
});
|
||||
|
||||
test('CSV roundtrip preserves Chinese, quotes, comma, newline, IDs and conflicts', () => {
|
||||
const record = core.normalizeRecord(fixture({ stem: '题干含有,逗号和"双引号"\n第二行', explanation: '解析\n仍有,逗号' }));
|
||||
const csv = core.toCSV([record]);
|
||||
assert.ok(csv.startsWith('\uFEFF'));
|
||||
assert.match(csv, /'2114891149151932417/);
|
||||
assert.deepEqual(core.fromCSV(csv), [record]);
|
||||
const conflict = core.mergeRecord(fixture(), fixture({ correctOptionIds: ['z'] }));
|
||||
assert.deepEqual(core.fromCSV(core.toCSV([conflict])), [conflict]);
|
||||
});
|
||||
|
||||
test('CSV spreadsheet formula protection is reversible without changing canonical text', () => {
|
||||
const record = core.normalizeRecord(fixture({ stem: '=SUM(1,2)', explanation: ' @危险公式', bankName: '+题库' }));
|
||||
const csv = core.toCSV([record]);
|
||||
assert.match(csv, /"'=SUM\(1,2\)"/);
|
||||
assert.match(csv, /"'@危险公式"/);
|
||||
assert.deepEqual(core.fromCSV(csv), [record]);
|
||||
});
|
||||
|
||||
test('CSV rejects malformed quotes, inconsistent columns, invalid JSON/types/statuses', () => {
|
||||
assert.throws(() => core.fromCSV('题干,题型\n"未闭合,单选题'), /未闭合/);
|
||||
assert.throws(() => core.fromCSV('题干,题型\n题目,单选题,多出一列'), /列数/);
|
||||
assert.throws(() => core.fromCSV('完整记录JSON\n"{bad}"'), /JSON/);
|
||||
assert.throws(() => core.fromCSV('题干,题型\n题目,其他题型'), /题型/);
|
||||
assert.throws(() => core.fromCSV('题干,题型,答案状态\n题目,单选题,猜测'), /状态/);
|
||||
assert.throws(() => core.fromCSV('题干,题干\n一题,二题'), /重复/);
|
||||
assert.throws(() => core.fromCSV('题干,题型\n"题目"非法,单选题'), /引号后/);
|
||||
});
|
||||
|
||||
test('standard human CSV imports explicit standard answers, never user answers', () => {
|
||||
const rows = core.fromCSV('来源题库,题干,题型,选项A,选项B,正确答案内容,我的答案\n练习,应提高温度吗,判断题,正确,错误,错误,正确\n练习,如何操作,单选题,提高温度,降低温度,,提高温度');
|
||||
assert.deepEqual(rows[0].answerTexts, ['错误']);
|
||||
assert.equal(rows[0].answerStatus, 'confirmed');
|
||||
assert.equal(rows[1].answerStatus, 'missing');
|
||||
});
|
||||
|
||||
test('unsafe numeric IDs are refused rather than rounding identifiers', () => {
|
||||
assert.throws(() => core.normalizeRecord(fixture({ questionId: 2114891149151932417 })), /不安全/);
|
||||
assert.equal(core.normalizeRecord(fixture()).questionId, '2114891149151932417');
|
||||
});
|
||||
|
||||
test('DOM-first anonymous record upgrades to unique server identity', () => {
|
||||
const dom = fixture({ bankId: '', questionId: '', answerStatus: 'missing', correctOptionIds: [] });
|
||||
const result = core.upsert([dom], [fixture()]);
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].questionId, fixture().questionId);
|
||||
assert.equal(result[0].bankId, 'bank-1');
|
||||
assert.equal(result[0].answerStatus, 'confirmed');
|
||||
});
|
||||
|
||||
test('server-first record absorbs later anonymous DOM and persistence merges', () => {
|
||||
const dom = reorder(fixture({ bankId: '', questionId: '', answerStatus: 'missing', correctOptionIds: [] }));
|
||||
const result = core.upsert([fixture()], [dom]);
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].questionId, fixture().questionId);
|
||||
assert.deepEqual(result[0].answerTexts, ['提高温度']);
|
||||
const persisted = core.upsert([dom], result);
|
||||
assert.equal(persisted.length, 1);
|
||||
assert.equal(persisted[0].answerStatus, 'confirmed');
|
||||
});
|
||||
|
||||
test('upgrading confirmed anonymous data keeps answer IDs and option IDs atomic', () => {
|
||||
const old = fixture({ bankId: '', questionId: '' });
|
||||
const network = reorder(fixture({ answerStatus: 'missing', correctOptionIds: [] }));
|
||||
network.options = network.options.map((option, index) => ({ ...option, id: `server-${index}` }));
|
||||
const result = core.upsert([old], [network]);
|
||||
assert.equal(result.length, 1);
|
||||
assert.deepEqual(result[0].correctOptionIds, ['y']);
|
||||
assert.equal(result[0].options.find((option) => option.id === 'y').text, '提高温度');
|
||||
assert.equal(core.mapAnswers(result[0], network).status, 'matched');
|
||||
});
|
||||
|
||||
test('different known bank IDs and same names never merge anonymous records', () => {
|
||||
const anonymous = fixture({ questionId: '', bankId: 'other-bank' });
|
||||
assert.equal(core.upsert([anonymous], [fixture()]).length, 2);
|
||||
});
|
||||
|
||||
test('different known question IDs are never merged by content', () => {
|
||||
assert.equal(core.upsert([fixture()], [fixture({ questionId: 'other-question' })]).length, 2);
|
||||
});
|
||||
|
||||
test('anonymous data matching multiple IDs or same-name banks remains separate', () => {
|
||||
const anonymous = fixture({ bankId: '', questionId: '', answerStatus: 'missing', correctOptionIds: [] });
|
||||
const anotherId = fixture({ questionId: 'other-id' });
|
||||
assert.equal(core.upsert([anonymous], [fixture(), anotherId]).length, 3);
|
||||
const otherBank = fixture({ bankId: 'other-bank' });
|
||||
assert.equal(core.upsert([anonymous], [fixture(), otherBank]).length, 3);
|
||||
});
|
||||
|
||||
test('anonymous upgrade requires complete options and exact type/content/bank', () => {
|
||||
const network = fixture();
|
||||
for (const change of [
|
||||
{ type: 'unknown' }, { bankName: '其他题库' }, { options: [] }, { stem: '温度过高,应如何操作?' },
|
||||
{ options: [{ id: '', text: '不同选项' }] },
|
||||
]) {
|
||||
const anonymous = fixture({ bankId: '', questionId: '', answerStatus: 'missing', correctOptionIds: [], ...change });
|
||||
assert.equal(core.upsert([anonymous], [network]).length, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('JSON and CSV JSON cannot label an answer outside saved options as confirmed', () => {
|
||||
const invalid = fixture({ correctOptionIds: [], answerTexts: ['不存在的选项'] });
|
||||
const record = core.normalizeRecord(invalid);
|
||||
assert.equal(record.answerStatus, 'conflict');
|
||||
const csv = '完整记录JSON\n"' + JSON.stringify(invalid).replace(/"/g, '""') + '"';
|
||||
assert.equal(core.fromCSV(csv)[0].answerStatus, 'conflict');
|
||||
});
|
||||
|
||||
test('text search supports stem, options and confirmed answer fields', () => {
|
||||
const record = core.normalizeRecord(fixture({ stem: '操作措施的选择' }));
|
||||
const options = core.search([record], '降低温度', { field: 'options' });
|
||||
assert.equal(options[0].matchedField, 'options');
|
||||
assert.equal(options[0].match, 'partial');
|
||||
assert.equal(core.search([record], '降低温度', { field: 'stem' }).length, 0);
|
||||
const answer = core.search([record], '提高温度', { field: 'answer' });
|
||||
assert.equal(answer[0].matchedField, 'answer');
|
||||
assert.equal(core.search([record], '降低温度', { field: 'answer' }).length, 0);
|
||||
assert.equal(core.search([record], '操作措施')[0].matchedField, 'stem');
|
||||
assert.equal(core.search([record], '降低温度')[0].matchedField, 'options');
|
||||
});
|
||||
|
||||
test('text search ranks stem exact and partial before options, answers and similar stems', () => {
|
||||
const make = (id, stem, options = [{ id: 'x', text: '无关操作' }], answerTexts = []) => core.normalizeRecord(fixture({
|
||||
questionId: id, type: 'unknown', stem, options, correctOptionIds: [], answerTexts,
|
||||
answerStatus: answerTexts.length ? 'confirmed' : 'missing',
|
||||
}));
|
||||
const exact = make('exact', '提高温度');
|
||||
const partial = make('partial', '如何提高温度以完成干燥');
|
||||
const option = make('option', '措施选择', [{ id: 'x', text: '提高温度' }]);
|
||||
const answer = make('answer', '操作说明', [], ['提高温度']);
|
||||
const similar = make('similar', '提高温度吗');
|
||||
// Query has no punctuation so similar is made genuinely non-contiguous.
|
||||
similar.stem = '提高操作温度';
|
||||
const found = core.search([similar, answer, option, partial, exact], '提高温度');
|
||||
assert.deepEqual(found.map((item) => item.record.questionId), ['exact', 'partial', 'option', 'answer', 'similar']);
|
||||
assert.deepEqual(found.map((item) => item.match), ['exact', 'partial', 'partial', 'partial', 'similar']);
|
||||
});
|
||||
|
||||
test('space-separated keywords require every term and can cross fields only in all scope', () => {
|
||||
const records = [
|
||||
core.normalizeRecord(fixture({ questionId: 'one', stem: '温度过低如何处理' })),
|
||||
core.normalizeRecord(fixture({ questionId: 'two', stem: '温度过低是否保持不变', correctOptionIds: ['z'] })),
|
||||
];
|
||||
const found = core.search(records, '温度过低 提高温度');
|
||||
assert.deepEqual(found.map((item) => item.record.questionId), ['one', 'two']);
|
||||
assert.equal(core.search(records, '温度过低 提高温度', { field: 'stem' }).length, 0);
|
||||
assert.equal(core.search(records, '温度过低 不存在关键词').length, 0);
|
||||
assert.deepEqual(core.search(records, '温度过低 处理', { field: 'stem' }).map((item) => item.record.questionId), ['one']);
|
||||
assert.equal(core.search(records, '提高温度 保持不变', { field: 'options' }).length, 2);
|
||||
assert.equal(core.search(records, '提高温度 保持不变', { field: 'answer' }).length, 0);
|
||||
});
|
||||
|
||||
test('separate option contents never create a phrase that is absent in all segments', () => {
|
||||
const record = core.normalizeRecord(fixture({
|
||||
stem: '措施选择', options: [{ id: 'x', text: '甲' }, { id: 'y', text: '乙' }], correctOptionIds: ['x'],
|
||||
}));
|
||||
assert.equal(core.search([record], '甲乙', { field: 'options' }).length, 0);
|
||||
assert.equal(core.search([record], '甲 乙', { field: 'options' }).length, 1);
|
||||
});
|
||||
|
||||
test('missing and conflicting records are never searched as confirmed answer content', () => {
|
||||
const missing = core.normalizeRecord(fixture({ questionId: 'missing', answerStatus: 'missing', answerTexts: ['提高温度'] }));
|
||||
const conflict = core.mergeRecord(fixture(), fixture({ correctOptionIds: ['x'] }));
|
||||
const records = [missing, conflict];
|
||||
assert.equal(core.search(records, '提高温度', { field: 'answer' }).length, 0);
|
||||
assert.ok(core.search(records, '提高温度', { field: 'options' }).every((item) => item.matchedField === 'options'));
|
||||
});
|
||||
|
||||
test('keyword search keeps negation, inequality, case-sensitive units and superscripts', () => {
|
||||
const records = ['P水 > P气', 'P水 < P气', '面积2m²', '面积2m2', '不正确操作', '正确操作', '压力2MPa', '压力2mPa'].map((stem, index) => core.normalizeRecord(fixture({ questionId: String(index), stem })));
|
||||
assert.deepEqual(core.search(records, 'P水 > P气', { field: 'stem' }).map((item) => item.record.stem), ['P水 > P气']);
|
||||
assert.equal(core.search(records, '不正确操作', { field: 'stem' })[0].record.stem, '不正确操作');
|
||||
assert.equal(core.search(records, '面积2m²', { field: 'stem' })[0].record.stem, '面积2m²');
|
||||
assert.equal(core.search(records, '压力2MPa', { field: 'stem' })[0].record.stem, '压力2MPa');
|
||||
});
|
||||
|
||||
test('search limit fifty and offset give stable pages independent of input order', () => {
|
||||
const records = Array.from({ length: 60 }, (_, index) => core.normalizeRecord(fixture({ questionId: `id-${String(index).padStart(2, '0')}`, stem: '完全一致的检索题干' })));
|
||||
const first = core.search(records, '完全一致的检索题干', { limit: 50 });
|
||||
const second = core.search([...records].reverse(), '完全一致的检索题干', { limit: 50, offset: 50 });
|
||||
assert.equal(first.length, 50);
|
||||
assert.equal(second.length, 10);
|
||||
assert.equal(first[0].record.questionId, 'id-00');
|
||||
assert.equal(second[0].record.questionId, 'id-50');
|
||||
assert.equal(new Set([...first, ...second].map((item) => item.record.questionId)).size, 60);
|
||||
assert.equal(core.search(records, '完全一致的检索题干', { limit: 0 }).length, 0);
|
||||
});
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
<!-- Minimal synthetic exercise fixture. No account, course, or task data. -->
|
||||
<div class="yxtulcdsdk-pc-marking-main text-no-select">
|
||||
<div class="standard-size-14"><span>判断题</span></div>
|
||||
<div class="yxtulcdsdk-form">
|
||||
<div class="yxtulcdsdk-form__sub">1.</div>
|
||||
<div class="yxtulcdsdk-form__content">
|
||||
<div data-rich-text="1">干燥过程传质推动力:物料表面水分压P水>热空气中的水分压P水</div>
|
||||
<div class="mt16">
|
||||
<div role="radiogroup" class="yxtulcdsdk-ques-radio-group yxtf-radio-group">
|
||||
<label role="radio" class="yxtf-radio yxtulcdsdk-ques-radio">
|
||||
<span class="yxtf-radio__input"><input type="radio" class="yxtf-radio__original" value="1"></span>
|
||||
<span class="yxtf-radio__label">正确</span>
|
||||
</label>
|
||||
<label role="radio" class="yxtf-radio yxtulcdsdk-ques-radio">
|
||||
<span class="yxtf-radio__input"><input type="radio" class="yxtf-radio__original" value="0"></span>
|
||||
<span class="yxtf-radio__label">错误</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="yxtulcdsdk-examing__bottom">
|
||||
<button type="button">上一题</button>
|
||||
<button type="button">下一题</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,629 @@
|
||||
'use strict';
|
||||
|
||||
// Browser plugin not available. Exercise a local fixture with cached Playwright;
|
||||
// never load the production site's scripts, APIs, or account session.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const http = require('node:http');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const RECORDS_KEY = 'huayi-qb.records.v1';
|
||||
const CONFIG_KEY = 'huayi-qb.config.v1';
|
||||
const QA_DIR = path.join(os.tmpdir(), 'huayi-qb-qa-v110');
|
||||
const BUNDLED = path.join(os.homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules');
|
||||
|
||||
function loadPlaywright() {
|
||||
if (process.env.QB_PLAYWRIGHT_MODULE) return require(process.env.QB_PLAYWRIGHT_MODULE);
|
||||
try { return require('playwright'); }
|
||||
catch { return require(path.join(BUNDLED, 'playwright')); }
|
||||
}
|
||||
|
||||
function browserOptions() {
|
||||
const executablePath = process.env.QB_CHROMIUM_EXECUTABLE || process.env.QB_CHROMIUM_PATH;
|
||||
if (executablePath) return { headless: true, executablePath };
|
||||
const cached = path.join(os.homedir(), 'AppData/Local/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-win64/chrome-headless-shell.exe');
|
||||
return fs.existsSync(cached) ? { headless: true, executablePath: cached } : { headless: true };
|
||||
}
|
||||
|
||||
function questionMarkup() {
|
||||
// Keep the selector structure needed by the adapter without depending on an
|
||||
// account-specific browser save or any production page resources.
|
||||
return fs.readFileSync(path.join(__dirname, 'fixtures/question.html'), 'utf8');
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>华谊集团化工总控工(高级)-判断题库1 · 本地验收</title>
|
||||
<style>body{margin:0;background:#eef2f5;color:#27364a;font:16px/1.7 system-ui,sans-serif}
|
||||
.fixture-header{padding:22px 30px;background:white;border-bottom:1px solid #d9e1e8}
|
||||
.fixture-content{margin:32px;max-width:760px;background:white;padding:28px;border-radius:14px}
|
||||
.fixture-label{font-size:12px;letter-spacing:1px;color:#687888}
|
||||
.yxtulcdsdk-pc-marking-main.text-no-select{-webkit-user-select:none;user-select:none}
|
||||
.yxtulcdsdk-form__sub{color:#8995a6;font-size:13px}.yxtulcdsdk-form__content>div[data-rich-text]{font-size:21px;margin:12px 0 25px}
|
||||
.yxtulcdsdk-ques-radio-group label{display:flex;align-items:center;gap:12px;border:1px solid #dce4eb;padding:12px 16px;margin:12px 0;border-radius:8px}
|
||||
.yxtf-radio__label{margin-left:8px}.yxtulcdsdk-examing__bottom{margin-top:28px}button{cursor:pointer}
|
||||
@media(max-width:500px){.fixture-content{margin:14px;padding:18px}.fixture-header{padding:15px}}</style>
|
||||
</head><body><header class="fixture-header"><div class="fixture-label">本地练习页 · 未连接在线服务</div>
|
||||
<div class="yxtulcdsdk-exam-header"><span class="yxtf-tooltip">华谊集团化工总控工(高级)-判断题库1</span></div></header>
|
||||
<main class="fixture-content">${questionMarkup()}</main></body></html>`;
|
||||
}
|
||||
|
||||
const BANK_FIXTURES = [
|
||||
{ id: 'judge-1', name: '华谊集团化工总控工(高级)-判断题库1', taskId: 'qa-task-judge-1', type: 'judge', stem: '判断库一的设备检查结论正确吗?', answer: '正确' },
|
||||
{ id: 'judge-2', name: '华谊集团化工总控工(高级)-判断题库2', taskId: 'qa-task-judge-2', type: 'judge', stem: '判断库二的维护措施正确吗?', answer: '错误' },
|
||||
{ id: 'single-1', name: '华谊集团化工总控工(高级)-单选题库1', taskId: 'qa-task-single-1', type: 'single', stem: '单选库一应该采用哪一种换热操作?', answer: '提高温度' },
|
||||
{ id: 'single-2', name: '华谊集团化工总控工(高级)-单选题库2', taskId: 'qa-task-single-2', type: 'single', stem: '单选库二应该采用哪一种压力操作?', answer: '降低压力' },
|
||||
{ id: 'multiple', name: '华谊集团化工总控工(高级)-多选题库', taskId: 'qa-task-multiple', type: 'multiple', stem: '多选库有哪些需要同时执行的巡检操作?', answer: '检查温度' }
|
||||
];
|
||||
|
||||
function multiBankFixture() {
|
||||
const first = BANK_FIXTURES[0];
|
||||
const sidebar = `<aside id="qa-sidebar" aria-label="培训大纲">${BANK_FIXTURES.map((bank, index) => `<button type="button" data-qa-bank="${bank.id}" data-task-id="${bank.taskId}" class="task-content${index ? '' : ' task-content-active'}"><span class="group-title${index ? '' : ' task-content-color-active'}">${bank.name}</span><small>练习 | 必修</small></button>`).join('')}</aside>`;
|
||||
return fixture().replace('干燥过程传质推动力:物料表面水分压P水>热空气中的水分压P水', first.stem)
|
||||
.replace('</head>', '<style>#qa-sidebar{position:fixed;left:20px;top:110px;bottom:20px;width:260px;display:flex;flex-direction:column;gap:12px;overflow:auto}#qa-sidebar button{border:1px solid #dce3eb;border-radius:8px;background:white;color:#506275;padding:14px;width:100%;text-align:left}#qa-sidebar small{display:block;color:#87929c;margin-top:7px}#qa-sidebar .task-content-active{background:#e4f5f0;border-color:#5fb9a3;color:#08735c}.fixture-content{margin-left:310px;max-width:650px}@media(max-width:500px){#qa-sidebar{display:none}.fixture-content{margin-left:14px}}</style></head>')
|
||||
.replace('<main class="fixture-content">', `${sidebar}<main class="fixture-content"><div class="yxtulcdsdk-exam-container" task-id="${first.taskId}">`).replace('</main>', '</div></main>');
|
||||
}
|
||||
|
||||
function bankResponse(bank) {
|
||||
const choiceItems = bank.type === 'judge' ? undefined : bank.type === 'multiple'
|
||||
? [{ id: 'check-t', content: '检查温度', answer: 1 }, { id: 'check-p', content: '检查压力', answer: 1 }, { id: 'skip', content: '跳过巡检', answer: 0 }]
|
||||
: [{ id: 'yes', content: bank.answer, answer: 1 }, { id: 'hold', content: '保持不变', answer: 0 }, { id: 'no', content: '停止运行', answer: 0 }];
|
||||
return { data: { praId: bank.id, praName: bank.name, listQues: [{ id: `question-${bank.id}`, quesType: { single: 0, multiple: 1, judge: 2 }[bank.type], content: bank.stem, ...(choiceItems ? { choiceItems } : { judgeAnswer: bank.answer === '正确' ? 1 : 0 }) }] } };
|
||||
}
|
||||
|
||||
async function setupBankSwitches(page) {
|
||||
await page.evaluate(banks => {
|
||||
window.__qaBanks = banks;
|
||||
const installQuestion = bank => {
|
||||
const container = document.querySelector('.yxtulcdsdk-exam-container');
|
||||
container.setAttribute('task-id', bank.taskId);
|
||||
document.querySelector('.yxtulcdsdk-exam-header .yxtf-tooltip').textContent = bank.name;
|
||||
const main = container.querySelector('.yxtulcdsdk-pc-marking-main');
|
||||
main.querySelector('.standard-size-14 span').textContent = { judge: '判断题', single: '单选题', multiple: '多选题' }[bank.type];
|
||||
const choices = bank.type === 'judge' ? [['1', '正确'], ['0', '错误']] : bank.type === 'multiple'
|
||||
? [['check-t', '检查温度'], ['check-p', '检查压力'], ['skip', '跳过巡检']]
|
||||
: [['hold', '保持不变'], ['no', '停止运行'], ['yes', bank.answer]];
|
||||
const isMultiple = bank.type === 'multiple';
|
||||
main.querySelector('.yxtulcdsdk-form__content').innerHTML = '<div data-rich-text="1"></div><div class="mt16"><div class="yxtulcdsdk-ques-radio-group">' + choices.map(([id, text], index) => `<label class="${isMultiple ? 'yxtulcdsdk-ques-checkbox yxtf-checkbox' : 'yxtulcdsdk-ques-radio yxtf-radio'}"><input type="${isMultiple ? 'checkbox' : 'radio'}" value="${id}"><span class="${isMultiple ? 'yxtf-checkbox__label' : 'yxtf-radio__label'}">${bank.type === 'judge' ? '' : String.fromCharCode(65 + index) + '. '}${text}</span></label>`).join('') + '</div></div>';
|
||||
main.querySelector('.yxtulcdsdk-form__content > [data-rich-text]').textContent = bank.stem;
|
||||
};
|
||||
installQuestion(banks[0]);
|
||||
document.querySelectorAll('[data-qa-bank]').forEach(button => button.addEventListener('click', () => {
|
||||
const bank = banks.find(item => item.id === button.dataset.qaBank);
|
||||
document.querySelectorAll('[data-qa-bank]').forEach(item => {
|
||||
item.classList.toggle('task-content-active', item === button);
|
||||
item.querySelector('.group-title').classList.toggle('task-content-color-active', item === button);
|
||||
});
|
||||
location.hash = `/playinfo?taskId=${bank.taskId}`;
|
||||
window.setTimeout(async () => { installQuestion(bank); await fetch(`/upm/start?praId=${bank.id}&item=bank-${bank.id}`).then(response => response.json()); }, 90);
|
||||
}));
|
||||
}, BANK_FIXTURES);
|
||||
await page.evaluate(bank => fetch(`/upm/start?praId=${bank.id}&item=bank-${bank.id}`).then(response => response.json()), BANK_FIXTURES[0]);
|
||||
}
|
||||
|
||||
async function downloadedCSV(page, filename) {
|
||||
const waiting = page.waitForEvent('download');
|
||||
await page.locator('[data-action="export-csv"]').click();
|
||||
const download = await waiting;
|
||||
const location = path.join(QA_DIR, filename);
|
||||
await download.saveAs(location);
|
||||
return { name: download.suggestedFilename(), text: fs.readFileSync(location, 'utf8') };
|
||||
}
|
||||
|
||||
function installGMShim() {
|
||||
window.unsafeWindow = window;
|
||||
window.__qbMenus = [];
|
||||
window.GM_getValue = (key, fallback) => {
|
||||
const value = localStorage.getItem(key);
|
||||
return value === null ? fallback : JSON.parse(value);
|
||||
};
|
||||
window.GM_setValue = (key, value) => localStorage.setItem(key, JSON.stringify(value));
|
||||
window.GM_registerMenuCommand = (label, callback) => { window.__qbMenus.push({ label, callback }); return window.__qbMenus.length; };
|
||||
window.GM_addValueChangeListener = () => 1;
|
||||
window.GM_removeValueChangeListener = () => {};
|
||||
}
|
||||
|
||||
async function waitFor(predicate, message, timeout = 5000) {
|
||||
const expires = Date.now() + timeout;
|
||||
do {
|
||||
const value = await predicate();
|
||||
if (value) return value;
|
||||
await new Promise(resolve => setTimeout(resolve, 40));
|
||||
} while (Date.now() < expires);
|
||||
assert.fail(message);
|
||||
}
|
||||
|
||||
async function storageRecords(page) {
|
||||
return page.evaluate(key => window.GM_getValue(key, []), RECORDS_KEY);
|
||||
}
|
||||
|
||||
async function activate(page, tab) {
|
||||
const exact = page.locator(`#huayi-qb-root [data-tab="${tab}"]`);
|
||||
if (await exact.count()) { await exact.click(); return; }
|
||||
const names = { search: /检索|查询/, library: /题库|导出/, settings: /设置/, collect: /采集/ };
|
||||
const candidates = page.getByRole('tab', { name: names[tab] });
|
||||
if (await candidates.count()) await candidates.first().click();
|
||||
}
|
||||
|
||||
async function importFile(page, name, text) {
|
||||
await activate(page, 'library');
|
||||
await page.locator('#qb-import-file').setInputFiles({ name, mimeType: name.endsWith('.json') ? 'application/json' : 'text/csv', buffer: Buffer.from(text) });
|
||||
}
|
||||
|
||||
async function selectQuestion(page) {
|
||||
await page.evaluate(() => {
|
||||
const content = document.querySelector('.yxtulcdsdk-form__content > [data-rich-text]');
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(content);
|
||||
window.getSelection().removeAllRanges();
|
||||
window.getSelection().addRange(range);
|
||||
content.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||
document.dispatchEvent(new Event('selectionchange'));
|
||||
});
|
||||
}
|
||||
|
||||
async function setSingleQuestion(page) {
|
||||
await page.evaluate(() => {
|
||||
const container = document.querySelector('.yxtulcdsdk-pc-marking-main');
|
||||
container.querySelector('.standard-size-14 span').textContent = '单选题';
|
||||
container.querySelector('.yxtulcdsdk-form__sub').textContent = '7.';
|
||||
container.querySelector('.yxtulcdsdk-form__content').innerHTML = '<div data-rich-text="1">以下哪项操作可以提高温度?</div><div class="mt16"><div role="radiogroup" class="yxtulcdsdk-ques-radio-group yxtf-radio-group">' +
|
||||
[['z', '保持不变'], ['x', '降低温度'], ['y', '提高温度']].map(([id, text], i) => '<label class="yxtf-radio yxtulcdsdk-ques-radio"><span class="yxtf-radio__input"><input type="radio" class="yxtf-radio__original" value="' + id + '"></span><span class="yxtf-radio__label">' + String.fromCharCode(65 + i) + '. ' + text + '</span></label>').join('') + '</div></div>';
|
||||
});
|
||||
}
|
||||
|
||||
test('真实浏览器:采集、答案映射、导入导出与面板隐藏恢复', { timeout: 150000 }, async t => {
|
||||
const { chromium } = loadPlaywright();
|
||||
const builtPath = path.join(ROOT, 'dist/huayi-question-bank.user.js');
|
||||
assert.ok(fs.existsSync(builtPath), '先运行 node build.cjs 生成安装版脚本');
|
||||
const script = fs.readFileSync(builtPath, 'utf8');
|
||||
fs.mkdirSync(QA_DIR, { recursive: true });
|
||||
const markup = fixture();
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/favicon.ico') { res.writeHead(204); res.end(); return; }
|
||||
if (req.url.startsWith('/upm/start') || req.url.startsWith('/unrelated/report')) {
|
||||
const item = new URL(req.url, 'http://127.0.0.1').searchParams.get('item') || '1';
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
if (item.startsWith('bank-')) {
|
||||
const bank = BANK_FIXTURES.find(candidate => item === `bank-${candidate.id}`);
|
||||
const delay = Math.min(1000, Number(new URL(req.url, 'http://127.0.0.1').searchParams.get('delay')) || 0);
|
||||
const finish = () => res.end(JSON.stringify(bank ? bankResponse(bank) : { data: {} }));
|
||||
if (delay) setTimeout(finish, delay); else finish();
|
||||
return;
|
||||
}
|
||||
if (item === 'scope-current') {
|
||||
res.end(JSON.stringify({ data: { praId: 'scope-bank', praName: '范围验收题库', listQues: [{
|
||||
id: 'scope-current', quesType: 0, content: '当前题库范围验收题',
|
||||
choiceItems: [{ id: 'x', content: '降低温度' }, { id: 'y', content: '提高温度' }, { id: 'z', content: '保持不变' }]
|
||||
}] } }));
|
||||
return;
|
||||
}
|
||||
res.end(JSON.stringify({ data: { praId: 'qa-network', praName: '接口验收题库', listQues: [{
|
||||
id: `network-${item}`, quesType: 0, content: `接口加载时尚未返回答案的题目 ${item}`,
|
||||
choiceItems: [{ id: 'n-a', content: '我的错误作答' }, { id: 'n-b', content: '平台标准选项' }]
|
||||
}] } }));
|
||||
return;
|
||||
}
|
||||
if (req.url === '/upm/qa-network/users/qa-user/answer') {
|
||||
req.resume();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ data: { correctAnswers: ['n-b'], explainText: '来自正常作答结果的标准答案。' } }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(req.url.startsWith('/multi-bank') ? multiBankFixture() : markup);
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
const browser = await chromium.launch(browserOptions());
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, acceptDownloads: true });
|
||||
await context.addInitScript(installGMShim);
|
||||
await context.addInitScript({ content: script });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
||||
await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.fulfill({ status: 204, body: '' }));
|
||||
|
||||
try {
|
||||
await page.goto(`${origin}/o2o/#/playinfo?taskId=qa-task-initial`, { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('#qb-panel').waitFor({ state: 'visible' });
|
||||
await t.test('页面非空、脱敏DOM夹具采集缺答案题目并恢复文字选择', async () => {
|
||||
assert.match(await page.title(), /判断题库1/);
|
||||
assert.equal(new URL(page.url()).origin, origin);
|
||||
assert.match(await page.locator('.fixture-content').innerText(), /干燥过程传质推动力/);
|
||||
const records = await waitFor(async () => (await storageRecords(page)).find(record => record.stem.includes('干燥过程传质推动力')), '真实题干应写入本地题库');
|
||||
assert.equal(records.answerStatus, 'missing');
|
||||
assert.equal(records.answerTexts.length, 0);
|
||||
assert.equal(await page.locator('.yxtulcdsdk-pc-marking-main').evaluate(element => getComputedStyle(element).userSelect), 'text');
|
||||
assert.equal(await page.locator('nextjs-portal, vite-error-overlay, #webpack-dev-server-client-overlay').count(), 0);
|
||||
});
|
||||
|
||||
await t.test('真实fetch与XHR响应观察:标准答案按同题ID补齐,暂停后停止采集', async () => {
|
||||
await page.evaluate(() => fetch('/upm/start?praId=qa-network').then(response => response.json()));
|
||||
const loaded = await waitFor(async () => (await storageRecords(page)).find(record => record.questionId === 'network-1'), 'fetch返回的完整题目应被采集');
|
||||
assert.equal(loaded.answerStatus, 'missing');
|
||||
await page.evaluate(() => new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', '/upm/qa-network/users/qa-user/answer');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = () => resolve(xhr.status);
|
||||
xhr.onerror = reject;
|
||||
xhr.send(JSON.stringify({ answers: [{ quesId: 'network-1', answers: ['n-a'] }] }));
|
||||
}));
|
||||
const completed = await waitFor(async () => (await storageRecords(page)).find(record => record.questionId === 'network-1' && record.answerStatus === 'confirmed'), 'XHR标准答案应按请求题ID补齐');
|
||||
assert.deepEqual(completed.answerTexts, ['平台标准选项']);
|
||||
assert.ok(!completed.answerTexts.includes('我的错误作答'), '不能将学生选择作为标准答案');
|
||||
await page.evaluate(() => fetch('/unrelated/report?item=noise').then(response => response.text()));
|
||||
await page.waitForTimeout(300);
|
||||
assert.equal((await storageRecords(page)).some(record => record.questionId === 'network-noise'), false, '无关接口不能采集');
|
||||
await activate(page, 'library');
|
||||
await page.locator('[data-action="toggle-capture"]').click();
|
||||
await page.evaluate(() => fetch('/upm/start?praId=qa-network&item=paused').then(response => response.json()));
|
||||
await page.waitForTimeout(300);
|
||||
assert.equal((await storageRecords(page)).some(record => record.questionId === 'network-paused'), false);
|
||||
await page.locator('[data-action="toggle-capture"]').click();
|
||||
await page.evaluate(() => fetch('/upm/start?praId=qa-network&item=resumed').then(response => response.json()));
|
||||
await waitFor(async () => (await storageRecords(page)).some(record => record.questionId === 'network-resumed'), '恢复采集后应重新观察fetch');
|
||||
await activate(page, 'search');
|
||||
});
|
||||
|
||||
await t.test('关闭复制恢复开关后原禁选样式生效,再开启恢复文字选择', async () => {
|
||||
await activate(page, 'settings');
|
||||
await page.locator('#qb-copy-enabled').uncheck();
|
||||
assert.equal(await page.locator('.yxtulcdsdk-pc-marking-main').evaluate(element => getComputedStyle(element).userSelect), 'none');
|
||||
await page.locator('#qb-copy-enabled').check();
|
||||
assert.equal(await page.locator('.yxtulcdsdk-pc-marking-main').evaluate(element => getComputedStyle(element).userSelect), 'text');
|
||||
await activate(page, 'search');
|
||||
});
|
||||
|
||||
await t.test('折叠后可展开;隐藏与刷新后恢复入口保留', async () => {
|
||||
const fullHeight = (await page.locator('#qb-panel').boundingBox()).height;
|
||||
await page.locator('[data-action="collapse"]').click();
|
||||
const collapsedHeight = (await page.locator('#qb-panel').boundingBox()).height;
|
||||
assert.ok(collapsedHeight < fullHeight, '折叠应缩小面板');
|
||||
await page.locator('[data-action="collapse"]').click();
|
||||
await page.locator('.header [data-action="hide"]').click();
|
||||
assert.equal(await page.locator('#qb-panel').isVisible(), false);
|
||||
assert.equal(await page.locator('#qb-launcher').isVisible(), true);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.locator('#qb-launcher').waitFor({ state: 'visible' });
|
||||
assert.equal(await page.locator('#qb-panel').isVisible(), false);
|
||||
await selectQuestion(page);
|
||||
await page.waitForTimeout(300);
|
||||
assert.equal(await page.locator('#qb-panel').isVisible(), false, '隐藏后划词不能擅自弹出面板');
|
||||
await page.screenshot({ path: path.join(QA_DIR, 'hidden-launcher.png') });
|
||||
await page.locator('[data-action="restore"]').click();
|
||||
assert.equal(await page.locator('#qb-panel').isVisible(), true);
|
||||
assert.match(await page.locator('#qb-search').inputValue(), /干燥过程传质推动力/, '恢复面板后应保留隐藏时划词检索的内容');
|
||||
});
|
||||
|
||||
await t.test('完全隐藏后快捷键与油猴菜单均可唤出', async () => {
|
||||
await activate(page, 'settings');
|
||||
await page.locator('#qb-keep-launcher').uncheck();
|
||||
await page.locator('.header [data-action="hide"]').click();
|
||||
assert.equal(await page.locator('#qb-panel').isVisible(), false);
|
||||
assert.equal(await page.locator('#qb-launcher').isVisible(), false);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('#huayi-qb-root', { state: 'attached' });
|
||||
assert.equal(await page.locator('#qb-launcher').isVisible(), false);
|
||||
await page.keyboard.press('Alt+Shift+Q');
|
||||
await page.locator('#qb-panel').waitFor({ state: 'visible' });
|
||||
await page.locator('.header [data-action="hide"]').click();
|
||||
const menuFound = await page.evaluate(() => {
|
||||
const menu = window.__qbMenus.find(item => /显示|唤出|恢复|打开/.test(item.label));
|
||||
if (!menu) return false;
|
||||
menu.callback();
|
||||
return true;
|
||||
});
|
||||
assert.equal(menuFound, true, '应提供油猴菜单恢复入口');
|
||||
assert.equal(await page.locator('#qb-panel').isVisible(), true);
|
||||
await activate(page, 'settings');
|
||||
await page.locator('#qb-keep-launcher').check();
|
||||
});
|
||||
|
||||
await t.test('JSON导入与当前页面选项乱序:正确内容映射为C', async () => {
|
||||
const initial = (await storageRecords(page))[0];
|
||||
const record = {
|
||||
bankId: initial.bankId, bankName: initial.bankName, questionId: 'e2e-single-7',
|
||||
type: 'single', stem: '以下哪项操作可以提高温度?',
|
||||
options: [{ id: 'x', text: '降低温度', label: 'A' }, { id: 'y', text: '提高温度', label: 'B' }, { id: 'z', text: '保持不变', label: 'C' }],
|
||||
correctOptionIds: ['y'], answerTexts: ['提高温度'], answerStatus: 'confirmed',
|
||||
explanation: '用于验证答案内容与页面当前顺序对应。', source: { kind: 'fixture', url: origin, observedAt: new Date().toISOString() }
|
||||
};
|
||||
await importFile(page, 'verified-question.json', JSON.stringify({ schemaVersion: 1, records: [record] }));
|
||||
await waitFor(async () => (await storageRecords(page)).some(item => item.questionId === record.questionId), 'JSON导入应持久化');
|
||||
await setSingleQuestion(page);
|
||||
await activate(page, 'search');
|
||||
await page.locator('#qb-search').fill(record.stem);
|
||||
await waitFor(async () => /提高温度/.test(await page.locator('#qb-panel').innerText()), '检索应显示正确答案内容');
|
||||
await waitFor(async () => /当前[^\n]{0,20}C|对应[^\n]{0,20}C/.test(await page.locator('#qb-panel').innerText()), '原B答案应按页面乱序映射到C');
|
||||
await waitFor(async () => (await storageRecords(page)).filter(item => item.stem === record.stem).length === 1, '无ID的DOM题目应与唯一同题标准答案合并');
|
||||
assert.equal(await page.locator('#qb-results article').count(), 1, '同一题不能同时出现已确认和待补两张卡');
|
||||
await page.screenshot({ path: path.join(QA_DIR, 'desktop-answer-mapping.png') });
|
||||
});
|
||||
|
||||
let exportedCSV = '';
|
||||
await t.test('CSV真实下载、BOM与答案内容及重新导入', async () => {
|
||||
await activate(page, 'library');
|
||||
await page.locator('#qb-scope').selectOption('');
|
||||
const downloaded = page.waitForEvent('download');
|
||||
await page.locator('[data-action="export-csv"]').click();
|
||||
const download = await downloaded;
|
||||
const csvPath = path.join(QA_DIR, 'exported-question-bank.csv');
|
||||
await download.saveAs(csvPath);
|
||||
const bytes = fs.readFileSync(csvPath);
|
||||
assert.equal(bytes.subarray(0, 3).toString('hex'), 'efbbbf');
|
||||
exportedCSV = bytes.toString('utf8');
|
||||
assert.match(exportedCSV, /提高温度/);
|
||||
const before = (await storageRecords(page)).length;
|
||||
await importFile(page, 'round-trip.csv', exportedCSV);
|
||||
await page.waitForTimeout(350);
|
||||
assert.equal((await storageRecords(page)).length, before, 'CSV重复导入应去重');
|
||||
});
|
||||
|
||||
await t.test('390px视口面板可见且不超出视口', async () => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await activate(page, 'search');
|
||||
await page.locator('#qb-search').fill('以下哪项操作可以提高温度?');
|
||||
await page.waitForTimeout(200);
|
||||
const rect = await page.locator('#qb-panel').boundingBox();
|
||||
assert.ok(rect.x >= 0 && rect.x + rect.width <= 391, '面板横向不应溢出');
|
||||
assert.ok(rect.y >= 0 && rect.y + rect.height <= 845, '面板纵向应适配可用高度');
|
||||
await page.screenshot({ path: path.join(QA_DIR, 'mobile-answer-mapping.png') });
|
||||
});
|
||||
|
||||
await t.test('格式错误导入保持已有题库并给出失败提示', async () => {
|
||||
const before = (await storageRecords(page)).length;
|
||||
await importFile(page, 'broken.json', '{"schemaVersion":1,"records":[{"stem":"缺少题型"}]}');
|
||||
await waitFor(async () => /导入失败/.test(await page.locator('#qb-status').innerText()), '错误文件应显示导入失败');
|
||||
assert.equal((await storageRecords(page)).length, before);
|
||||
});
|
||||
|
||||
await t.test('当前题库范围包含唯一同名无bankId旧题;同名多bankId时不盲并', async () => {
|
||||
await setSingleQuestion(page);
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('.yxtulcdsdk-exam-header .yxtf-tooltip').textContent = '范围验收题库';
|
||||
document.querySelector('.yxtulcdsdk-form__content > [data-rich-text]').textContent = '当前题库范围验收题';
|
||||
});
|
||||
await page.evaluate(() => fetch('/upm/start?praId=scope-bank&item=scope-current').then(response => response.json()));
|
||||
await waitFor(async () => (await storageRecords(page)).some(record => record.questionId === 'scope-current' && record.bankId === 'scope-bank'), '当前题应从网络数据取得稳定题库ID');
|
||||
const shared = {
|
||||
bankName: '范围验收题库', type: 'single',
|
||||
options: [{ id: 'x', text: '降低温度', label: 'A' }, { id: 'y', text: '提高温度', label: 'B' }, { id: 'z', text: '保持不变', label: 'C' }],
|
||||
correctOptionIds: ['y'], answerTexts: ['提高温度'], answerStatus: 'confirmed',
|
||||
source: { kind: 'import', url: '', observedAt: new Date().toISOString() }
|
||||
};
|
||||
await importFile(page, 'current-scope.json', JSON.stringify({ schemaVersion: 1, records: [
|
||||
{ ...shared, bankId: 'scope-bank', questionId: 'scope-current', stem: '当前题库范围验收题' },
|
||||
{ ...shared, bankId: '', questionId: 'scope-legacy', stem: '只有题库名称的旧题也应导出' }
|
||||
] }));
|
||||
await waitFor(async () => (await storageRecords(page)).some(record => record.questionId === 'scope-legacy'), '同名无bankId旧题应已导入');
|
||||
await page.locator('#qb-scope').selectOption('__current__');
|
||||
const downloadCSV = async filename => {
|
||||
const waiting = page.waitForEvent('download');
|
||||
await page.locator('[data-action="export-csv"]').click();
|
||||
const download = await waiting;
|
||||
const location = path.join(QA_DIR, filename);
|
||||
await download.saveAs(location);
|
||||
return fs.readFileSync(location, 'utf8');
|
||||
};
|
||||
const uniqueScope = await downloadCSV('current-scope-unique-name.csv');
|
||||
assert.match(uniqueScope, /scope-current/);
|
||||
assert.match(uniqueScope, /scope-legacy/, '唯一同名旧题不能从当前题库导出中遗漏');
|
||||
await importFile(page, 'ambiguous-scope.json', JSON.stringify({ schemaVersion: 1, records: [
|
||||
{ ...shared, bankId: 'scope-other-bank', questionId: 'scope-other', stem: '同名但不同题库ID的题目' }
|
||||
] }));
|
||||
await waitFor(async () => (await storageRecords(page)).some(record => record.questionId === 'scope-other'), '第二个同名题库应已导入');
|
||||
const ambiguousScope = await downloadCSV('current-scope-ambiguous-name.csv');
|
||||
assert.match(ambiguousScope, /scope-current/);
|
||||
assert.doesNotMatch(ambiguousScope, /scope-legacy/, '存在同名多个题库ID时不能猜测无ID旧题归属');
|
||||
assert.doesNotMatch(ambiguousScope, /scope-other/, '其他题库ID不能混入当前题库范围');
|
||||
await page.locator('#qb-scope').selectOption('');
|
||||
});
|
||||
|
||||
await t.test('JSON批次第二题非法时整批保持未导入', async () => {
|
||||
const before = (await storageRecords(page)).length;
|
||||
const valid = {
|
||||
bankId: 'batch-bank', bankName: '批次验收题库', questionId: 'atomic-first-must-not-save',
|
||||
type: 'single', stem: '批次第一条有效题目', options: [{ id: 'a', text: '选项一', label: 'A' }, { id: 'b', text: '选项二', label: 'B' }],
|
||||
correctOptionIds: ['a'], answerTexts: ['选项一'], answerStatus: 'confirmed'
|
||||
};
|
||||
await importFile(page, 'invalid-second-record.json', JSON.stringify({ schemaVersion: 1, records: [
|
||||
valid, { ...valid, questionId: 'atomic-invalid-second', type: 'unsupported', stem: '批次第二条非法题目' }
|
||||
] }));
|
||||
await waitFor(async () => /导入失败/.test(await page.locator('#qb-status').innerText()), '第二题非法应显示整批导入失败');
|
||||
const records = await storageRecords(page);
|
||||
assert.equal(records.length, before);
|
||||
assert.equal(records.some(record => record.questionId === 'atomic-first-must-not-save'), false, '不能先导入第一条再失败');
|
||||
});
|
||||
|
||||
await t.test('已确认答案不属于选项时展示冲突卡且不给出答案字母', async () => {
|
||||
const stem = '答案不在选项冲突验收题';
|
||||
await importFile(page, 'invalid-confirmed-answer.json', JSON.stringify({ schemaVersion: 1, records: [{
|
||||
bankId: 'conflict-bank', bankName: '冲突验收题库', questionId: 'answer-outside-options', type: 'single', stem,
|
||||
options: [{ id: 'a', text: '选项一', label: 'A' }, { id: 'b', text: '选项二', label: 'B' }],
|
||||
answerTexts: ['不存在的正确选项'], answerStatus: 'confirmed'
|
||||
}] }));
|
||||
const record = await waitFor(async () => (await storageRecords(page)).find(item => item.questionId === 'answer-outside-options'), '不一致答案应保留供核验');
|
||||
assert.equal(record.answerStatus, 'conflict');
|
||||
await activate(page, 'search');
|
||||
await page.locator('#qb-search-scope').selectOption('');
|
||||
await page.locator('#qb-search').fill(stem);
|
||||
await waitFor(async () => (await page.locator('#qb-results .badge.conflict').count()) === 1, '不一致答案应显示冲突状态');
|
||||
const card = page.locator('#qb-results article').first();
|
||||
assert.match(await card.innerText(), /存在不同标准答案,需要核验/);
|
||||
assert.doesNotMatch(await card.innerText(), /当前页面对应[::]/, '冲突答案不能显示已确认字母');
|
||||
});
|
||||
|
||||
await t.test('多题库:文字搜索、当前库统计与命名、多文件导入', async group => {
|
||||
const bankContext = await browser.newContext({ viewport: { width: 1440, height: 1000 }, acceptDownloads: true });
|
||||
await bankContext.addInitScript(installGMShim);
|
||||
await bankContext.addInitScript({ content: script });
|
||||
await bankContext.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.fulfill({ status: 204, body: '' }));
|
||||
const bankPage = await bankContext.newPage();
|
||||
bankPage.on('pageerror', error => errors.push(error.message));
|
||||
bankPage.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
||||
try {
|
||||
await bankPage.goto(`${origin}/multi-bank#/playinfo?taskId=${BANK_FIXTURES[0].taskId}`, { waitUntil: 'domcontentloaded' });
|
||||
await bankPage.locator('#qb-panel').waitFor({ state: 'visible' });
|
||||
await setupBankSwitches(bankPage);
|
||||
await waitFor(async () => (await storageRecords(bankPage)).some(record => record.bankId === 'judge-1'), '初始判断题库应采集到稳定身份');
|
||||
|
||||
await group.test('五个题库侧栏切换只统计和导出当前库,文件名随实际名称变化', async () => {
|
||||
for (const bank of BANK_FIXTURES) {
|
||||
if (bank.id !== BANK_FIXTURES[0].id) await bankPage.locator(`[data-qa-bank="${bank.id}"]`).click();
|
||||
await waitFor(async () => (await storageRecords(bankPage)).some(record => record.bankId === bank.id), `${bank.name}应采集成功`);
|
||||
await waitFor(async () => (await bankPage.locator('#qb-context-name').innerText()).includes(bank.name), '当前题库名称应跟随红色侧栏项');
|
||||
await activate(bankPage, 'library');
|
||||
assert.equal(await bankPage.locator('#qb-scope').inputValue(), '__current__', '切换后默认导出当前题库');
|
||||
assert.equal((await bankPage.locator('#qb-stat-total').innerText()).replace(/,/g, ''), '1', '切换后主统计不能累积其他题库');
|
||||
assert.ok((await bankPage.locator('#qb-export-name').innerText()).includes(bank.name), '导出说明应包含实际题库名称');
|
||||
const exported = await downloadedCSV(bankPage, `current-${bank.id}.csv`);
|
||||
assert.ok(exported.name.includes(bank.name), `文件名应使用实际题库名称:${exported.name}`);
|
||||
assert.ok(exported.text.includes(bank.stem));
|
||||
for (const other of BANK_FIXTURES.filter(item => item.id !== bank.id)) assert.ok(!exported.text.includes(other.stem), '当前库CSV不能混入已保存的其他题库');
|
||||
}
|
||||
assert.match(await bankPage.title(), /判断题库1/, '页面title保持旧值,验证侧栏优先识别');
|
||||
const stored = await storageRecords(bankPage);
|
||||
for (const bank of BANK_FIXTURES) {
|
||||
const exact = stored.filter(record => record.stem === bank.stem);
|
||||
assert.equal(exact.length, 1, '切换过渡期间旧题不得采为新题库副本');
|
||||
assert.equal(exact[0].bankId, bank.id);
|
||||
assert.equal(exact[0].bankName, bank.name);
|
||||
}
|
||||
await bankPage.screenshot({ path: path.join(QA_DIR, 'current-bank-export.png') });
|
||||
});
|
||||
|
||||
await group.test('文字搜索可手输题干、选项与标准答案,支持当前库和全部库', async () => {
|
||||
await bankPage.evaluate(() => { window.__qaLateResponse = fetch('/upm/start?praId=multiple&item=bank-multiple&delay=500').then(response => response.json()); });
|
||||
await bankPage.locator('[data-qa-bank="single-1"]').click();
|
||||
await waitFor(async () => (await bankPage.locator('#qb-context-name').innerText()).includes('单选题库1'), '应切至单选库1');
|
||||
await bankPage.evaluate(() => window.__qaLateResponse);
|
||||
assert.ok((await bankPage.locator('#qb-context-name').innerText()).includes('单选题库1'), '旧题库迟到响应不能把当前名称改回去');
|
||||
await activate(bankPage, 'search');
|
||||
await bankPage.locator('#qb-search-field').selectOption('stem');
|
||||
await bankPage.locator('#qb-search').fill('单选库一');
|
||||
await bankPage.locator('#qb-search').press('Enter');
|
||||
await waitFor(async () => (await bankPage.locator('#qb-results article').count()) === 1, '手输题干应得到唯一候选');
|
||||
assert.match(await bankPage.locator('#qb-results').innerText(), /提高温度/);
|
||||
await bankPage.locator('#qb-search-field').selectOption('options');
|
||||
await bankPage.locator('#qb-search').fill('提高温度');
|
||||
await bankPage.locator('[data-action="search"]').click();
|
||||
await waitFor(async () => (await bankPage.locator('#qb-results article').count()) === 1, '选项文字应可检索');
|
||||
await bankPage.locator('#qb-search-field').selectOption('answer');
|
||||
await bankPage.locator('#qb-search').fill('降低压力');
|
||||
await bankPage.locator('#qb-search').press('Enter');
|
||||
await waitFor(async () => (await bankPage.locator('#qb-results article').count()) === 0, '当前单选库1不应查出单选库2答案');
|
||||
await bankPage.locator('#qb-search-scope').selectOption('');
|
||||
await waitFor(async () => (await bankPage.locator('#qb-results article').count()) === 1, '全部库应检索到另一题库的标准答案');
|
||||
assert.match(await bankPage.locator('#qb-results').innerText(), /单选题库2/);
|
||||
await bankPage.locator('[data-action="clear-search"]').click();
|
||||
assert.equal(await bankPage.locator('#qb-search').inputValue(), '');
|
||||
assert.equal(await bankPage.locator('#qb-results article').count(), 0);
|
||||
await bankPage.locator('#qb-search-field').selectOption('all');
|
||||
await bankPage.locator('#qb-search').fill('巡检');
|
||||
await bankPage.locator('#qb-search').press('Enter');
|
||||
await waitFor(async () => (await bankPage.locator('#qb-results article').count()) > 0, '全部字段关键词应可查询');
|
||||
await bankPage.screenshot({ path: path.join(QA_DIR, 'text-search-all-banks.png') });
|
||||
});
|
||||
|
||||
await group.test('一次导入多个CSV和JSON题库并保留各自名称,重复导入不增加题数', async () => {
|
||||
const jsonRecord = {
|
||||
bankId: 'import-chemistry', bankName: '导入化学题库', questionId: 'import-chemical-one', type: 'single', stem: '化学导入样题的答案是什么?',
|
||||
options: [{ id: 'a', text: '甲', label: 'A' }, { id: 'b', text: '乙', label: 'B' }], answerTexts: ['乙'], correctOptionIds: ['b'], answerStatus: 'confirmed'
|
||||
};
|
||||
const files = [
|
||||
{ name: '化学.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({ schemaVersion: 1, records: [jsonRecord] })) },
|
||||
{ name: '设备.csv', mimeType: 'text/csv', buffer: Buffer.from('来源题库,题型,题干,选项A,选项B,正确答案内容,答案状态\n导入设备题库,单选题,设备导入样题如何操作?,开阀,关阀,开阀,已确认\n') },
|
||||
{ name: '文件名备用题库.csv', mimeType: 'text/csv', buffer: Buffer.from('题型,题干,选项A,选项B,正确答案内容,答案状态\n单选题,没有来源名称的题目如何归库?,一,二,二,已确认\n') }
|
||||
];
|
||||
await activate(bankPage, 'library');
|
||||
assert.equal(await bankPage.locator('#qb-import-file').getAttribute('multiple') !== null, true);
|
||||
await bankPage.locator('#qb-import-file').setInputFiles(files);
|
||||
await waitFor(async () => (await storageRecords(bankPage)).some(record => record.questionId === jsonRecord.questionId), '多个文件应一批导入');
|
||||
const records = await storageRecords(bankPage);
|
||||
assert.ok(records.some(record => record.bankName === '导入化学题库' && record.stem === jsonRecord.stem));
|
||||
assert.ok(records.some(record => record.bankName === '导入设备题库' && record.stem.startsWith('设备导入')));
|
||||
assert.ok(records.some(record => record.bankName === '文件名备用题库' && record.stem.startsWith('没有来源名称')));
|
||||
const total = records.length;
|
||||
await bankPage.locator('#qb-import-file').setInputFiles(files);
|
||||
await waitFor(async () => /导入/.test(await bankPage.locator('#qb-status').innerText()), '重复多文件导入应完成');
|
||||
await bankPage.waitForTimeout(350);
|
||||
assert.equal((await storageRecords(bankPage)).length, total);
|
||||
assert.equal((await bankPage.locator('#qb-stat-total').innerText()).replace(/,/g, ''), '1', '导入多个题库不改变当前题库统计');
|
||||
});
|
||||
|
||||
await group.test('多个文件中一个格式错误时整批不导入', async () => {
|
||||
const before = (await storageRecords(bankPage)).length;
|
||||
await bankPage.locator('#qb-import-file').setInputFiles([
|
||||
{ name: '这一库不能部分写入.csv', mimeType: 'text/csv', buffer: Buffer.from('来源题库,题型,题干,选项A,选项B,正确答案内容\n原子文件题库,单选题,多文件失败前有效题目,一,二,一\n') },
|
||||
{ name: '坏文件.json', mimeType: 'application/json', buffer: Buffer.from('{"schemaVersion":1,"records":[{"stem":"缺少题型"}]}') }
|
||||
]);
|
||||
await waitFor(async () => /导入失败/.test(await bankPage.locator('#qb-status').innerText()), '应提示多文件批次失败');
|
||||
assert.equal((await storageRecords(bankPage)).length, before);
|
||||
assert.equal((await storageRecords(bankPage)).some(record => record.stem === '多文件失败前有效题目'), false);
|
||||
});
|
||||
|
||||
await group.test('导出指定题库使用实际名称并清理Windows文件名非法字符', async () => {
|
||||
const bankName = '自定义:专项/题库*?';
|
||||
await importFile(bankPage, 'special.json', JSON.stringify({ schemaVersion: 1, records: [{
|
||||
bankId: 'special-bank', bankName, questionId: 'special-one', type: 'judge', stem: '特殊名称题库的判断题', options: [{ id: '1', text: '正确' }, { id: '0', text: '错误' }], answerTexts: ['正确'], correctOptionIds: ['1'], answerStatus: 'confirmed'
|
||||
}] }));
|
||||
const value = await bankPage.locator('#qb-scope option').evaluateAll((options, name) => options.find(option => option.textContent.includes(name))?.value, bankName);
|
||||
assert.ok(value, '应可选择刚导入的指定题库');
|
||||
await bankPage.locator('#qb-scope').selectOption(value);
|
||||
const exported = await downloadedCSV(bankPage, 'special-name.csv');
|
||||
assert.match(exported.name, /自定义.*专项.*题库/);
|
||||
assert.doesNotMatch(exported.name, /[<>:"/\\|?*]/, '文件名不能包含Windows非法字符');
|
||||
assert.match(exported.text, /特殊名称题库的判断题/);
|
||||
assert.ok(exported.text.includes(bankName), 'CSV内部来源名称应保留原文');
|
||||
});
|
||||
|
||||
await group.test('新空题库切换后不继续显示或导出上一库数据,窄屏布局正常', async () => {
|
||||
await bankPage.evaluate(() => {
|
||||
document.querySelectorAll('.task-content-active').forEach(item => item.classList.remove('task-content-active'));
|
||||
const button = document.createElement('button');
|
||||
button.className = 'task-content task-content-active';
|
||||
button.dataset.taskId = 'qa-task-empty';
|
||||
button.innerHTML = '<span class="group-title task-content-color-active">尚未载入的空题库</span>';
|
||||
document.querySelector('#qa-sidebar').append(button);
|
||||
location.hash = '/playinfo?taskId=qa-task-empty';
|
||||
document.querySelector('.yxtulcdsdk-exam-container').setAttribute('task-id', 'qa-task-empty');
|
||||
document.querySelector('.yxtulcdsdk-exam-header .yxtf-tooltip').textContent = '尚未载入的空题库';
|
||||
document.querySelector('.yxtulcdsdk-pc-marking-main').replaceChildren();
|
||||
});
|
||||
await waitFor(async () => (await bankPage.locator('#qb-context-name').innerText()).includes('尚未载入的空题库'), '空库名称也应及时更新');
|
||||
await activate(bankPage, 'library');
|
||||
assert.equal(await bankPage.locator('#qb-scope').inputValue(), '__current__');
|
||||
await waitFor(async () => (await bankPage.locator('#qb-stat-total').innerText()).trim() === '0', '空库主计数应为0');
|
||||
let downloaded = false;
|
||||
const captureDownload = () => { downloaded = true; };
|
||||
bankPage.on('download', captureDownload);
|
||||
await bankPage.locator('[data-action="export-csv"]').click();
|
||||
await bankPage.waitForTimeout(250);
|
||||
bankPage.off('download', captureDownload);
|
||||
assert.equal(downloaded, false, '空库不能导出上一题库的数据');
|
||||
assert.match(await bankPage.locator('#qb-status').innerText(), /没有题目|暂无|尚未/);
|
||||
await bankPage.setViewportSize({ width: 390, height: 844 });
|
||||
await activate(bankPage, 'search');
|
||||
const rect = await bankPage.locator('#qb-panel').boundingBox();
|
||||
assert.ok(rect.x >= 0 && rect.x + rect.width <= 391);
|
||||
assert.ok(rect.y >= 0 && rect.y + rect.height <= 845);
|
||||
await bankPage.screenshot({ path: path.join(QA_DIR, 'mobile-current-bank.png') });
|
||||
});
|
||||
} finally { await bankContext.close(); }
|
||||
});
|
||||
|
||||
await t.test('控制台无脚本错误', async () => { assert.deepEqual(errors, []); });
|
||||
t.diagnostic(`Browser plugin not available; cached Chromium via Playwright. Local origin: ${origin}; viewports 1440x1000 and 390x844; evidence: ${QA_DIR}`);
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user