Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions api/exam.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
mathrand "math/rand"
"net/http"
"strconv"
"strings"
"time"

"github.com/ZipCodeCore/zip-athena/database"
Expand Down Expand Up @@ -320,6 +321,7 @@ func (h *Handler) StartExamHandler(w http.ResponseWriter, r *http.Request) {
type examQuestion struct {
ID int `json:"id"`
QuestionText string `json:"question_text"`
Category string `json:"category"`
Difficulty int `json:"difficulty"`
Answers []examAnswer `json:"answers"`
}
Expand All @@ -341,9 +343,22 @@ func (h *Handler) StartExamHandler(w http.ResponseWriter, r *http.Request) {
for i, a := range answers {
eas[i] = examAnswer{ID: a.ID, AnswerText: a.AnswerText}
}
categoryLabel := ""
if topic, err := h.db.GetTopic(q.TopicID); err == nil {
categoryLabel = strings.TrimSpace(topic.Name)
if categoryLabel == "" {
categoryLabel = strings.TrimSpace(topic.Category)
}
} else {
h.logger.WithContext(r.Context()).WithError(err).WithField("question_id", q.ID).WithField("topic_id", q.TopicID).Warn("Failed to load topic for exam question")
}
if categoryLabel == "" && len(q.Tags) > 0 {
categoryLabel = strings.TrimSpace(q.Tags[0])
}
questions = append(questions, examQuestion{
ID: q.ID,
QuestionText: q.QuestionText,
Category: categoryLabel,
Difficulty: q.Difficulty,
Answers: eas,
})
Expand Down
11 changes: 11 additions & 0 deletions api/exam_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,17 @@ func TestStartExamHandler(t *testing.T) {
if result["questions"] == nil {
t.Error("Expected questions in response")
}
questionsResult, ok := result["questions"].([]interface{})
if !ok || len(questionsResult) == 0 {
t.Fatalf("Expected at least one question in response, got %v", result["questions"])
}
firstQuestion, ok := questionsResult[0].(map[string]interface{})
if !ok {
t.Fatalf("Expected first question to be an object, got %T", questionsResult[0])
}
if firstQuestion["category"] != "Java Basics" {
t.Errorf("Expected question category %q, got %v", "Java Basics", firstQuestion["category"])
}
}

func TestStartExamHandler_NotAssigned(t *testing.T) {
Expand Down
131 changes: 109 additions & 22 deletions static/exam.html
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,90 @@ <h3 class="mb-0" id="resultTitle">Exam Complete!</h3>
.replace(/'/g, '&#039;');
}

function renderQuestionText(container, text) {
container.innerHTML = '';

const codeBlockRegex = /```(\w+)?\r?\n?([\s\S]*?)```/g;
let lastIndex = 0;
let match;
let foundCodeBlock = false;

while ((match = codeBlockRegex.exec(text)) !== null) {
foundCodeBlock = true;
if (match.index > lastIndex) {
const preamble = text.substring(lastIndex, match.index).trim();
if (preamble) {
const p = document.createElement('p');
p.className = 'question-preamble mb-2';
p.textContent = preamble;
container.appendChild(p);
}
}

const language = match[1] || '';
const code = match[2].replace(/^(?:\r?\n)+/, '').trimEnd();
const pre = document.createElement('pre');
pre.className = 'code-block';
const codeEl = document.createElement('code');
codeEl.className = language ? `language-${language}` : '';
codeEl.textContent = code;
pre.appendChild(codeEl);
container.appendChild(pre);

lastIndex = match.index + match[0].length;
}

if (!foundCodeBlock) {
container.textContent = text;
return;
}

if (lastIndex < text.length) {
const remaining = text.substring(lastIndex).trim();
if (remaining) {
const p = document.createElement('p');
p.className = 'question-preamble mb-2';
p.textContent = remaining;
container.appendChild(p);
}
}
}

function getQuestionCategory(question) {
return question && question.category ? question.category : '';
}

function appendQuestionContent(container, question, index, icon) {
const header = document.createElement('div');
header.className = 'd-flex flex-wrap align-items-center gap-2 mb-2';

const numberBadge = document.createElement('span');
numberBadge.className = 'badge bg-secondary';
numberBadge.textContent = index + 1;
header.appendChild(numberBadge);

const category = getQuestionCategory(question);
if (category) {
const categoryBadge = document.createElement('span');
categoryBadge.className = 'badge rounded-pill bg-info text-dark';
categoryBadge.textContent = category;
header.appendChild(categoryBadge);
}

if (icon) {
const iconSpan = document.createElement('span');
iconSpan.textContent = icon;
header.appendChild(iconSpan);
}

container.appendChild(header);

const questionText = document.createElement('div');
questionText.className = 'mb-3';
renderQuestionText(questionText, question.question_text || '');
container.appendChild(questionText);
}

// ============ Render Exam ============
// Questions from API: { id, question_text, difficulty, answers: [{id, answer_text}] }
function renderExam() {
Expand Down Expand Up @@ -247,13 +331,11 @@ <h3 class="mb-0" id="resultTitle">Exam Complete!</h3>

div.innerHTML = `
<div class="card-body">
<p class="mb-2">
<span class="badge bg-secondary me-2">${idx + 1}</span>
<strong>${escapeHtml(q.question_text)}</strong>
</p>
<div class="question-content"></div>
${answersHtml}
</div>
`;
appendQuestionContent(div.querySelector('.question-content'), q, idx);
container.appendChild(div);
});

Expand Down Expand Up @@ -353,7 +435,16 @@ <h3 class="mb-0" id="resultTitle">Exam Complete!</h3>
// Question-by-question breakdown using the original exam question data
const qResults = result.question_results || {};
if (examData && examData.questions && examData.questions.length > 0) {
let html = '<h5 class="text-start mb-3">Question Breakdown</h5><div class="text-start">';
const questionResultsEl = document.getElementById('questionResults');
questionResultsEl.innerHTML = '';

const title = document.createElement('h5');
title.className = 'text-start mb-3';
title.textContent = 'Question Breakdown';
questionResultsEl.appendChild(title);

const resultsList = document.createElement('div');
resultsList.className = 'text-start';
examData.questions.forEach((q, idx) => {
const isCorrect = qResults[String(q.id)] === true;
const icon = isCorrect ? '✅' : '❌';
Expand All @@ -363,25 +454,21 @@ <h3 class="mb-0" id="resultTitle">Exam Complete!</h3>
const selectedEl = document.querySelector(`input[name="q_${q.id}"]:checked`);
const yourAnswerText = selectedEl ? selectedEl.dataset.answerText : '(not answered)';

// Find the correct answer text from the answers list
let correctAnswerText = '';
if (!isCorrect && q.answers) {
// We don't know which answer is correct from the client side without extra API call
// Just show what they answered
}
const card = document.createElement('div');
card.className = `card mb-2 ${cardClass}`;
const cardBody = document.createElement('div');
cardBody.className = 'card-body py-2 px-3';
appendQuestionContent(cardBody, q, idx, icon);

html += `
<div class="card mb-2 ${cardClass}">
<div class="card-body py-2 px-3">
<p class="mb-1"><span class="badge bg-secondary me-2">${idx + 1}</span>
${icon} <strong>${escapeHtml(q.question_text)}</strong></p>
<p class="mb-0 small">Your answer: <span class="${isCorrect ? 'text-success' : 'text-danger'}">${escapeHtml(yourAnswerText)}</span></p>
</div>
</div>
`;
const answer = document.createElement('p');
answer.className = 'mb-0 small';
answer.innerHTML = `Your answer: <span class="${isCorrect ? 'text-success' : 'text-danger'}">${escapeHtml(yourAnswerText)}</span>`;
cardBody.appendChild(answer);

card.appendChild(cardBody);
resultsList.appendChild(card);
});
html += '</div>';
document.getElementById('questionResults').innerHTML = html;
questionResultsEl.appendChild(resultsList);
}

showPanel('resultsPanel');
Expand Down
Loading