369 lines
14 KiB
JavaScript
369 lines
14 KiB
JavaScript
import { coursesAPI } from '../api/courses.js';
|
|
|
|
export class TestViewer {
|
|
constructor(container, stepId) {
|
|
this.container = container;
|
|
this.stepId = stepId;
|
|
this.questions = [];
|
|
this.settings = {};
|
|
this.attempts = [];
|
|
this.currentQuestionIndex = 0;
|
|
this.answers = {};
|
|
this.timeRemaining = 0;
|
|
this.timerInterval = null;
|
|
this.isSubmitted = false;
|
|
}
|
|
|
|
async load() {
|
|
try {
|
|
const response = await coursesAPI.getTestForViewing(this.stepId);
|
|
if (response) {
|
|
this.questions = response.questions || [];
|
|
this.settings = response.settings || {};
|
|
this.attempts = response.attempts || [];
|
|
|
|
// Initialize answers object
|
|
this.questions.forEach(q => {
|
|
if (q.question_type === 'single_choice') {
|
|
this.answers[q.id] = null;
|
|
} else if (q.question_type === 'multiple_choice') {
|
|
this.answers[q.id] = [];
|
|
} else if (q.question_type === 'text_answer') {
|
|
this.answers[q.id] = '';
|
|
} else if (q.question_type === 'match') {
|
|
this.answers[q.id] = {};
|
|
}
|
|
});
|
|
|
|
// Start timer if time limit is set
|
|
if (this.settings.time_limit > 0) {
|
|
this.timeRemaining = this.settings.time_limit * 60;
|
|
this.startTimer();
|
|
}
|
|
|
|
this.render();
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load test:', error);
|
|
this.showError('Ошибка загрузки теста: ' + error.message);
|
|
}
|
|
}
|
|
|
|
startTimer() {
|
|
this.updateTimerDisplay();
|
|
this.timerInterval = setInterval(() => {
|
|
this.timeRemaining--;
|
|
this.updateTimerDisplay();
|
|
|
|
if (this.timeRemaining <= 0) {
|
|
clearInterval(this.timerInterval);
|
|
this.submitTest();
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
updateTimerDisplay() {
|
|
const timerEl = document.getElementById('test-timer');
|
|
if (timerEl) {
|
|
const minutes = Math.floor(this.timeRemaining / 60);
|
|
const seconds = this.timeRemaining % 60;
|
|
timerEl.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
|
|
if (this.timeRemaining < 300) { // Less than 5 minutes
|
|
timerEl.classList.add('warning');
|
|
}
|
|
}
|
|
}
|
|
|
|
render() {
|
|
if (this.isSubmitted) {
|
|
return this.renderResults();
|
|
}
|
|
|
|
const question = this.questions[this.currentQuestionIndex];
|
|
|
|
this.container.innerHTML = `
|
|
<div class="test-viewer">
|
|
<div class="test-header">
|
|
<div class="test-info">
|
|
<span class="question-counter">Вопрос ${this.currentQuestionIndex + 1} из ${this.questions.length}</span>
|
|
${this.settings.time_limit > 0 ? `
|
|
<span class="test-timer" id="test-timer">
|
|
${Math.floor(this.timeRemaining / 60)}:${(this.timeRemaining % 60).toString().padStart(2, '0')}
|
|
</span>
|
|
` : ''}
|
|
</div>
|
|
<div class="test-progress">
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" style="width: ${((this.currentQuestionIndex + 1) / this.questions.length * 100)}%"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="question-container">
|
|
${this.renderQuestion(question)}
|
|
</div>
|
|
|
|
<div class="test-navigation">
|
|
<button class="btn btn-outline" onclick="window.prevQuestion()" ${this.currentQuestionIndex === 0 ? 'disabled' : ''}>
|
|
← Назад
|
|
</button>
|
|
|
|
<div class="question-dots">
|
|
${this.questions.map((q, idx) => `
|
|
<button class="dot ${idx === this.currentQuestionIndex ? 'active' : ''} ${this.answers[q.id] ? 'answered' : ''}"
|
|
onclick="window.goToQuestion(${idx})">
|
|
${idx + 1}
|
|
</button>
|
|
`).join('')}
|
|
</div>
|
|
|
|
${this.currentQuestionIndex < this.questions.length - 1 ? `
|
|
<button class="btn btn-primary" onclick="window.nextQuestion()">
|
|
Далее →
|
|
</button>
|
|
` : `
|
|
<button class="btn btn-success" onclick="window.submitTest()">
|
|
Завершить тест
|
|
</button>
|
|
`}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
renderQuestion(question) {
|
|
if (!question) return '<p>Вопрос не найден</p>';
|
|
|
|
return `
|
|
<div class="question" data-question-id="${question.id}">
|
|
<div class="question-header">
|
|
<span class="question-points">${question.points} балл(ов)</span>
|
|
</div>
|
|
<div class="question-text">${question.question_text}</div>
|
|
|
|
<div class="question-answers">
|
|
${this.renderAnswers(question)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
renderAnswers(question) {
|
|
switch (question.question_type) {
|
|
case 'single_choice':
|
|
return this.renderSingleChoice(question);
|
|
case 'multiple_choice':
|
|
return this.renderMultipleChoice(question);
|
|
case 'text_answer':
|
|
return this.renderTextAnswer(question);
|
|
case 'match':
|
|
return this.renderMatch(question);
|
|
default:
|
|
return '<p>Неизвестный тип вопроса</p>';
|
|
}
|
|
}
|
|
|
|
renderSingleChoice(question) {
|
|
return `
|
|
<div class="answers-list single-choice">
|
|
${question.answers.map(answer => `
|
|
<label class="answer-option ${this.answers[question.id] === answer.id ? 'selected' : ''}">
|
|
<input type="radio" name="question-${question.id}" value="${answer.id}"
|
|
${this.answers[question.id] === answer.id ? 'checked' : ''}
|
|
onchange="window.selectAnswer(${question.id}, ${answer.id})">
|
|
<span class="answer-text">${answer.answer_text}</span>
|
|
</label>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
renderMultipleChoice(question) {
|
|
return `
|
|
<div class="answers-list multiple-choice">
|
|
${question.answers.map(answer => `
|
|
<label class="answer-option ${this.answers[question.id]?.includes(answer.id) ? 'selected' : ''}">
|
|
<input type="checkbox" value="${answer.id}"
|
|
${this.answers[question.id]?.includes(answer.id) ? 'checked' : ''}
|
|
onchange="window.toggleAnswer(${question.id}, ${answer.id})">
|
|
<span class="answer-text">${answer.answer_text}</span>
|
|
</label>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
renderTextAnswer(question) {
|
|
return `
|
|
<div class="text-answer">
|
|
<textarea class="form-control" rows="4"
|
|
placeholder="Введите ваш ответ..."
|
|
onchange="window.setTextAnswer(${question.id}, this.value)">${this.answers[question.id] || ''}</textarea>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
renderMatch(question) {
|
|
// Shuffle right items for display
|
|
const rightItems = [...question.match_items].sort(() => Math.random() - 0.5);
|
|
|
|
return `
|
|
<div class="match-question">
|
|
<div class="match-columns">
|
|
<div class="match-column left-column">
|
|
${question.match_items.map(item => `
|
|
<div class="match-item" data-id="${item.id}">
|
|
<span>${item.left_text}</span>
|
|
<select onchange="window.selectMatch(${question.id}, ${item.id}, this.value)">
|
|
<option value="">Выберите...</option>
|
|
${rightItems.map(r => `
|
|
<option value="${r.right_text}" ${this.answers[question.id]?.[item.id] === r.right_text ? 'selected' : ''}>
|
|
${r.right_text}
|
|
</option>
|
|
`).join('')}
|
|
</select>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
renderResults() {
|
|
const lastAttempt = this.attempts[0];
|
|
if (!lastAttempt) return '<p>Результаты не найдены</p>';
|
|
|
|
const percentage = Math.round((lastAttempt.score / lastAttempt.max_score) * 100);
|
|
|
|
return `
|
|
<div class="test-results">
|
|
<div class="results-header ${lastAttempt.passed ? 'passed' : 'failed'}">
|
|
<h2>${lastAttempt.passed ? '✓ Тест пройден!' : '✗ Тест не пройден'}</h2>
|
|
<div class="score-circle">
|
|
<span class="score-value">${percentage}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="results-details">
|
|
<p>Набрано баллов: ${lastAttempt.score} из ${lastAttempt.max_score}</p>
|
|
<p>Проходной балл: ${this.settings.passing_score}%</p>
|
|
</div>
|
|
|
|
${this.settings.max_attempts > 0 ? `
|
|
<p class="attempts-info">Попыток использовано: ${this.attempts.length} из ${this.settings.max_attempts}</p>
|
|
` : ''}
|
|
|
|
${!lastAttempt.passed && (this.settings.max_attempts === 0 || this.attempts.length < this.settings.max_attempts) ? `
|
|
<button class="btn btn-primary" onclick="window.location.reload()">
|
|
Попробовать снова
|
|
</button>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
showError(message) {
|
|
this.container.innerHTML = `
|
|
<div class="test-error">
|
|
<p>${message}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async submitTest() {
|
|
if (this.timerInterval) {
|
|
clearInterval(this.timerInterval);
|
|
}
|
|
|
|
// Validate all questions are answered
|
|
const unanswered = this.questions.filter(q => {
|
|
const answer = this.answers[q.id];
|
|
if (q.question_type === 'single_choice') return answer === null;
|
|
if (q.question_type === 'multiple_choice') return answer.length === 0;
|
|
if (q.question_type === 'text_answer') return !answer.trim();
|
|
if (q.question_type === 'match') return Object.keys(answer).length === 0;
|
|
return false;
|
|
});
|
|
|
|
if (unanswered.length > 0 && !confirm(`Осталось ${unanswered.length} без ответа. Продолжить?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await coursesAPI.submitTest(this.stepId, this.answers);
|
|
if (response && response.attempt) {
|
|
this.attempts.unshift(response.attempt);
|
|
this.isSubmitted = true;
|
|
this.container.innerHTML = this.renderResults();
|
|
}
|
|
} catch (error) {
|
|
alert('Ошибка отправки теста: ' + error.message);
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
if (this.timerInterval) {
|
|
clearInterval(this.timerInterval);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Global functions
|
|
window.prevQuestion = function() {
|
|
if (!window.currentTestViewer) return;
|
|
if (window.currentTestViewer.currentQuestionIndex > 0) {
|
|
window.currentTestViewer.currentQuestionIndex--;
|
|
window.currentTestViewer.render();
|
|
}
|
|
};
|
|
|
|
window.nextQuestion = function() {
|
|
if (!window.currentTestViewer) return;
|
|
if (window.currentTestViewer.currentQuestionIndex < window.currentTestViewer.questions.length - 1) {
|
|
window.currentTestViewer.currentQuestionIndex++;
|
|
window.currentTestViewer.render();
|
|
}
|
|
};
|
|
|
|
window.goToQuestion = function(index) {
|
|
if (!window.currentTestViewer) return;
|
|
window.currentTestViewer.currentQuestionIndex = index;
|
|
window.currentTestViewer.render();
|
|
};
|
|
|
|
window.selectAnswer = function(questionId, answerId) {
|
|
if (!window.currentTestViewer) return;
|
|
window.currentTestViewer.answers[questionId] = answerId;
|
|
};
|
|
|
|
window.toggleAnswer = function(questionId, answerId) {
|
|
if (!window.currentTestViewer) return;
|
|
const answers = window.currentTestViewer.answers[questionId];
|
|
const index = answers.indexOf(answerId);
|
|
if (index > -1) {
|
|
answers.splice(index, 1);
|
|
} else {
|
|
answers.push(answerId);
|
|
}
|
|
};
|
|
|
|
window.setTextAnswer = function(questionId, text) {
|
|
if (!window.currentTestViewer) return;
|
|
window.currentTestViewer.answers[questionId] = text;
|
|
};
|
|
|
|
window.selectMatch = function(questionId, leftId, rightValue) {
|
|
if (!window.currentTestViewer) return;
|
|
if (!window.currentTestViewer.answers[questionId]) {
|
|
window.currentTestViewer.answers[questionId] = {};
|
|
}
|
|
window.currentTestViewer.answers[questionId][leftId] = rightValue;
|
|
};
|
|
|
|
window.submitTest = function() {
|
|
if (!window.currentTestViewer) return;
|
|
window.currentTestViewer.submitTest();
|
|
};
|