From ea84a911f4aef8c2e1c40bd1822fa17ca03e48f0 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Wed, 28 Oct 2026 11:46:45 +0000 Subject: [PATCH] AI bugbash --- backend/package.json | 1 + backend/server.js | 16 ++++- backend/server.test.js | 56 +++++++++++++++ backend/validate-question-bank.js | 98 ++++++++++++++++++++++++++ backend/validate-question-bank.test.js | 91 ++++++++++++++++++++++++ frontend/css/card.css | 16 ++++- frontend/css/radio.css | 5 +- frontend/index.html | 56 ++++++--------- frontend/js/fizika.js | 32 +++++++-- frontend/js/load.js | 74 ++++++++++++++----- 10 files changed, 384 insertions(+), 61 deletions(-) create mode 100644 backend/validate-question-bank.js create mode 100644 backend/validate-question-bank.test.js diff --git a/backend/package.json b/backend/package.json index 5cc3a4c..b9e6c62 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,6 +6,7 @@ "scripts": { "start": "node server.js", "dev": "nodemon server.js", + "validate:data": "node validate-question-bank.js", "test": "node --test" }, "dependencies": { diff --git a/backend/server.js b/backend/server.js index 3938d95..8efafa8 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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]); diff --git a/backend/server.test.js b/backend/server.test.js index 9e16e7f..edfeb2a 100644 --- a/backend/server.test.js +++ b/backend/server.test.js @@ -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); diff --git a/backend/validate-question-bank.js b/backend/validate-question-bank.js new file mode 100644 index 0000000..4217f35 --- /dev/null +++ b/backend/validate-question-bank.js @@ -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 [--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 }; diff --git a/backend/validate-question-bank.test.js b/backend/validate-question-bank.test.js new file mode 100644 index 0000000..2b7068b --- /dev/null +++ b/backend/validate-question-bank.test.js @@ -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/, + ); + } +}); diff --git a/frontend/css/card.css b/frontend/css/card.css index ffa3408..8e2c64a 100644 --- a/frontend/css/card.css +++ b/frontend/css/card.css @@ -18,7 +18,18 @@ h2 { text-align: center; color: #0a0f14; } -pre { +.card fieldset { + border: 0; + padding: 0; + margin: 1.5em 0 0; + min-width: 0; +} +.card legend { + padding: 0; + margin-bottom: 1em; +} +pre, +.card legend { text-align: justify; font-family: "Open sans", sans-serif; font-weight: 300; @@ -45,7 +56,8 @@ img { h2 { font-size: 1.25rem; } - pre { + pre, + .card legend { font-size: 1rem; line-height: 1.6; text-align: left; diff --git a/frontend/css/radio.css b/frontend/css/radio.css index 5497b23..eaad798 100644 --- a/frontend/css/radio.css +++ b/frontend/css/radio.css @@ -12,6 +12,7 @@ input[type="radio"] { opacity: 0; } input[type="radio"] + label { + cursor: pointer; display: inline; font-family: "Open Sans", sans-serif; font-weight: 300; @@ -29,12 +30,12 @@ input[type="radio"]:checked + label { } @media only screen and (max-width: 1000px) { - .card form { + .card .answers { display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 0.75em 0.5em; } - .card form br { + .card .answers br { display: none; } input[type="radio"] { diff --git a/frontend/index.html b/frontend/index.html index e217d2d..e4a5f5e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -135,94 +135,98 @@
- +

- +

- +

- +

- +

- +

- +

- +

- +

- +

- +

- +

- +

- +

- +

- +

- +

- +

@@ -260,21 +264,7 @@
diff --git a/frontend/js/fizika.js b/frontend/js/fizika.js index 28f002f..a220fad 100644 --- a/frontend/js/fizika.js +++ b/frontend/js/fizika.js @@ -14,6 +14,22 @@ function aspect() { } async function ajaxLoad(type) { + let topicQuestionCount = 15; + if (type != 1 && type != 2) { + const countInput = document.getElementById("numberof"); + // Keep the empty-field default, but reject incomplete numeric input. + topicQuestionCount = + countInput.value === "" && !countInput.validity.badInput + ? 15 + : countInput.valueAsNumber; + if (!isValidQuestionCount(topicQuestionCount)) { + countInput.setCustomValidity(QUESTION_COUNT_ERROR); + countInput.reportValidity(); + return; + } + countInput.setCustomValidity(""); + } + reviewMode = 0; totalPoints = 0; loadedQuestions = []; @@ -61,7 +77,6 @@ async function ajaxLoad(type) { 15, ); } else { - var NOQ = $("#numberof").val() ? $("#numberof").val() : 15; categories = [ $("#mk").prop("checked") ? "mk" : "", $("#md").prop("checked") ? "md" : "", @@ -79,7 +94,12 @@ async function ajaxLoad(type) { $("#cs").prop("checked") ? "cs" : "", $("#v").prop("checked") ? "v" : "", ]; - result = await loadQuestions(false, categories, undefined, NOQ); + result = await loadQuestions( + false, + categories, + undefined, + topicQuestionCount, + ); } $("#loadingGif").hide(); @@ -121,7 +141,7 @@ async function ajaxLoad(type) { // its correct answer, and report whether they got it right. function gradeQuestion(question) { var div = "#feladat" + question.id; - var selected = "#form" + question.id + " #rad" + question.correct; + var selected = `#question-${question.id}-answer-${question.correct}`; var isCorrect = $(selected).is(":checked"); $(div).animate({ backgroundColor: isCorrect ? "#C6FF8C" : "#FF808C" }, 1100); return isCorrect; @@ -163,7 +183,7 @@ function showCorrect() { reviewMode = 1; var percentage = scoreLoadedQuestions(); loadedQuestions.forEach(function (question) { - $("#label" + question.id + ".rad" + question.correct).css( + $(`label[for="question-${question.id}-answer-${question.correct}"]`).css( "background-color", "#C6FF8C", ); @@ -315,6 +335,7 @@ $(document).ready(function () { initializeMonthDropdown($("#evszam").val()); $("#honap").val("all"); }); + $("#honap").change(initializeQuestionDropdown); $("#mec").change(function () { var isChecked = this.checked; @@ -338,6 +359,9 @@ $(document).ready(function () { $("#berettsegi").click(function (event) { ajaxLoad(2); }); + $("#numberof").on("input", function () { + this.setCustomValidity(""); + }); $("#load").click(function (event) { ajaxLoad(isSearch ? 1 : 3); }); diff --git a/frontend/js/load.js b/frontend/js/load.js index 438bac0..7ed43f1 100644 --- a/frontend/js/load.js +++ b/frontend/js/load.js @@ -100,29 +100,25 @@ const loadQuestions = async ( ({ id, source, description, a, b, c, d, image }, i) => { const qid = Number(id); const safeImage = image ? encodeURIComponent(image) : ""; + const answersHtml = [a, b, c, ...(d ? [d] : [])] + .map((answer, answerIndex) => { + const answerNumber = answerIndex + 1; + const answerId = `question-${qid}-answer-${answerNumber}`; + return ` + + +
`; + }) + .join(""); resultHtml += `

${i + 1}.

${sanitizeQuestionHtml(source)}

-
${sanitizeQuestionHtml(description)}
- ${image ? `
` : ""}
- - -
- - -
- - -
- ${ - d - ? ` - - -
` - : "" - } +
+ ${sanitizeQuestionHtml(description)} + ${image ? `
` : ""} +
${answersHtml}
+
`; @@ -281,6 +277,45 @@ const initializeMonthDropdown = (selectedYear) => { } monthDropdown.innerHTML = monthOptions; + initializeQuestionDropdown(); +}; + +// Offer only imported question numbers matching the selected year and paper. +// With an "all" filter, use the union across the matching papers. +const initializeQuestionDropdown = () => { + if (!questions) return; + + const questionDropdown = document.getElementById("feladat"); + if (!questionDropdown) return; + + const selectedYear = document.getElementById("evszam").value; + const selectedPaper = document.getElementById("honap").value; + const previousQuestion = questionDropdown.value; + const questionNumbers = new Set(); + + questions.forEach(({ source }) => { + const match = source.match(/^(\d{4})\/([^/\s]+)\/([1-9]\d*)$/); + if ( + match && + (selectedYear === "all/" || selectedYear === `${match[1]}/`) && + (selectedPaper === "all" || selectedPaper === match[2]) && + Number.isSafeInteger(Number(match[3])) + ) { + questionNumbers.add(Number(match[3])); + } + }); + + const numberOptions = [...questionNumbers] + .sort((a, b) => a - b) + .map((number) => ``) + .join(""); + questionDropdown.innerHTML = + '' + numberOptions; + + // Retain a still-valid selection; clear it when the new paper lacks it. + if (questionNumbers.has(Number(previousQuestion.slice(1)))) { + questionDropdown.value = previousQuestion; + } }; // Helper function to get readable month labels @@ -288,6 +323,7 @@ const getMonthLabel = (monthValue) => { // Handle known patterns const knownLabels = { 1: "Május-Június", + "1ma": "Május-Június (magyar, mint idegen nyelv)", 2: "Október-November", 3: "Harmadik időszak", m1: "1. Mintafeladatsor",