AI bugbash
This commit is contained in:
parent
a97ca3852e
commit
ea84a911f4
5 changed files with 384 additions and 61 deletions
|
|
@ -6,6 +6,7 @@
|
|||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"validate:data": "node validate-question-bank.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -135,6 +135,11 @@ app.get("/api/admin/questions", async (req, res) => {
|
|||
app.post("/api/admin/questions", async (req, res) => {
|
||||
try {
|
||||
const data = await readData();
|
||||
if (data.some((q) => q.source === req.body.source)) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "Source identifier already exists" });
|
||||
}
|
||||
const maxId = Math.max(...data.map((q) => q.id), 0);
|
||||
|
||||
const newQuestion = { id: maxId + 1, ...req.body };
|
||||
|
|
@ -156,7 +161,16 @@ app.put("/api/admin/questions/:id", async (req, res) => {
|
|||
return res.status(404).json({ error: "Question not found" });
|
||||
}
|
||||
|
||||
data[index] = { ...data[index], ...req.body };
|
||||
const updatedQuestion = { ...data[index], ...req.body };
|
||||
if (
|
||||
data.some((q, i) => i !== index && q.source === updatedQuestion.source)
|
||||
) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "Source identifier already exists" });
|
||||
}
|
||||
|
||||
data[index] = updatedQuestion;
|
||||
await writeData(data);
|
||||
|
||||
res.json(data[index]);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ process.env.DATA_PATH = dataPath;
|
|||
process.env.PICS_PATH = picsPath;
|
||||
|
||||
const { app, isUnsafeFilename } = require("./server");
|
||||
const { validateQuestionBank } = require("./validate-question-bank");
|
||||
|
||||
const seedQuestions = [
|
||||
{
|
||||
|
|
@ -107,6 +108,61 @@ test("PUT updates an existing question", async () => {
|
|||
assert.equal(updated.source, "2016/m1/1");
|
||||
});
|
||||
|
||||
test("POST rejects a duplicate source without changing the bank", async () => {
|
||||
const before = fs.readFileSync(dataPath, "utf8");
|
||||
const res = await sendJson("/api/admin/questions", "POST", seedQuestions[0]);
|
||||
assert.equal(res.status, 409);
|
||||
assert.equal((await res.json()).error, "Source identifier already exists");
|
||||
assert.equal(fs.readFileSync(dataPath, "utf8"), before);
|
||||
});
|
||||
|
||||
test("PUT rejects another question's source without changing the bank", async () => {
|
||||
const before = fs.readFileSync(dataPath, "utf8");
|
||||
const res = await sendJson("/api/admin/questions/2", "PUT", {
|
||||
source: seedQuestions[0].source,
|
||||
description: "must not be saved",
|
||||
});
|
||||
assert.equal(res.status, 409);
|
||||
assert.equal((await res.json()).error, "Source identifier already exists");
|
||||
assert.equal(fs.readFileSync(dataPath, "utf8"), before);
|
||||
});
|
||||
|
||||
test("PUT allows a question to keep its own source", async () => {
|
||||
const res = await sendJson("/api/admin/questions/1", "PUT", {
|
||||
source: seedQuestions[0].source,
|
||||
description: "updated",
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal((await res.json()).description, "updated");
|
||||
});
|
||||
|
||||
test("correcting October 2025 question 6 leaves exactly one result for 5 and 6", async () => {
|
||||
const questions = Array.from({ length: 15 }, (_, i) => ({
|
||||
...seedQuestions[0],
|
||||
id: 683 + i,
|
||||
source: `2025/2/${i + 1}`,
|
||||
}));
|
||||
questions[5].source = "2025/2/5";
|
||||
questions[5].image = "202526.JPG";
|
||||
fs.writeFileSync(dataPath, JSON.stringify(questions, null, 2));
|
||||
|
||||
const res = await sendJson("/api/admin/questions/688", "PUT", {
|
||||
source: "2025/2/6",
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const bank = await (await api("/api/fizika")).json();
|
||||
assert.deepEqual(
|
||||
bank.filter((q) => q.source === "2025/2/5").map((q) => q.id),
|
||||
[687],
|
||||
);
|
||||
assert.deepEqual(
|
||||
bank.filter((q) => q.source === "2025/2/6").map((q) => q.id),
|
||||
[688],
|
||||
);
|
||||
assert.equal(bank.find((q) => q.id === 688).image, "202526.JPG");
|
||||
assert.deepEqual(validateQuestionBank(bank), []);
|
||||
});
|
||||
|
||||
test("PUT returns 404 for a missing question", async () => {
|
||||
const res = await sendJson("/api/admin/questions/999", "PUT", { a: "x" });
|
||||
assert.equal(res.status, 404);
|
||||
|
|
|
|||
98
backend/validate-question-bank.js
Normal file
98
backend/validate-question-bank.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// Validate a complete import separately from individual admin edits, which
|
||||
// legitimately build up a paper one question at a time.
|
||||
function validateQuestionBank(questions, { paper, questionCount = 15 } = {}) {
|
||||
if (!Number.isSafeInteger(questionCount) || questionCount < 1) {
|
||||
throw new Error("Expected question count must be a positive integer");
|
||||
}
|
||||
if (!Array.isArray(questions)) return ["Question bank must be an array"];
|
||||
if (!questions.length && !paper) {
|
||||
return ["Question bank must contain at least one question"];
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
const papers = new Map();
|
||||
// An explicitly requested paper must be present, even if no rows match it.
|
||||
if (paper) papers.set(paper, new Map());
|
||||
|
||||
for (const question of questions) {
|
||||
const source = question?.source;
|
||||
const match =
|
||||
typeof source === "string" &&
|
||||
source.match(/^(\d{4}\/[^/\s]+)\/([1-9]\d*)$/);
|
||||
if (!match || !Number.isSafeInteger(Number(match[2]))) {
|
||||
errors.push(`Invalid source identifier: ${JSON.stringify(source)}`);
|
||||
continue;
|
||||
}
|
||||
const paperId = match[1];
|
||||
if (paper && paperId !== paper) continue;
|
||||
|
||||
if (!papers.has(paperId)) papers.set(paperId, new Map());
|
||||
const numbers = papers.get(paperId);
|
||||
const number = Number(match[2]);
|
||||
if (numbers.has(number)) {
|
||||
errors.push(
|
||||
`Duplicate source ${source} (question IDs ${numbers.get(number)} and ${question.id})`,
|
||||
);
|
||||
} else {
|
||||
numbers.set(number, question.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [paperId, numbers] of papers) {
|
||||
const missing = [];
|
||||
for (let number = 1; number <= questionCount; number++) {
|
||||
if (!numbers.has(number)) missing.push(number);
|
||||
}
|
||||
if (missing.length) {
|
||||
errors.push(`${paperId}: missing question numbers ${missing.join(", ")}`);
|
||||
}
|
||||
const unexpected = [...numbers.keys()]
|
||||
.filter((number) => number > questionCount)
|
||||
.sort((a, b) => a - b);
|
||||
if (unexpected.length) {
|
||||
errors.push(
|
||||
`${paperId}: unexpected question numbers ${unexpected.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
const { values, positionals } = require("node:util").parseArgs({
|
||||
allowPositionals: true,
|
||||
options: {
|
||||
paper: { type: "string" },
|
||||
count: { type: "string", default: "15" },
|
||||
},
|
||||
});
|
||||
const dataPath = positionals[0] || process.env.DATA_PATH;
|
||||
if (!dataPath || positionals.length > 1) {
|
||||
throw new Error(
|
||||
"Usage: node validate-question-bank.js <path> [--paper YYYY/session] [--count 15]",
|
||||
);
|
||||
}
|
||||
const questions = JSON.parse(
|
||||
require("node:fs").readFileSync(dataPath, "utf8"),
|
||||
);
|
||||
const errors = validateQuestionBank(questions, {
|
||||
paper: values.paper,
|
||||
questionCount: Number(values.count),
|
||||
});
|
||||
if (errors.length) {
|
||||
console.error(errors.join("\n"));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
`Question bank validation passed (${values.paper || "all papers"}; ${values.count} questions per paper).`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { validateQuestionBank };
|
||||
91
backend/validate-question-bank.test.js
Normal file
91
backend/validate-question-bank.test.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { validateQuestionBank } = require("./validate-question-bank");
|
||||
|
||||
const completePaper = (paper = "2025/2", count = 15) =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: 683 + i,
|
||||
source: `${paper}/${i + 1}`,
|
||||
}));
|
||||
|
||||
test("complete papers have unique sources and every expected question", () => {
|
||||
assert.deepEqual(
|
||||
validateQuestionBank([...completePaper(), ...completePaper("2016/m1")]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("the October 2025 mislabel reports both duplicate 5 and missing 6", () => {
|
||||
const questions = completePaper();
|
||||
questions[5].source = "2025/2/5";
|
||||
const errors = validateQuestionBank(questions);
|
||||
assert.equal(errors.length, 2);
|
||||
assert.match(errors[0], /Duplicate source 2025\/2\/5.*687 and 688/);
|
||||
assert.match(errors[1], /2025\/2: missing question numbers 6$/);
|
||||
|
||||
questions[5].source = "2025/2/6";
|
||||
assert.deepEqual(validateQuestionBank(questions), []);
|
||||
});
|
||||
|
||||
test("completeness includes missing first and last questions", () => {
|
||||
const errors = validateQuestionBank(completePaper().slice(1, -1));
|
||||
assert.deepEqual(errors, ["2025/2: missing question numbers 1, 15"]);
|
||||
});
|
||||
|
||||
test("unexpected question numbers fail validation", () => {
|
||||
assert.deepEqual(validateQuestionBank(completePaper("2025/2", 16)), [
|
||||
"2025/2: unexpected question numbers 16",
|
||||
]);
|
||||
});
|
||||
|
||||
test("paper checks ignore other papers but detect an entirely missing import", () => {
|
||||
assert.deepEqual(
|
||||
validateQuestionBank([...completePaper(), { id: 1, source: "2024/2/1" }], {
|
||||
paper: "2025/2",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
assert.match(
|
||||
validateQuestionBank([], { paper: "2025/2" })[0],
|
||||
/2025\/2: missing question numbers 1, 2, 3.*15$/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the expected count is explicit, so shorter imports cannot appear complete", () => {
|
||||
assert.deepEqual(
|
||||
validateQuestionBank(completePaper("2025/2", 3), { questionCount: 3 }),
|
||||
[],
|
||||
);
|
||||
assert.match(
|
||||
validateQuestionBank(completePaper("2025/2", 3))[0],
|
||||
/missing question numbers 4, 5, 6.*15$/,
|
||||
);
|
||||
});
|
||||
|
||||
test("malformed sources and invalid banks fail validation", () => {
|
||||
for (const source of [
|
||||
undefined,
|
||||
null,
|
||||
"2025/2/0",
|
||||
"2025/2/1.5",
|
||||
"2025/2/01",
|
||||
"2025/2/1 ",
|
||||
]) {
|
||||
assert.match(
|
||||
validateQuestionBank([{ source }])[0],
|
||||
/Invalid source identifier/,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(validateQuestionBank({}), [
|
||||
"Question bank must be an array",
|
||||
]);
|
||||
assert.deepEqual(validateQuestionBank([]), [
|
||||
"Question bank must contain at least one question",
|
||||
]);
|
||||
for (const questionCount of [0, -1, 1.5, NaN, Infinity]) {
|
||||
assert.throws(
|
||||
() => validateQuestionBank([], { questionCount }),
|
||||
/positive integer/,
|
||||
);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue