Add auditing
This commit is contained in:
parent
d230b98d74
commit
718b67080a
3 changed files with 167 additions and 1 deletions
|
|
@ -19,7 +19,9 @@ jobs:
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: npm run format:check
|
run: |
|
||||||
|
npm run format:check
|
||||||
|
npm run test
|
||||||
|
|
||||||
- name: Install backend dependencies
|
- name: Install backend dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
|
||||||
163
backend/audit-question-bank.js
Normal file
163
backend/audit-question-bank.js
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const { validateQuestionBank } = require("./validate-question-bank");
|
||||||
|
|
||||||
|
const additions = require("./audit/additions.json");
|
||||||
|
const corrections = require("./audit/corrections.json");
|
||||||
|
const papers = require("./audit/papers.json");
|
||||||
|
const assets = require("./audit/assets.json");
|
||||||
|
const fields = [
|
||||||
|
"source",
|
||||||
|
"description",
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
"c",
|
||||||
|
"d",
|
||||||
|
"correct",
|
||||||
|
"type",
|
||||||
|
"image",
|
||||||
|
];
|
||||||
|
const acceptedAnswers = (question) =>
|
||||||
|
question.acceptedAnswers || [question.correct];
|
||||||
|
|
||||||
|
function auditQuestionBank(
|
||||||
|
questions,
|
||||||
|
{
|
||||||
|
expectedQuestions = additions,
|
||||||
|
expectedCorrections = corrections,
|
||||||
|
expectedPapers = papers,
|
||||||
|
expectedAssets = assets,
|
||||||
|
picsPath,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const errors = validateQuestionBank(questions);
|
||||||
|
if (!Array.isArray(questions)) return errors;
|
||||||
|
const bySource = new Map(questions.map((q) => [q.source, q]));
|
||||||
|
const ids = new Set();
|
||||||
|
for (const question of questions) {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(question.id) ||
|
||||||
|
question.id < 1 ||
|
||||||
|
ids.has(question.id)
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`${question.source}: invalid or duplicate question ID ${question.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ids.add(question.id);
|
||||||
|
}
|
||||||
|
for (const paper of expectedPapers) {
|
||||||
|
if (!questions.some((q) => q.source.startsWith(`${paper.paper}/`))) {
|
||||||
|
errors.push(`${paper.paper}: entire paper is missing`);
|
||||||
|
}
|
||||||
|
paper.answerKey.forEach((correct, index) => {
|
||||||
|
const source = `${paper.paper}/${index + 1}`;
|
||||||
|
const actual = bySource.get(source);
|
||||||
|
if (!actual) {
|
||||||
|
errors.push(`${source}: missing question`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (actual.correct !== correct)
|
||||||
|
errors.push(`${source}: correct answer differs from the official key`);
|
||||||
|
const accepted = paper.acceptedAnswers[index + 1] || [correct];
|
||||||
|
if (JSON.stringify(acceptedAnswers(actual)) !== JSON.stringify(accepted))
|
||||||
|
errors.push(`${source}: accepted answers differ from the official key`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const correction of expectedCorrections) {
|
||||||
|
const actual = bySource.get(correction.source);
|
||||||
|
if (!actual) continue;
|
||||||
|
for (const [field, value] of Object.entries(correction.after)) {
|
||||||
|
if (JSON.stringify(actual[field]) !== JSON.stringify(value))
|
||||||
|
errors.push(
|
||||||
|
`${correction.source}: ${field} differs from the verified correction`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const expected of expectedQuestions) {
|
||||||
|
const actual = bySource.get(expected.source);
|
||||||
|
if (!actual) {
|
||||||
|
errors.push(`${expected.source}: missing question`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const field of fields) {
|
||||||
|
if (actual[field] !== expected[field])
|
||||||
|
errors.push(
|
||||||
|
`${expected.source}: ${field} differs from the reviewed source`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
JSON.stringify(acceptedAnswers(actual)) !==
|
||||||
|
JSON.stringify(acceptedAnswers(expected))
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`${expected.source}: accepted answers differ from the official key`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (picsPath) {
|
||||||
|
for (const asset of expectedAssets) {
|
||||||
|
if (
|
||||||
|
path.basename(asset.file) !== asset.file ||
|
||||||
|
/[\\/]/.test(asset.file)
|
||||||
|
) {
|
||||||
|
errors.push(`Unsafe image filename: ${asset.file}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(path.join(picsPath, asset.file));
|
||||||
|
if (
|
||||||
|
crypto.createHash("sha256").update(data).digest("hex") !==
|
||||||
|
asset.sha256
|
||||||
|
) {
|
||||||
|
errors.push(`${asset.file}: image differs from the reviewed figure`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(
|
||||||
|
`${asset.file}: ${error.code === "ENOENT" ? "missing image" : error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
try {
|
||||||
|
const { values, positionals } = require("node:util").parseArgs({
|
||||||
|
allowPositionals: true,
|
||||||
|
options: { pics: { type: "string" } },
|
||||||
|
});
|
||||||
|
const dataPath = positionals[0] || process.env.DATA_PATH;
|
||||||
|
if (!dataPath || positionals.length > 1)
|
||||||
|
throw new Error(
|
||||||
|
"Usage: node audit-question-bank.js <fizika.json> --pics <images-directory>",
|
||||||
|
);
|
||||||
|
const errors = auditQuestionBank(
|
||||||
|
JSON.parse(fs.readFileSync(dataPath, "utf8")),
|
||||||
|
{
|
||||||
|
picsPath:
|
||||||
|
values.pics ||
|
||||||
|
process.env.PICS_PATH ||
|
||||||
|
path.join(__dirname, "../frontend/pics"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (errors.length) throw new Error(errors.join("\n"));
|
||||||
|
console.log(
|
||||||
|
`Audit passed: ${papers.length} papers, ${papers.reduce((count, paper) => count + paper.questionCount, 0)} questions, ${assets.length} images checked against the reviewed sources.`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
auditQuestionBank,
|
||||||
|
additions,
|
||||||
|
corrections,
|
||||||
|
papers,
|
||||||
|
assets,
|
||||||
|
fields,
|
||||||
|
};
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "nodemon server.js",
|
"dev": "nodemon server.js",
|
||||||
"validate:data": "node validate-question-bank.js",
|
"validate:data": "node validate-question-bank.js",
|
||||||
|
"audit:data": "node audit-question-bank.js",
|
||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue