1/**
2 * Class that represents an HTML Quiz. Provides methods for checking answers, generating a score and providing visual feedback.
3 *
4 * See https://alpsquid.github.io/quizlib for usage
5 *
6 * @class Quiz
7 * @constructor
8 * @param {String} quizContainer ID of the quiz container element.
9 * @param {Array} answers Array of correct answers using the input value. e.g. ['a', '7', ['a', 'b']].
10 *        Can use nested arrays for multi-answers such as checkbox questions
11 * @example
12 * 		new Quiz('quiz-div', ['a', '7', ['c', 'd'], 'squids', ['a', 'b']]);
13 */
14var Quiz = function(quizContainer, answers) {
15	/**
16	 * Enum containing classes used by QuizLib as follows:
17	 * - **QUESTION**: 'quizlib-question'
18	 *   - used to identify a question element
19	 * - **QUESTION_TITLE**: 'quizlib-question-title'
20	 *   - used to identify the question title element
21	 * - **QUESTION_WARNING**: 'quizlib-question-answers'
22	 *   - used to identify the element containing question answers
23	 * - **QUESTION_ANSWERS**: 'quizlib-question-warning'
24	 *   - used by the 'unanswered question warning' element. Removed by {{#crossLink "Quiz/clearHighlights:method"}}{{/crossLink}}
25	 * - **CORRECT**: 'quizlib-correct'
26	 *   - added to question titles to highlight correctly answered questions.
27	 *     Use freely to take advantage of {{#crossLink "Quiz/highlightResults:method"}}{{/crossLink}} and {{#crossLink "Quiz/clearHighlights:method"}}{{/crossLink}}
28	 * - **INCORRECT**: 'quizlib-incorrect'
29	 *   - added to question titles to highlight incorrectly answered questions.
30	 *     Use freely to take advantage of {{#crossLink "Quiz/highlightResults:method"}}{{/crossLink}} and {{#crossLink "Quiz/clearHighlights:method"}}{{/crossLink}}
31	 * - **TEMP**: 'quizlib-temp'
32	 *   - Add to any elements you want to be removed by {{#crossLink "Quiz/clearHighlights:method"}}{{/crossLink}} (called by {{#crossLink "Quiz/checkAnswers:method"}}{{/crossLink}}).
33	 *     For example, adding an element with the correct answer in your {{#crossLink "Quiz/clearHighlights:method"}}{{/crossLink}} callback and have it be removed automatically
34	 *
35	 * @property Classes
36	 * @type Object
37	 * @default See above
38	 * @final
39	 */
40	this.Classes = Object.freeze({
41		QUESTION: "quizlib-question",
42		QUESTION_TITLE: "quizlib-question-title",
43		QUESTION_ANSWERS: "quizlib-question-answers",
44		QUESTION_WARNING: "quizlib-question-warning",
45		CORRECT: "quizlib-correct",
46		INCORRECT: "quizlib-incorrect",
47		TEMP: "quizlib-temp"
48	});
49
50	/**
51	 * Warning displayed on unanswered questions
52	 *
53	 * @property unansweredQuestionText
54	 * @type String
55	 * @default 'Unanswered Question!'
56	 */
57	this.unansweredQuestionText = 'Unanswered Question!';
58
59	// Quiz container element
60	this.container = document.getElementById(quizContainer);
61	this.questions = [];
62	/**
63	 * Quiz Result object containing quiz score information. See {{#crossLink "QuizResult"}}{{/crossLink}}
64	 *
65	 * @property result
66	 * @type QuizResult
67	 */
68	this.result = new QuizResult();
69	/**
70	 * User defined answers taken from constructor
71	 *
72	 * @property answers
73	 * @type Array
74	 */
75	this.answers = answers;
76
77	// Get all the questions and add element to the questions array
78	for (var i=0; i < this.container.children.length; i++) {
79		if (this.container.children[i].classList.contains(this.Classes.QUESTION)) {
80			this.questions.push(this.container.children[i]);
81		}
82	}
83
84	if (this.answers.length != this.questions.length) {
85		throw new Error("Number of answers does not match number of questions!");
86	}
87};
88
89/**
90 * Checks quiz answers against provided answers. Calls {{#crossLink "Quiz/clearHighlights:method"}}{{/crossLink}} for each question.
91 *
92 * @method checkAnswers
93 * @param {Boolean} [flagUnanswered=true] Whether to ignore unanswered questions. If false, unanswered questions will not be flagged
94 * @return {Boolean} True or if *flagUnanswered* is true: True if all questions have been answered. Otherwise false and unanswered questions are highlighted.
95 */
96Quiz.prototype.checkAnswers = function(flagUnanswered) {
97	if (flagUnanswered === undefined) flagUnanswered = true;
98	var unansweredQs = [];
99	var questionResults = [];
100	for (var i=0; i < this.questions.length; i++) {
101		var question = this.questions[i];
102		var answer = this.answers[i];
103		var userAnswer = [];
104
105		this.clearHighlights(question);
106
107		// Get answers
108		var answerInputs = question.getElementsByClassName(this.Classes.QUESTION_ANSWERS)[0].getElementsByTagName('input');
109		var input;
110		for (var k=0; k < answerInputs.length; k++) {
111			input = answerInputs[k];
112			if (input.type === "checkbox" || input.type === "radio") {
113				 if (input.checked) userAnswer.push(input.value);
114			} else if (input.value !== '') {
115				userAnswer.push(input.value);
116			}
117		}
118		// Remove single answer from array to match provided answer format
119		if (userAnswer.length == 1 && !Array.isArray(answer)) {
120			userAnswer = userAnswer[0];
121		} else if (userAnswer.length === 0) {
122			unansweredQs.push(question);
123		}
124
125		questionResults.push(Utils.compare(userAnswer, answer));
126	}
127
128	if (unansweredQs.length === 0 || !flagUnanswered) {
129		this.result.setResults(questionResults);
130		return true;
131	}
132	else {
133		// Highlight unanswered questions if set
134		for (i=0; i < unansweredQs.length; i++) {
135			var warning = document.createElement('span');
136			warning.appendChild(document.createTextNode(this.unansweredQuestionText));
137			warning.className = this.Classes.QUESTION_WARNING;
138			unansweredQs[i].getElementsByClassName(this.Classes.QUESTION_TITLE)[0].appendChild(warning);
139		}
140	}
141	return false;
142};
143
144/**
145 * Clears highlighting for a question element (correct and incorrect classes), including unanswered question warnings and elements using the Classes.TEMP class
146 *
147 * @method clearHighlights
148 * @param {HTMLDocument} question Question element to clear
149 */
150Quiz.prototype.clearHighlights = function(question) {
151	// Remove question warning if it exists
152	var questionWarnings = question.getElementsByClassName(this.Classes.QUESTION_WARNING);
153	while (questionWarnings.length > 0) {
154		questionWarnings[0].parentNode.removeChild(questionWarnings[0]);
155	}
156
157	// Remove highlighted elements
158	var highlightedQuestions = [question.getElementsByClassName(this.Classes.CORRECT), question.getElementsByClassName(this.Classes.INCORRECT)];
159	var highlightedElement;
160	for (i=0; i < highlightedQuestions.length; i++) {
161		while (highlightedQuestions[i].length > 0) {
162			highlightedElement = highlightedQuestions[i][0];
163			highlightedElement.classList.remove(this.Classes.CORRECT);
164			highlightedElement.classList.remove(this.Classes.INCORRECT);
165		}
166	}
167
168	// Remove temp elements
169	var tempElements = question.getElementsByClassName(this.Classes.TEMP);
170	while (tempElements.length > 0) {
171		tempElements[0].parentNode.removeChild(tempElements[0]);
172	}
173};
174
175/**
176 * Highlights correctly and incorrectly answered questions by:
177 * - Adding the class 'quizlib-correct' to correctly answered question titles
178 * - Adding the class 'quizlib-incorrect' to incorrectly answered question titles
179 *
180 * @method highlightResults
181 * @param {Function} [questionCallback] Optional callback for each question with the following arguments:
182 * 1. Element: the question element
183 * 2. Number: question number
184 * 3. Boolean: true if correct, false if incorrect.
185 *
186 * This allows you to further customise the handling of answered questions (and decouples the library from a specific HTML structure), for example highlighting the correct answer(s) on incorrect questions.
187 * Use the Classes.TEMP ('quizlib-temp') class on added elements that you want removing when {{#crossLink "Quiz/checkAnswers:method"}}{{/crossLink}} is called
188 *
189 * @example
190 * ```
191 *    // Method Call
192 *    quiz.highlightResults(handleAnswers);
193 *
194 *    // handleAnswers callback
195 *    function handleAnswers(questionElement, questionNo, correctFlag) {
196 *        ...
197 *    }
198 * ```
199 */
200Quiz.prototype.highlightResults = function(questionCallback) {
201	var question;
202	for (var i=0; i < this.questions.length; i++) {
203		question = this.questions[i];
204		if (this.result.results[i]) {
205			question.getElementsByClassName(this.Classes.QUESTION_TITLE)[0].classList.add(this.Classes.CORRECT);
206		}
207		else {
208			question.getElementsByClassName(this.Classes.QUESTION_TITLE)[0].classList.add(this.Classes.INCORRECT);
209		}
210		if (questionCallback !== undefined) questionCallback(question, i, this.result.results[i]);
211	}
212};
213
214
215/**
216 * Quiz Result class that holds score information
217 *
218 * @class QuizResult
219 * @constructor
220 */
221var QuizResult = function() {
222	/**
223	 * Array of booleans where the index is the question number and the value is whether the question was answered correctly. Updated by {{#crossLink "QuizResult/setResults:method"}}{{/crossLink}}
224	 * @property results
225	 * @type Array
226	 */
227	this.results = [];
228	/**
229	 * Total number of questions. Updated by {{#crossLink "QuizResult/setResults:method"}}{{/crossLink}}
230	 * @property totalQuestions
231	 * @type Number
232	 */
233	this.totalQuestions = 0;
234	/**
235	 * Number of questions answered correctly. Updated by {{#crossLink "QuizResult/setResults:method"}}{{/crossLink}}
236	 * @property score
237	 * @type Number
238	 */
239	this.score = 0;
240	/**
241	 * Percentage score between 0 and 1. Updated by {{#crossLink "QuizResult/setResults:method"}}{{/crossLink}}
242	 * @property scorePercent
243	 * @type Number
244	 */
245	this.scorePercent = 0;
246	/**
247	 * Formatted score percent that's more useful to humans (1 - 100). Percent is rounded down. Updated by {{#crossLink "QuizResult/setResults:method"}}{{/crossLink}}
248	 * @property scorePercentFormatted
249	 * @type Number
250	 */
251	this.scorePercentFormatted = 0;
252};
253
254/**
255 * Calculates score information from an array of question results and updates properties
256 *
257 * @method setResults
258 * @param {Array} questionResults Array of question results where the index is the question number and the value is whether the question was answered correctly. e.g. [true, true, false]
259 */
260QuizResult.prototype.setResults = function(questionResults) {
261	this.results = questionResults;
262	this.totalQuestions = this.results.length;
263	this.score = 0;
264	for (var i=0; i < this.results.length; i++) {
265		if (this.results[i]) this.score++;
266	}
267	this.scorePercent = this.score / this.totalQuestions;
268	this.scorePercentFormatted = Math.floor(this.scorePercent * 100);
269};
270
271
272/**
273 * Utils class that provides useful methods
274 *
275 * @class Utils
276 */
277var Utils = function() {};
278/**
279 * Compare two objects without coercion. If objects are arrays, their contents will be compared, including order.
280 *
281 * @method compare
282 * @param {Object} obj1 main object
283 * @param {Object} obj2 object to compare obj1 against
284 * @return {boolean} True if objects are equal
285 */
286Utils.compare = function(obj1, obj2) {
287	if (obj1.length != obj2.length) return false;
288
289	if (Array.isArray(obj1) && Array.isArray(obj2)) {
290		for (var i=0; i < obj1.length; i++) {
291			if (obj1[i] !== obj2[i]) return false;
292		}
293		return true;
294	}
295	return obj1 === obj2;
296};
297