diff --git a/samigo/samigo-app/src/java/org/sakaiproject/tool/assessment/ui/bean/evaluation/TotalScoresBean.java b/samigo/samigo-app/src/java/org/sakaiproject/tool/assessment/ui/bean/evaluation/TotalScoresBean.java index ffbc0fadc5bf..d8d4df301a3e 100644 --- a/samigo/samigo-app/src/java/org/sakaiproject/tool/assessment/ui/bean/evaluation/TotalScoresBean.java +++ b/samigo/samigo-app/src/java/org/sakaiproject/tool/assessment/ui/bean/evaluation/TotalScoresBean.java @@ -24,12 +24,15 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.Collection; +import java.util.Comparator; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import javax.faces.bean.ManagedBean; @@ -66,14 +69,18 @@ import org.sakaiproject.tool.assessment.data.dao.grading.AssessmentGradingData; import org.sakaiproject.tool.assessment.data.dao.grading.ItemGradingData; import org.sakaiproject.tool.assessment.data.ifc.assessment.AnswerIfc; +import org.sakaiproject.tool.assessment.data.ifc.assessment.PublishedAssessmentIfc; +import org.sakaiproject.tool.assessment.data.ifc.assessment.SectionDataIfc; +import org.sakaiproject.tool.assessment.data.ifc.assessment.ItemTextIfc; import org.sakaiproject.tool.assessment.data.ifc.assessment.EvaluationModelIfc; -import org.sakaiproject.tool.assessment.data.ifc.shared.TypeIfc; import org.sakaiproject.tool.assessment.facade.AgentFacade; import org.sakaiproject.tool.assessment.facade.PublishedAssessmentFacade; import org.sakaiproject.samigo.util.SamigoConstants; import org.sakaiproject.tool.assessment.services.GradingService; import org.sakaiproject.tool.assessment.services.PersistenceService; import org.sakaiproject.tool.assessment.services.assessment.PublishedAssessmentService; +import org.sakaiproject.tool.assessment.services.assessment.StatisticsService; +import org.sakaiproject.tool.assessment.services.assessment.StatisticsService.SubmissionOutcome; import org.sakaiproject.tool.assessment.shared.api.grading.GradingSectionAwareServiceAPI; import org.sakaiproject.tool.assessment.shared.impl.grading.GradingSectionAwareServiceImpl; import org.sakaiproject.tool.assessment.ui.bean.util.TotalScoresExportBean; @@ -245,22 +252,31 @@ protected void init() { } public boolean getIsOneSelectionType() { - if (this.getPublishedAssessment() == null) { - return false; + PublishedAssessmentData currentPublishedAssessment = this.getPublishedAssessment(); + if (currentPublishedAssessment == null) { + String currentPublishedId = StringUtils.trimToNull(getPublishedId()); + if (currentPublishedId != null && !"0".equals(currentPublishedId)) { + PublishedAssessmentFacade publishedAssessmentFacade = new PublishedAssessmentService().getPublishedAssessment(currentPublishedId); + if (publishedAssessmentFacade != null && publishedAssessmentFacade.getData() instanceof PublishedAssessmentData) { + currentPublishedAssessment = (PublishedAssessmentData) publishedAssessmentFacade.getData(); + this.publishedAssessment = currentPublishedAssessment; + } + } + } + + if (currentPublishedAssessment == null) { + return isOneSelectionType; } else { - for (Object sectionObject : this.getPublishedAssessment().getSectionArray()) { + for (Object sectionObject : currentPublishedAssessment.getSectionArray()) { PublishedSectionData sectionData = (PublishedSectionData) sectionObject; for (Object itemObject : sectionData.getItemArray()) { PublishedItemData item = (PublishedItemData) itemObject; - boolean isMultipleChoice = item.getTypeId().equals(TypeIfc.MULTIPLE_CHOICE); - boolean isSingleSelection = item.getTypeId().equals(TypeIfc.MULTIPLE_CORRECT_SINGLE_SELECTION); - boolean isTrueFalseQuestionType = item.getTypeId().equals(TypeIfc.TRUE_FALSE); - if (!isMultipleChoice && !isSingleSelection && !isTrueFalseQuestionType) { - return false; + if (isTallyableItemType(item.getTypeId())) { + return true; } } } - return true; + return false; } } @@ -279,30 +295,41 @@ public Map getResults() { this.setResultsAlreadyCalculated(true); // Instance a new PublishedAssessmentService to get all the published Answer for each student PublishedAssessmentService pubAssessmentService = new PublishedAssessmentService(); - Map publishedAnswerHash = pubAssessmentService.preparePublishedAnswerHash(pubAssessmentService.getPublishedAssessment(this.getPublishedId())); + PublishedAssessmentIfc publishedAssessmentData = pubAssessmentService.getPublishedAssessment(this.getPublishedId()); + Map publishedAnswerHash = pubAssessmentService.preparePublishedAnswerHash(publishedAssessmentData); // Instance a new GradingService to get all the student responses GradingService gradingService = new GradingService(); + StatisticsService statisticsService = new StatisticsService(); Map> resultsByUser = new HashMap<>(); + List tallyableItems = getTallyableItems(publishedAssessmentData); + Map answersById = new HashMap<>(); + for (Object answerObject : publishedAnswerHash.values()) { + AnswerIfc answer = (AnswerIfc) answerObject; + if (answer != null && answer.getId() != null) { + answersById.put(answer.getId(), answer); + } + } // For each agent (student) we will search the correct/incorrect/empty responses for (Object object : agents) { AgentResults agentResults = (AgentResults) object; if (agentResults.getAssessmentGradingId() != -1) { - // Getting the responses for that student (agentResults) - AssessmentGradingData assessmentGradingAux = gradingService.load(agentResults.getAssessmentGradingId().toString()); + // Tallying needs item gradings only; skip attachment loading to avoid extra per-student DB work. + AssessmentGradingData assessmentGradingAux = gradingService.load(agentResults.getAssessmentGradingId().toString(), false); List resultsAux = new ArrayList<>(Collections.nCopies(3, 0)); - for (ItemGradingData item : assessmentGradingAux.getItemGradingSet()) { - if (item.getPublishedAnswerId() == null) { // If it does not have publishedAnswerId that means it is empty + Map> gradingByItem = groupGradingsByItem(assessmentGradingAux); + for (PublishedItemData item : tallyableItems) { + Long itemId = item.getItemId(); + List gradingList = gradingByItem.get(itemId); + if (hasRandomDrawPart && gradingList == null) { + continue; + } + SubmissionOutcome submissionOutcome = statisticsService.classifySubmission(item, gradingList, answersById); + if (submissionOutcome == SubmissionOutcome.CORRECT) { + resultsAux.set(0, resultsAux.get(0) + 1); + } else if (submissionOutcome == SubmissionOutcome.INCORRECT) { + resultsAux.set(1, resultsAux.get(1) + 1); + } else if (submissionOutcome == SubmissionOutcome.BLANK) { resultsAux.set(2, resultsAux.get(2) + 1); - } else { // If it has publishedAnswerId that means has response - // If it has response we will get the answer from publishedAnswerHash and if it is correct or incorrect - AnswerIfc answer = (AnswerIfc) publishedAnswerHash.get(item.getPublishedAnswerId()); - if (!answer.getIsCorrect()) { - // For incorrect answers cases - resultsAux.set(1, ((int) resultsAux.get(1)) + 1); - } else { - // For correct answers cases - resultsAux.set(0, ((int) resultsAux.get(0)) + 1); - } } } resultsByUser.put(agentResults.getAssessmentGradingId(), resultsAux); @@ -312,6 +339,50 @@ public Map getResults() { } return results; } + + private boolean isTallyableItemType(Long typeId) { + return StatisticsService.supportsTotalScoresTally(typeId); + } + + private List getTallyableItems(PublishedAssessmentIfc publishedAssessmentData) { + List tallyableItems = new ArrayList<>(); + if (publishedAssessmentData == null) { + return tallyableItems; + } + for (Object sectionObject : publishedAssessmentData.getSectionArray()) { + SectionDataIfc sectionData = (SectionDataIfc) sectionObject; + for (Object itemObject : sectionData.getItemArray()) { + PublishedItemData item = (PublishedItemData) itemObject; + if (isTallyableItemType(item.getTypeId())) { + tallyableItems.add(item); + } + } + } + return tallyableItems; + } + + private Map> groupGradingsByItem(AssessmentGradingData assessmentGradingData) { + Map> gradingByItem = new HashMap<>(); + if (assessmentGradingData == null || assessmentGradingData.getItemGradingSet() == null) { + return gradingByItem; + } + for (ItemGradingData gradingData : (Set) assessmentGradingData.getItemGradingSet()) { + if (gradingData == null) { + continue; + } + Long itemId = gradingData.getPublishedItemId(); + if (itemId == null) { + continue; + } + List gradingList = gradingByItem.get(itemId); + if (gradingList == null) { + gradingList = new ArrayList<>(); + gradingByItem.put(itemId, gradingList); + } + gradingList.add(gradingData); + } + return gradingByItem; + } // Following three methods are for interface PhaseAware public void endProcessValidators() { diff --git a/samigo/samigo-app/src/java/org/sakaiproject/tool/assessment/ui/listener/evaluation/HistogramListener.java b/samigo/samigo-app/src/java/org/sakaiproject/tool/assessment/ui/listener/evaluation/HistogramListener.java index 01ca165bc6fe..0f7b4d27fb08 100644 --- a/samigo/samigo-app/src/java/org/sakaiproject/tool/assessment/ui/listener/evaluation/HistogramListener.java +++ b/samigo/samigo-app/src/java/org/sakaiproject/tool/assessment/ui/listener/evaluation/HistogramListener.java @@ -65,11 +65,14 @@ import org.sakaiproject.tool.assessment.data.ifc.assessment.PublishedAssessmentIfc; import org.sakaiproject.tool.assessment.data.ifc.assessment.SectionDataIfc; import org.sakaiproject.tool.assessment.data.ifc.shared.TypeIfc; +import org.sakaiproject.tool.assessment.data.ifc.shared.TypeIfc.TypeId; import org.sakaiproject.tool.assessment.facade.AgentFacade; import org.sakaiproject.tool.assessment.facade.PublishedAssessmentFacade; import org.sakaiproject.tool.assessment.services.GradingService; import org.sakaiproject.tool.assessment.services.PublishedItemService; import org.sakaiproject.tool.assessment.services.assessment.PublishedAssessmentService; +import org.sakaiproject.tool.assessment.services.assessment.StatisticsService; +import org.sakaiproject.tool.assessment.services.assessment.StatisticsService.SubmissionOutcome; import org.sakaiproject.tool.assessment.shared.api.assessment.SecureDeliveryServiceAPI; import org.sakaiproject.tool.assessment.shared.api.assessment.SecureDeliveryServiceAPI.Phase; import org.sakaiproject.tool.assessment.shared.api.assessment.SecureDeliveryServiceAPI.PhaseStatus; @@ -113,6 +116,7 @@ public class HistogramListener private static final ResourceLoader rc = new ResourceLoader("org.sakaiproject.tool.assessment.bundle.CommonMessages"); private GradingService delegate; + private StatisticsService statisticsService; /** * Standard process action method. @@ -624,25 +628,17 @@ public boolean histogramScores(HistogramScoresBean histogramScores, TotalScoresB int maxNumOfAnswers = 0; List detailedStatistics = new ArrayList(); Iterator infoIter = info.iterator(); - while (infoIter.hasNext()) { - HistogramQuestionScoresBean questionScores = (HistogramQuestionScoresBean)infoIter.next(); - if (questionScores.getQuestionType().equals(TypeIfc.MULTIPLE_CHOICE.toString()) - || questionScores.getQuestionType().equals(TypeIfc.MULTIPLE_CORRECT.toString()) - || questionScores.getQuestionType().equals(TypeIfc.MULTIPLE_CHOICE_SURVEY.toString()) - || questionScores.getQuestionType().equals(TypeIfc.TRUE_FALSE.toString()) - || questionScores.getQuestionType().equals(TypeIfc.FILL_IN_BLANK.toString()) - || questionScores.getQuestionType().equals(TypeIfc.MATCHING.toString()) - || questionScores.getQuestionType().equals(TypeIfc.FILL_IN_NUMERIC.toString()) - || questionScores.getQuestionType().equals(TypeIfc.MULTIPLE_CORRECT_SINGLE_SELECTION.toString()) - || questionScores.getQuestionType().equals(TypeIfc.CALCULATED_QUESTION.toString()) - || questionScores.getQuestionType().equals("16") - ) { - questionScores.setShowIndividualAnswersInDetailedStatistics(true); - detailedStatistics.add(questionScores); - if (questionScores.getHistogramBars() != null) { - maxNumOfAnswers = questionScores.getHistogramBars().length >maxNumOfAnswers ? questionScores.getHistogramBars().length : maxNumOfAnswers; + while (infoIter.hasNext()) { + HistogramQuestionScoresBean questionScores = (HistogramQuestionScoresBean)infoIter.next(); + boolean includeInDetailedStatistics = isDetailedStatisticsQuestionType(questionScores.getQuestionType()); + if (includeInDetailedStatistics) { + boolean showIndividualAnswers = showsIndividualAnswersInDetailedStatistics(questionScores.getQuestionType()); + questionScores.setShowIndividualAnswersInDetailedStatistics(showIndividualAnswers); + detailedStatistics.add(questionScores); + if (showIndividualAnswers && questionScores.getHistogramBars() != null) { + maxNumOfAnswers = questionScores.getHistogramBars().length > maxNumOfAnswers ? questionScores.getHistogramBars().length : maxNumOfAnswers; + } } - } if (showObjectivesColumn) { // Get the percentage correct by objective @@ -824,54 +820,145 @@ private void determineResults(PublishedAssessmentIfc pub, HistogramQuestionScore if (itemScores == null) itemScores = new ArrayList(); - int responses = 0; - Set assessmentGradingIds = new HashSet(); - int numStudentsWithZeroAnswers = 0; - for (ItemGradingData itemGradingData: itemScores) { - // only count the unique questions answers - // There may be multiple itemGradingData with the same AssessmentGradingId - // for matching questions (essentially a collection of MC questions) - if(!assessmentGradingIds.contains(itemGradingData.getAssessmentGradingId())){ - if (itemGradingData.getPublishedAnswerId() != null) { - responses++; - assessmentGradingIds.add(itemGradingData.getAssessmentGradingId()); - } else if (!qbean.getQuestionType().equals(TypeIfc.MATCHING.toString())) { - assessmentGradingIds.add(itemGradingData.getAssessmentGradingId()); - } + Map hasAnswersByAssessmentGradingId = new HashMap<>(); + for (ItemGradingData itemGradingData : itemScores) { + Long assessmentGradingId = itemGradingData.getAssessmentGradingId(); + if (assessmentGradingId == null) { + continue; + } - assessmentGradingIds.add(itemGradingData.getAssessmentGradingId()); + boolean hasAnswer = hasAnswerForItemType(qbean.getQuestionType(), itemGradingData); + hasAnswersByAssessmentGradingId.merge(assessmentGradingId, hasAnswer, Boolean::logicalOr); + } - if (itemGradingData.getSubmittedDate() == null) { - numStudentsWithZeroAnswers++; - } + int responses = 0; + int numStudentsWithZeroAnswers = 0; + for (Boolean hasAnswer : hasAnswersByAssessmentGradingId.values()) { + if (Boolean.TRUE.equals(hasAnswer)) { + responses++; + } else { + numStudentsWithZeroAnswers++; } } - if (qbean.getQuestionType().equals(TypeIfc.IMAGEMAP_QUESTION.toString())) { - responses = assessmentGradingIds.size(); - } + qbean.setNumResponses(responses); qbean.setNumberOfStudentsWithZeroAnswers(numStudentsWithZeroAnswers); - if (qbean.getQuestionType().equals(TypeIfc.MULTIPLE_CHOICE.toString()) || // mcsc - qbean.getQuestionType().equals(TypeIfc.MULTIPLE_CORRECT.toString()) || // mcmcms - qbean.getQuestionType().equals(TypeIfc.MULTIPLE_CORRECT_SINGLE_SELECTION.toString()) || // mcmcss - qbean.getQuestionType().equals(TypeIfc.MULTIPLE_CHOICE_SURVEY.toString()) || // mc survey - qbean.getQuestionType().equals(TypeIfc.TRUE_FALSE.toString()) || // tf - qbean.getQuestionType().equals(TypeIfc.MATCHING.toString()) || // matching - qbean.getQuestionType().equals(TypeIfc.FILL_IN_BLANK.toString()) || // Fill in the blank - qbean.getQuestionType().equals(TypeIfc.EXTENDED_MATCHING_ITEMS.toString()) || // Extended Matching Items - qbean.getQuestionType().equals(TypeIfc.FILL_IN_NUMERIC.toString()) || // Numeric Response - qbean.getQuestionType().equals(TypeIfc.CALCULATED_QUESTION.toString()) || // CALCULATED_QUESTION - qbean.getQuestionType().equals(TypeIfc.IMAGEMAP_QUESTION.toString()) || // IMAGEMAP_QUESTION - qbean.getQuestionType().equals(TypeIfc.MATRIX_CHOICES_SURVEY.toString())) // matrix survey + if (isAnswerStatisticsQuestionType(qbean.getQuestionType())) doAnswerStatistics(pub, qbean, itemScores); - if (qbean.getQuestionType().equals(TypeIfc.ESSAY_QUESTION.toString()) || // essay - qbean.getQuestionType().equals(TypeIfc.FILE_UPLOAD.toString()) || // file upload - qbean.getQuestionType().equals(TypeIfc.AUDIO_RECORDING.toString())) // audio recording + if (isScoreStatisticsQuestionType(qbean.getQuestionType())) doScoreStatistics(qbean, itemScores); } + private boolean isDetailedStatisticsQuestionType(String questionType) { + return StatisticsService.includesInDetailedStatistics(questionType); + } + + private boolean showsIndividualAnswersInDetailedStatistics(String questionType) { + return StatisticsService.showsIndividualAnswersInDetailedStatistics(questionType); + } + + private boolean isAnswerStatisticsQuestionType(String questionType) { + return StatisticsService.supportsAnswerStatistics(questionType); + } + + private boolean isScoreStatisticsQuestionType(String questionType) { + return StatisticsService.supportsScoreStatistics(questionType); + } + + private TypeId resolveQuestionTypeId(String questionType) { + if (StringUtils.isBlank(questionType)) { + return null; + } + + long parsedQuestionTypeId; + try { + parsedQuestionTypeId = Long.parseLong(questionType); + } catch (NumberFormatException e) { + return null; + } + + if (!TypeId.isValidId(parsedQuestionTypeId)) { + return null; + } + return TypeId.getInstance(parsedQuestionTypeId); + } + + private void dispatchAnswerStatistics(TypeId questionTypeId, Map publishedItemHash, Map publishedItemTextHash, + Map publishedAnswerHash, List scores, HistogramQuestionScoresBean qbean, ItemDataIfc item, List text, + List answers, Map emiRequiredCorrectAnswersCount) { + if (questionTypeId == null) { + return; + } + + switch (questionTypeId) { + case MULTIPLE_CHOICE_ID: + case MULTIPLE_CORRECT_SINGLE_SELECTION_ID: + case MULTIPLE_CHOICE_SURVEY_ID: + case TRUE_FALSE_ID: + getTFMCScores(publishedAnswerHash, scores, qbean, answers); + break; + case MULTIPLE_CORRECT_ID: + case FILL_IN_BLANK_ID: + case FILL_IN_NUMERIC_ID: + getFIBMCMCScores(publishedItemHash, publishedAnswerHash, scores, qbean, answers, item); + break; + case MATCHING_ID: + getMatchingScores(publishedItemTextHash, publishedAnswerHash, scores, qbean, text); + break; + case EXTENDED_MATCHING_ITEMS_ID: + getEMIScores(publishedItemHash, publishedAnswerHash, emiRequiredCorrectAnswersCount, scores, qbean, answers); + break; + case MATRIX_CHOICES_SURVEY_ID: + getMatrixSurveyScores(publishedItemTextHash, publishedAnswerHash, scores, qbean, text); + break; + case CALCULATED_QUESTION_ID: + getCalculatedQuestionScores(scores, qbean, item); + break; + case IMAGEMAP_QUESTION_ID: + getImageMapQuestionScores(publishedItemTextHash, publishedAnswerHash, (List) scores, qbean, (List) text); + break; + default: + log.warn("No answer-statistics dispatcher for question type [{}] (parsed id: {}). " + + "Question type supports answer statistics but is not handled in dispatchAnswerStatistics.", + qbean.getQuestionType(), questionTypeId); + break; + } + } + + private boolean isSurveyLikeAnswerKeyCandidate(TypeId questionTypeId) { + return questionTypeId == TypeId.MULTIPLE_CHOICE_ID + || questionTypeId == TypeId.MULTIPLE_CORRECT_ID + || questionTypeId == TypeId.MULTIPLE_CORRECT_SINGLE_SELECTION_ID + || questionTypeId == TypeId.TRUE_FALSE_ID; + } + + private boolean hasAnswerForItemType(String questionType, ItemGradingData itemGradingData) { + Long answerId = itemGradingData.getPublishedAnswerId(); + String answerText = itemGradingData.getAnswerText(); + + if (StringUtils.equalsAny(questionType, + TypeIfc.FILL_IN_BLANK.toString(), + TypeIfc.FILL_IN_NUMERIC.toString(), + TypeIfc.CALCULATED_QUESTION.toString())) { + return answerId != null && StringUtils.isNotBlank(answerText); + } + + if (StringUtils.equals(questionType, TypeIfc.IMAGEMAP_QUESTION.toString())) { + String normalizedAnswerText = StringUtils.trimToNull(answerText); + return answerId != null + && normalizedAnswerText != null + && !StringUtils.equalsIgnoreCase(normalizedAnswerText, "undefined"); + } + + if (StringUtils.equals(questionType, TypeIfc.MATCHING.toString())) { + return answerId != null || StringUtils.isNotBlank(answerText); + } + + return answerId != null; + } + /** * For each question where statistics are required for seperate answers, * this method calculates the answer statistics by calling a different @@ -915,13 +1002,14 @@ private void doAnswerStatistics(PublishedAssessmentIfc pub, HistogramQuestionSco //int numAnswers = 0; + TypeId questionTypeId = resolveQuestionTypeId(qbean.getQuestionType()); ItemDataIfc item = (ItemDataIfc) publishedItemHash.get(qbean.getItemId()); List text = item.getItemTextArraySorted(); List answers = null; - //keys number of correct answers required by sub-question (ItemText) + //keys number of correct answers required by sub-question (ItemText) Map emiRequiredCorrectAnswersCount = null; - if (qbean.getQuestionType().equals(TypeIfc.EXTENDED_MATCHING_ITEMS.toString())) { //EMI + if (questionTypeId == TypeId.EXTENDED_MATCHING_ITEMS_ID) { //EMI emiRequiredCorrectAnswersCount = new HashMap(); answers = new ArrayList(); for (int i=0; i 0) { ItemTextIfc firstText = (ItemTextIfc) publishedItemTextHash.get(((ItemTextIfc) text.toArray()[0]).getId()); answers = firstText.getAnswerArraySorted(); } } - - if (StringUtils.equalsAny(qbean.getQuestionType(), TypeIfc.MULTIPLE_CHOICE.toString(), TypeIfc.MULTIPLE_CORRECT_SINGLE_SELECTION.toString(), TypeIfc.MULTIPLE_CHOICE_SURVEY.toString(), TypeIfc.TRUE_FALSE.toString())) { - getTFMCScores(publishedAnswerHash, scores, qbean, answers); - } else if (StringUtils.equalsAny(qbean.getQuestionType(), TypeIfc.MULTIPLE_CORRECT.toString(), TypeIfc.FILL_IN_BLANK.toString(), TypeIfc.FILL_IN_NUMERIC.toString())) { - getFIBMCMCScores(publishedItemHash, publishedAnswerHash, scores, qbean, answers, item); - } else if (qbean.getQuestionType().equals(TypeIfc.MATCHING.toString())) { - getMatchingScores(publishedItemTextHash, publishedAnswerHash, scores, qbean, text); - } else if (qbean.getQuestionType().equals(TypeIfc.EXTENDED_MATCHING_ITEMS.toString())) { - getEMIScores(publishedItemHash, publishedAnswerHash, emiRequiredCorrectAnswersCount, scores, qbean, answers); - } else if (qbean.getQuestionType().equals(TypeIfc.MATRIX_CHOICES_SURVEY.toString())) { - getMatrixSurveyScores(publishedItemTextHash, publishedAnswerHash, scores, qbean, text); - } else if (qbean.getQuestionType().equals(TypeIfc.CALCULATED_QUESTION.toString())) { - getCalculatedQuestionScores(scores, qbean, item); - } else if (qbean.getQuestionType().equals(TypeIfc.IMAGEMAP_QUESTION.toString())) { - getImageMapQuestionScores(publishedItemTextHash, publishedAnswerHash, (List) scores, qbean, (List) text); - } - - long attemptCount = Optional.ofNullable(qbean.getN()).map(Long::valueOf).orElse(0L); + + dispatchAnswerStatistics(questionTypeId, publishedItemHash, publishedItemTextHash, publishedAnswerHash, scores, qbean, + item, text, answers, emiRequiredCorrectAnswersCount); + + boolean isSurveyType = StatisticsService.isSurveyQuestionType(qbean.getQuestionType()) + || isSurveyLikeQuestionWithoutAnswerKey(qbean.getQuestionType(), answers); + + if (!isSurveyType) { + Map answersById = new HashMap<>(); + for (Object answerObject : publishedAnswerHash.values()) { + AnswerIfc answer = (AnswerIfc) answerObject; + if (answer != null && answer.getId() != null) { + answersById.put(answer.getId(), answer); + } + } + applyCanonicalSubmissionTallies(qbean, item, scores, answersById); + } + + long respondedCount = qbean.getNumResponses(); long correctCount = Optional.ofNullable(qbean.getStudentsWithAllCorrect()).map(Set::size).orElse(0); long blankCount = Optional.ofNullable(qbean.getNumberOfStudentsWithZeroAnswers()).orElse(0); - long totalCount = attemptCount + blankCount; - long incorrectCount = attemptCount - correctCount; + long totalCount = respondedCount + blankCount; + long incorrectCount = respondedCount - correctCount; + if (incorrectCount < 0) { + incorrectCount = 0; + } - // Ideally totalCount should not be 0, if it happens we should handle it to avoid division by 0 - if (totalCount > 0) { - int difficulty = calcDifficulty(totalCount, incorrectCount, blankCount); - qbean.setDifficulty(difficulty); + if (!isSurveyType) { + // Ideally totalCount should not be 0, if it happens we should handle it to avoid division by 0 + if (totalCount > 0) { + int difficulty = calcDifficulty(totalCount, incorrectCount, blankCount); + qbean.setDifficulty(difficulty); + } else { + log.warn("respondedCount is 0 for item with id=[{}], title=[{}], type=[{}]", + qbean.getItemId(), qbean.getTitle(), qbean.getQuestionType()); + } + + qbean.setNumberOfStudentsWithCorrectAnswers(correctCount); + qbean.setNumberOfStudentsWithIncorrectAnswers(incorrectCount); } else { - log.warn("attemptCount is 0 for item with id=[{}], title=[{}], type=[{}]", - qbean.getItemId(), qbean.getTitle(), qbean.getQuestionType()); + qbean.setDifficulty(null); + qbean.setNumberOfStudentsWithCorrectAnswers(null); + qbean.setNumberOfStudentsWithIncorrectAnswers(null); + } + } + + private boolean isSurveyLikeQuestionWithoutAnswerKey(String questionType, List answers) { + TypeId questionTypeId = resolveQuestionTypeId(questionType); + if (!isSurveyLikeAnswerKeyCandidate(questionTypeId)) { + return false; + } + + if (answers == null || answers.isEmpty()) { + return false; + } + + boolean hasAnswersWithNullCorrectness = false; + for (Object answerObj : answers) { + AnswerIfc answer = (AnswerIfc) answerObj; + if (answer.getIsCorrect() != null) { + return false; + } + hasAnswersWithNullCorrectness = true; + } + + return hasAnswersWithNullCorrectness; + } + + private void applyCanonicalSubmissionTallies(HistogramQuestionScoresBean qbean, ItemDataIfc item, + List scores, Map answersById) { + Map> scoresByAssessment = new HashMap<>(); + for (ItemGradingData score : scores) { + if (score == null || score.getAssessmentGradingId() == null) { + continue; + } + scoresByAssessment.computeIfAbsent(score.getAssessmentGradingId(), key -> new ArrayList<>()).add(score); + } + + int respondedCount = 0; + int blankCount = 0; + Set studentsResponded = new TreeSet<>(); + Set studentsWithAllCorrect = new TreeSet<>(); + for (List submissionScores : scoresByAssessment.values()) { + SubmissionOutcome submissionOutcome = getStatisticsService().classifySubmission(item, submissionScores, answersById); + String agentId = getSubmissionAgentId(submissionScores); + if (submissionOutcome == SubmissionOutcome.CORRECT) { + respondedCount++; + if (agentId != null) { + studentsResponded.add(agentId); + studentsWithAllCorrect.add(agentId); + } + } else if (submissionOutcome == SubmissionOutcome.INCORRECT) { + respondedCount++; + if (agentId != null) { + studentsResponded.add(agentId); + } + } else if (submissionOutcome == SubmissionOutcome.BLANK) { + blankCount++; + } + } + + qbean.setNumResponses(respondedCount); + qbean.setNumberOfStudentsWithZeroAnswers(blankCount); + qbean.setStudentsResponded(studentsResponded); + qbean.setStudentsWithAllCorrect(studentsWithAllCorrect); + } + + private StatisticsService getStatisticsService() { + if (statisticsService == null) { + statisticsService = new StatisticsService(); } + return statisticsService; + } - qbean.setNumberOfStudentsWithCorrectAnswers(correctCount); - qbean.setNumberOfStudentsWithIncorrectAnswers(incorrectCount); + void setStatisticsService(StatisticsService statisticsService) { + this.statisticsService = statisticsService; + } + + private String getSubmissionAgentId(List submissionScores) { + for (ItemGradingData score : submissionScores) { + String agentId = score.getAgentId(); + if (StringUtils.isNotBlank(agentId)) { + return agentId; + } + } + return null; } /** @@ -1180,7 +1360,7 @@ public int compare(String o1, String o2) { while (answeriter.hasNext()) { AnswerIfc answerchoice = (AnswerIfc) answeriter .next(); - if (answerchoice.getIsCorrect().booleanValue()) { + if (Boolean.TRUE.equals(answerchoice.getIsCorrect())) { corranswers++; } } @@ -1197,9 +1377,7 @@ public int compare(String o1, String o2) { // now check each answer AnswerIfc answer = (AnswerIfc) publishedAnswerHash.get(item .getPublishedAnswerId()); - if (answer != null - && (answer.getIsCorrect() == null || (!answer - .getIsCorrect().booleanValue()))) { + if (answer == null || !Boolean.TRUE.equals(answer.getIsCorrect())) { hasIncorrect = true; break; } @@ -1242,7 +1420,13 @@ public int compare(String o1, String o2) { .get(key); if (studentResponseListForSubQuestion != null && !studentResponseListForSubQuestion.isEmpty()) { ItemGradingData response1 = (ItemGradingData)studentResponseListForSubQuestion.get(0); - Long subQuestionId = ((AnswerIfc)publishedAnswerHash.get(response1.getPublishedAnswerId())).getItemText().getId(); + AnswerIfc firstResponseAnswer = (AnswerIfc) publishedAnswerHash.get(response1.getPublishedAnswerId()); + if (firstResponseAnswer == null || firstResponseAnswer.getItemText() == null) { + log.warn("Could not determine EMI sub-question for ItemGradingData with id {}", + response1.getItemGradingId()); + continue; + } + Long subQuestionId = firstResponseAnswer.getItemText().getId(); Set studentsResponded = (Set)studentsRespondedPerSubQuestion.get(subQuestionId); if (studentsResponded == null) studentsResponded = new TreeSet(); @@ -1252,6 +1436,10 @@ public int compare(String o1, String o2) { boolean hasIncorrect = false; //numCorrectSubQuestionAnswers = (Integer) correctAnswersPerSubQuestion.get(subQuestionId); Integer numCorrectSubQuestionAnswers = (Integer) emiRequiredCorrectAnswersCount.get(subQuestionId); + if (numCorrectSubQuestionAnswers == null) { + log.warn("No required correct answer count found for EMI sub-question id {}", subQuestionId); + continue; + } if (studentResponseListForSubQuestion.size() < numCorrectSubQuestionAnswers.intValue()) { hasIncorrect = true; @@ -1263,9 +1451,7 @@ public int compare(String o1, String o2) { ItemGradingData response = (ItemGradingData)studentResponseIter.next(); AnswerIfc answer = (AnswerIfc) publishedAnswerHash.get(response .getPublishedAnswerId()); - if (answer != null - && (answer.getIsCorrect() == null || (!answer - .getIsCorrect().booleanValue()))) { + if (answer == null || !Boolean.TRUE.equals(answer.getIsCorrect())) { hasIncorrect = true; break; } @@ -1461,11 +1647,14 @@ private void getFIBMCMCScores(Map publishedItemHash, Map publishedAnswerHash, Li sequenceMap.put(answer.getSequence(), answer.getId()); } iter = scores.iterator(); + boolean isFIB = qbean.getQuestionType().equals(TypeIfc.FILL_IN_BLANK.toString()); + boolean isFIN = qbean.getQuestionType().equals(TypeIfc.FILL_IN_NUMERIC.toString()); while (iter.hasNext()) { ItemGradingData data = (ItemGradingData) iter.next(); AnswerIfc answer = (AnswerIfc) publishedAnswerHash.get(data .getPublishedAnswerId()); if (answer != null) { + boolean hasInput = !isFIB && !isFIN || StringUtils.isNotBlank(data.getAnswerText()); // found a response Integer num = null; // num is a counter @@ -1480,23 +1669,25 @@ private void getFIBMCMCScores(Map publishedItemHash, Map publishedAnswerHash, Li if (num == null) num = Integer.valueOf(0); - List studentResponseList = (List) numStudentRespondedMap - .get(data.getAssessmentGradingId()); - if (studentResponseList == null) { - studentResponseList = new ArrayList(); + if (hasInput) { + List studentResponseList = (List) numStudentRespondedMap + .get(data.getAssessmentGradingId()); + if (studentResponseList == null) { + studentResponseList = new ArrayList(); + } + studentResponseList.add(data); + numStudentRespondedMap.put(data.getAssessmentGradingId(), + studentResponseList); } - studentResponseList.add(data); - numStudentRespondedMap.put(data.getAssessmentGradingId(), - studentResponseList); // we found a response, and got the existing num , now update // one - if (qbean.getQuestionType().equals("8")) { + if (isFIB) { // for fib we only count the number of correct responses - if (delegate.getFIBResult(data, new HashMap>(), itemData, publishedAnswerHash)) { + if (hasInput && delegate.getFIBResult(data, new HashMap>(), itemData, publishedAnswerHash)) { results.merge(answer.getId(), 1, Integer::sum); } - } else if (qbean.getQuestionType().equals("11")) { - if (delegate.getFINResult(data, itemData, publishedAnswerHash)) { + } else if (isFIN) { + if (hasInput && delegate.getFINResult(data, itemData, publishedAnswerHash)) { results.merge(answer.getId(), 1, Integer::sum); } } else { @@ -1579,7 +1770,7 @@ private void getFIBMCMCScores(Map publishedItemHash, Map publishedAnswerHash, Li while (answeriter.hasNext()) { AnswerIfc answerchoice = (AnswerIfc) answeriter .next(); - if (answerchoice.getIsCorrect().booleanValue()) { + if (Boolean.TRUE.equals(answerchoice.getIsCorrect())) { corranswers++; } } @@ -1597,9 +1788,7 @@ private void getFIBMCMCScores(Map publishedItemHash, Map publishedAnswerHash, Li AnswerIfc answer = (AnswerIfc) publishedAnswerHash.get(item .getPublishedAnswerId()); - if (answer != null - && (answer.getIsCorrect() == null || (!answer - .getIsCorrect().booleanValue()))) { + if (answer == null || !Boolean.TRUE.equals(answer.getIsCorrect())) { hasIncorrect = true; break; } @@ -1763,49 +1952,82 @@ private void getCalculatedQuestionScores(List scores, Histogram final String INCORRECT = ContextUtil.getLocalizedString("org.sakaiproject.tool.assessment.bundle.AuthorMessages","incorrect"); final int COLUMN_MAX_HEIGHT = 100; - // count incorrect and correct to support column height calculation - Map results = new HashMap<>(); + Map results = new LinkedHashMap<>(); results.put(CORRECT, Integer.valueOf(0)); results.put(INCORRECT, Integer.valueOf(0)); - Map answersMap = new HashMap<>(); - LinkedHashMap answersMapValues = new LinkedHashMap<>(); - LinkedHashMap globalanswersMapValues = new LinkedHashMap<>(); - LinkedHashMap mainvariablesWithValues = new LinkedHashMap<>(); - int total = 0; if (!scores.isEmpty()) { // not every question may have an answer i.e. randomly drawn questions - int i = 1; - Long publishAnswerIdAnt = scores.get(0).getPublishedAnswerId(); + Map> scoresByAssessment = new HashMap<>(); for (ItemGradingData score : scores) { - Long publishAnswerIdAct = score.getPublishedAnswerId(); - if (!Objects.equals(publishAnswerIdAnt, publishAnswerIdAct)) { - i++; - publishAnswerIdAnt = publishAnswerIdAct; + Long assessmentGradingId = score.getAssessmentGradingId(); + List list = scoresByAssessment.get(assessmentGradingId); + if (list == null) { + list = new ArrayList<>(); + scoresByAssessment.put(assessmentGradingId, list); } - delegate.extractCalcQAnswersArray(answersMap, answersMapValues, globalanswersMapValues, mainvariablesWithValues, item, score.getAssessmentGradingId(), score.getAgentId()); - if (score.getAutoScore() != null) { - total++; - if (delegate.getCalcQResult(score, item, answersMap, i)) { - results.merge(CORRECT, 1, Integer::sum); - } else { - results.merge(INCORRECT, 1, Integer::sum); + list.add(score); + } + + for (Map.Entry> entry : scoresByAssessment.entrySet()) { + List submissionScores = new ArrayList<>(entry.getValue()); + if (submissionScores.isEmpty()) { + continue; + } + submissionScores.sort(Comparator.comparing(ItemGradingData::getPublishedAnswerId, + Comparator.nullsLast(Long::compareTo))); + int totalParts = submissionScores.size(); + int correctParts = 0; + int blankParts = 0; + int answerSequence = 0; + Long previousAnswerId = null; + Map answersMap = new HashMap<>(); + LinkedHashMap answersMapValues = new LinkedHashMap<>(); + LinkedHashMap globalanswersMapValues = new LinkedHashMap<>(); + LinkedHashMap mainvariablesWithValues = new LinkedHashMap<>(); + + for (ItemGradingData score : submissionScores) { + Long currentAnswerId = score.getPublishedAnswerId(); + if (currentAnswerId == null || StringUtils.isBlank(score.getAnswerText())) { + blankParts++; + continue; + } + if (!Objects.equals(previousAnswerId, currentAnswerId)) { + answerSequence++; + previousAnswerId = currentAnswerId; + } + if (isCalculatedPartCorrect(score, item, answerSequence, answersMap, answersMapValues, globalanswersMapValues, mainvariablesWithValues)) { + correctParts++; } } + + int attemptedParts = totalParts - blankParts; + if (attemptedParts <= 0) { + continue; + } + + String agentId = submissionScores.get(0).getAgentId(); + qbean.addStudentResponded(agentId); + if (correctParts == attemptedParts) { + results.merge(CORRECT, 1, Integer::sum); + qbean.addStudentWithAllCorrect(agentId); + } else { + results.merge(INCORRECT, 1, Integer::sum); + } } } // build the histogram bar for correct/incorrect answers + int totalResponses = results.getOrDefault(CORRECT, 0) + results.getOrDefault(INCORRECT, 0); List barList = new ArrayList<>(); - for (Map.Entry entry : results.entrySet()) { + for (Map.Entry resultEntry : results.entrySet()) { HistogramBarBean bar = new HistogramBarBean(); - bar.setLabel(entry.getKey()); - bar.setNumStudents(entry.getValue()); - bar.setNumStudentsText(String.valueOf(entry.getValue())); - bar.setNumStudentsText(entry.getValue() + " " + entry.getKey()); - bar.setIsCorrect(entry.getKey().equals(CORRECT)); + bar.setLabel(resultEntry.getKey()); + bar.setNumStudents(resultEntry.getValue()); + bar.setNumStudentsText(resultEntry.getValue() + " " + resultEntry.getKey()); + bar.setIsCorrect(resultEntry.getKey().equals(CORRECT)); int height = 0; - if (scores.size() > 0) { - height = COLUMN_MAX_HEIGHT * entry.getValue() / scores.size(); + if (totalResponses > 0) { + height = COLUMN_MAX_HEIGHT * resultEntry.getValue() / totalResponses; } bar.setColumnHeight(Integer.toString(height)); barList.add(bar); @@ -1815,14 +2037,31 @@ private void getCalculatedQuestionScores(List scores, Histogram bars = barList.toArray(bars); qbean.setHistogramBars(bars); - if (qbean.getNumResponses() > 0) { - int correct = total - results.get(INCORRECT); - double percentCorrect = ((double) correct / (double) total) * 100; - String percentCorrectStr = Integer.toString((int)percentCorrect); + if (totalResponses > 0) { + double percentCorrect = ((double) results.getOrDefault(CORRECT, 0) / (double) totalResponses) * 100; + String percentCorrectStr = Integer.toString((int) percentCorrect); qbean.setPercentCorrect(percentCorrectStr); } } + private boolean isCalculatedPartCorrect(ItemGradingData score, ItemDataIfc item, int answerSequence, + Map answersMap, LinkedHashMap answersMapValues, + LinkedHashMap globalanswersMapValues, LinkedHashMap mainvariablesWithValues) { + if (score.getIsCorrect() != null) { + return score.getIsCorrect(); + } + + Double autoScore = score.getAutoScore(); + if (autoScore != null) { + return autoScore > 0; + } + + // Legacy safety fallback when no persisted correctness is available. + delegate.extractCalcQAnswersArray(answersMap, answersMapValues, globalanswersMapValues, mainvariablesWithValues, + item, score.getAssessmentGradingId(), score.getAgentId()); + return delegate.getCalcQResult(score, item, answersMap, answerSequence); + } + private void getImageMapQuestionScores(Map publishedItemTextHash, Map publishedAnswerHash, List scores, HistogramQuestionScoresBean qbean, List labels) { @@ -2039,18 +2278,18 @@ private void getMatchingScores(Map publishedItemTextHash, Map publishedAnswerHas Iterator listiter = resultsForOneStudent.iterator(); int correctMatchesCount = 0; - while (listiter.hasNext()){ - ItemGradingData item = (ItemGradingData)listiter.next(); - - if (!delegate.isDistractor((ItemTextIfc) publishedItemTextHash.get(item.getPublishedItemTextId()))){ - // now check each answer in Matching - AnswerIfc answer = (AnswerIfc) publishedAnswerHash.get(item.getPublishedAnswerId()); - if (answer.getIsCorrect() == null || (!answer.getIsCorrect().booleanValue())){ - hasIncorrectMatches = true; - break; - }else{ - correctMatchesCount++; - } + while (listiter.hasNext()){ + ItemGradingData item = (ItemGradingData)listiter.next(); + + if (!delegate.isDistractor((ItemTextIfc) publishedItemTextHash.get(item.getPublishedItemTextId()))){ + // now check each answer in Matching + AnswerIfc answer = (AnswerIfc) publishedAnswerHash.get(item.getPublishedAnswerId()); + if (answer == null || !Boolean.TRUE.equals(answer.getIsCorrect())){ + hasIncorrectMatches = true; + break; + }else{ + correctMatchesCount++; + } } } diff --git a/samigo/samigo-app/src/java/org/sakaiproject/tool/assessment/ui/servlet/evaluation/ExportReportServlet.java b/samigo/samigo-app/src/java/org/sakaiproject/tool/assessment/ui/servlet/evaluation/ExportReportServlet.java index bdb7074c4b54..7a566893e204 100644 --- a/samigo/samigo-app/src/java/org/sakaiproject/tool/assessment/ui/servlet/evaluation/ExportReportServlet.java +++ b/samigo/samigo-app/src/java/org/sakaiproject/tool/assessment/ui/servlet/evaluation/ExportReportServlet.java @@ -257,6 +257,7 @@ private AssessmentReport itemAnalysisReport(String title, String subject, Histog } private List> itemAnalysisData(HistogramScoresBean histogramScoresBean) { + int maxNumberOfAnswers = histogramScoresBean.getMaxNumberOfAnswers(); return histogramScoresBean.getDetailedStatistics().stream() .map(itemStatistics -> { @@ -272,15 +273,21 @@ private List> itemAnalysisData(HistogramScoresBean histogramScoresB dataRow.add(itemStatistics.getDiscrimination()); } - if (histogramScoresBean.getMaxNumberOfAnswers() > 0) { - dataRow.add(itemStatistics.getDifficulty().toString()); - dataRow.add(itemStatistics.getNumberOfStudentsWithCorrectAnswers().toString()); - dataRow.add(itemStatistics.getNumberOfStudentsWithIncorrectAnswers().toString()); + if (maxNumberOfAnswers > 0) { + dataRow.add(toCellValue(itemStatistics.getDifficulty())); + dataRow.add(toCellValue(itemStatistics.getNumberOfStudentsWithCorrectAnswers())); + dataRow.add(toCellValue(itemStatistics.getNumberOfStudentsWithIncorrectAnswers())); dataRow.add(String.valueOf(itemStatistics.getNumberOfStudentsWithZeroAnswers())); } - HistogramBarBean[] histogramBars = itemStatistics.getHistogramBars(); - for (int i = 0; i < histogramBars.length; i++) { + HistogramBarBean[] histogramBars = Optional.ofNullable(itemStatistics.getHistogramBars()) + .orElse(new HistogramBarBean[0]); + for (int i = 0; i < maxNumberOfAnswers; i++) { + if (!itemStatistics.getShowIndividualAnswersInDetailedStatistics() + || i >= histogramBars.length || histogramBars[i] == null) { + dataRow.add(""); + continue; + } dataRow.add(String.valueOf(histogramBars[i].getNumStudents())); } @@ -288,6 +295,10 @@ private List> itemAnalysisData(HistogramScoresBean histogramScoresB }).collect(Collectors.toList()); } + private String toCellValue(Object value) { + return value == null ? "" : String.valueOf(value); + } + private List itemAnalysisHeader(HistogramScoresBean histogramScoresBean) { int itemCount = histogramScoresBean.getDetailedStatistics().size(); List header = new ArrayList<>(); diff --git a/samigo/samigo-app/src/test/org/sakaiproject/tool/assessment/ui/listener/evaluation/HistogramListenerTest.java b/samigo/samigo-app/src/test/org/sakaiproject/tool/assessment/ui/listener/evaluation/HistogramListenerTest.java new file mode 100644 index 000000000000..59d24ccfc65a --- /dev/null +++ b/samigo/samigo-app/src/test/org/sakaiproject/tool/assessment/ui/listener/evaluation/HistogramListenerTest.java @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2026 The Apereo Foundation + * + * Licensed under the Educational Community License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/ecl2 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.sakaiproject.tool.assessment.ui.listener.evaluation; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import org.junit.Test; +import org.sakaiproject.memory.api.Cache; +import org.sakaiproject.memory.api.MemoryService; +import org.sakaiproject.tool.assessment.data.dao.assessment.PublishedAnswer; +import org.sakaiproject.tool.assessment.data.dao.assessment.PublishedItemData; +import org.sakaiproject.tool.assessment.data.dao.grading.ItemGradingData; +import org.sakaiproject.tool.assessment.data.ifc.assessment.AnswerIfc; +import org.sakaiproject.tool.assessment.data.ifc.assessment.ItemDataIfc; +import org.sakaiproject.tool.assessment.data.ifc.shared.TypeIfc; +import org.sakaiproject.tool.assessment.facade.StatisticsFacadeQueriesAPI; +import org.sakaiproject.tool.assessment.services.GradingService; +import org.sakaiproject.tool.assessment.services.QuestionPoolService; +import org.sakaiproject.tool.assessment.services.assessment.StatisticsService; +import org.sakaiproject.tool.assessment.ui.bean.evaluation.HistogramQuestionScoresBean; + +public class HistogramListenerTest { + + @Test + public void testApplyCanonicalSubmissionTalliesMcssBlankCountedOnlyAsBlank() throws Exception { + HistogramListener listener = new HistogramListener(); + listener.setStatisticsService(createStatisticsService()); + + HistogramQuestionScoresBean qbean = new HistogramQuestionScoresBean(); + PublishedItemData item = new PublishedItemData(); + item.setItemId(1L); + item.setTypeId(TypeIfc.MULTIPLE_CORRECT_SINGLE_SELECTION); + + List scores = new ArrayList<>(); + // Student 1: correct + scores.add(itemGrading(1L, 100L, "student-1", 10L)); + // Student 2: incorrect + scores.add(itemGrading(2L, 101L, "student-2", 11L)); + // Student 3: blank + scores.add(itemGrading(3L, null, "student-3", 12L)); + + Map answersById = new HashMap<>(); + answersById.put(100L, answer(100L, true)); + answersById.put(101L, answer(101L, false)); + + invokeApplyCanonicalSubmissionTallies(listener, qbean, item, scores, answersById); + + assertEquals(2, qbean.getNumResponses()); + assertEquals(1, qbean.getNumberOfStudentsWithZeroAnswers()); + assertEquals(new TreeSet<>(Arrays.asList("student-1", "student-2")), qbean.getStudentsResponded()); + assertEquals(new TreeSet<>(Collections.singleton("student-1")), qbean.getStudentsWithAllCorrect()); + } + + private StatisticsService createStatisticsService() { + GradingService gradingService = new GradingService(); + MemoryService memoryService = mock(MemoryService.class); + Cache cache = mock(Cache.class); + when(memoryService.getCache(anyString())).thenReturn(cache); + QuestionPoolService questionPoolService = mock(QuestionPoolService.class); + StatisticsFacadeQueriesAPI statisticsFacadeQueries = mock(StatisticsFacadeQueriesAPI.class); + + return new StatisticsService(gradingService, memoryService, questionPoolService, statisticsFacadeQueries); + } + + private ItemGradingData itemGrading(Long itemGradingId, Long answerId, String agentId, Long assessmentGradingId) { + ItemGradingData data = new ItemGradingData(); + data.setItemGradingId(itemGradingId); + data.setAssessmentGradingId(assessmentGradingId); + data.setAgentId(agentId); + data.setPublishedAnswerId(answerId); + return data; + } + + private PublishedAnswer answer(Long answerId, boolean isCorrect) { + PublishedAnswer answer = new PublishedAnswer(); + answer.setId(answerId); + answer.setIsCorrect(isCorrect); + return answer; + } + + private void invokeApplyCanonicalSubmissionTallies(HistogramListener listener, HistogramQuestionScoresBean qbean, + ItemDataIfc item, List scores, Map answersById) throws Exception { + Method method = HistogramListener.class.getDeclaredMethod("applyCanonicalSubmissionTallies", + HistogramQuestionScoresBean.class, ItemDataIfc.class, List.class, Map.class); + method.setAccessible(true); + method.invoke(listener, qbean, item, scores, answersById); + } +} diff --git a/samigo/samigo-app/src/test/org/sakaiproject/tool/assessment/ui/servlet/evaluation/ExportReportServletTest.java b/samigo/samigo-app/src/test/org/sakaiproject/tool/assessment/ui/servlet/evaluation/ExportReportServletTest.java new file mode 100644 index 000000000000..40f8c9b9facf --- /dev/null +++ b/samigo/samigo-app/src/test/org/sakaiproject/tool/assessment/ui/servlet/evaluation/ExportReportServletTest.java @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2026 The Apereo Foundation + * + * Licensed under the Educational Community License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://opensource.org/licenses/ecl2 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.sakaiproject.tool.assessment.ui.servlet.evaluation; + +import static org.junit.Assert.assertEquals; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; + +import org.junit.Test; +import org.mockito.Mockito; +import org.sakaiproject.tool.assessment.ui.bean.evaluation.HistogramBarBean; +import org.sakaiproject.tool.assessment.ui.bean.evaluation.HistogramQuestionScoresBean; +import org.sakaiproject.tool.assessment.ui.bean.evaluation.HistogramScoresBean; + +public class ExportReportServletTest { + + @Test + public void testItemAnalysisDataHandlesNullTalliesAndPadsAnswerColumns() throws Exception { + ExportReportServlet servlet = allocateServletWithoutConstructor(); + HistogramScoresBean scoresBean = new HistogramScoresBean(); + scoresBean.setMaxNumberOfAnswers(2); + + HistogramQuestionScoresBean surveyRow = new HistogramQuestionScoresBean(); + surveyRow.setQuestionNumber("1"); + surveyRow.setNumResponses(0); + surveyRow.setPercentCorrect("0%"); + surveyRow.setShowIndividualAnswersInDetailedStatistics(false); + surveyRow.setNumberOfStudentsWithZeroAnswers(1); + + HistogramQuestionScoresBean answeredRow = new HistogramQuestionScoresBean(); + answeredRow.setQuestionNumber("2"); + answeredRow.setNumResponses(1); + answeredRow.setPercentCorrect("100%"); + answeredRow.setShowIndividualAnswersInDetailedStatistics(true); + answeredRow.setNumberOfStudentsWithZeroAnswers(0); + HistogramBarBean answerA = new HistogramBarBean(); + answerA.setNumStudents(1); + answeredRow.setHistogramBars(new HistogramBarBean[] {answerA}); + + scoresBean.setDetailedStatistics(Arrays.asList(surveyRow, answeredRow)); + + List> rows = invokeItemAnalysisData(servlet, scoresBean); + assertEquals(2, rows.size()); + + List firstRow = rows.get(0); + List secondRow = rows.get(1); + assertEquals(9, firstRow.size()); + assertEquals(9, secondRow.size()); + + assertEquals("", firstRow.get(3)); // Difficulty + assertEquals("", firstRow.get(4)); // Total Correct + assertEquals("", firstRow.get(5)); // Total Incorrect + assertEquals("1", firstRow.get(6)); // No Answer + assertEquals("", firstRow.get(7)); // A + assertEquals("", firstRow.get(8)); // B + + assertEquals("1", secondRow.get(7)); // A + assertEquals("", secondRow.get(8)); // B padded + } + + @SuppressWarnings("unchecked") + private List> invokeItemAnalysisData(ExportReportServlet servlet, HistogramScoresBean scoresBean) throws Exception { + Method method = ExportReportServlet.class.getDeclaredMethod("itemAnalysisData", HistogramScoresBean.class); + method.setAccessible(true); + return (List>) method.invoke(servlet, scoresBean); + } + + private ExportReportServlet allocateServletWithoutConstructor() throws Exception { + return Mockito.mock(ExportReportServlet.class, Mockito.CALLS_REAL_METHODS); + } +} diff --git a/samigo/samigo-services/src/java/org/sakaiproject/tool/assessment/facade/AssessmentGradingFacadeQueries.java b/samigo/samigo-services/src/java/org/sakaiproject/tool/assessment/facade/AssessmentGradingFacadeQueries.java index 8ce9af84570f..bd48ebf5883f 100644 --- a/samigo/samigo-services/src/java/org/sakaiproject/tool/assessment/facade/AssessmentGradingFacadeQueries.java +++ b/samigo/samigo-services/src/java/org/sakaiproject/tool/assessment/facade/AssessmentGradingFacadeQueries.java @@ -2359,12 +2359,15 @@ public List getExportResponsesData(String publishedAssessmentId, boolean anonymo AnswerIfc answer = (AnswerIfc) publishedAnswerHash.get(answerid); if (answer != null) { if (isOneSelectionType) { - if (!answer.getIsCorrect()) { + Boolean answerCorrectness = resolveOneSelectionCorrectness(answer, grade); + if (Boolean.TRUE.equals(answerCorrectness)) { + // For correct answers cases + responseList.set(emptyIndex - 2, ((int) responseList.get(emptyIndex - 2)) + 1); + } else if (Boolean.FALSE.equals(answerCorrectness)) { // For incorrect answers cases - responseList.set(emptyIndex-1, ((int) responseList.get(emptyIndex-1)) + 1); + responseList.set(emptyIndex - 1, ((int) responseList.get(emptyIndex - 1)) + 1); } else { - // For correct answers cases - responseList.set(emptyIndex-2, ((int) responseList.get(emptyIndex-2)) + 1); + log.debug("Skipping one-selection tally for answer {} due to unknown correctness", answerid); } } String temptext = answer.getText(); @@ -2617,6 +2620,29 @@ public List getExportResponsesData(String publishedAssessmentId, boolean anonymo return finalList; } + /** + * Resolve correctness for one-selection export counters without null unboxing. + * Order of precedence: + * 1) Published answer correctness flag + * 2) Item grading correctness flag + * 3) Item grading auto score sign + */ + Boolean resolveOneSelectionCorrectness(AnswerIfc answer, ItemGradingData grade) { + if (answer != null && answer.getIsCorrect() != null) { + return answer.getIsCorrect(); + } + + if (grade != null && grade.getIsCorrect() != null) { + return grade.getIsCorrect(); + } + + if (grade != null && grade.getAutoScore() != null) { + return grade.getAutoScore() > 0; + } + + return null; + } + /** * @param sectionItems diff --git a/samigo/samigo-services/src/java/org/sakaiproject/tool/assessment/services/assessment/StatisticsService.java b/samigo/samigo-services/src/java/org/sakaiproject/tool/assessment/services/assessment/StatisticsService.java index e03a1b3fa346..d089d712b6a6 100644 --- a/samigo/samigo-services/src/java/org/sakaiproject/tool/assessment/services/assessment/StatisticsService.java +++ b/samigo/samigo-services/src/java/org/sakaiproject/tool/assessment/services/assessment/StatisticsService.java @@ -19,6 +19,8 @@ import java.util.Collection; import java.util.Collections; import java.util.Comparator; +import java.util.EnumMap; +import java.util.EnumSet; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -39,6 +41,7 @@ import org.sakaiproject.tool.assessment.data.dao.assessment.PublishedAnswer; import org.sakaiproject.tool.assessment.data.dao.assessment.PublishedItemData; import org.sakaiproject.tool.assessment.data.dao.grading.ItemGradingData; +import org.sakaiproject.tool.assessment.data.ifc.assessment.AnswerIfc; import org.sakaiproject.tool.assessment.data.ifc.assessment.AssessmentIfc; import org.sakaiproject.tool.assessment.data.ifc.assessment.ItemDataIfc; import org.sakaiproject.tool.assessment.data.ifc.assessment.ItemTextIfc; @@ -58,6 +61,190 @@ @Slf4j public class StatisticsService { + public enum SubmissionOutcome { + CORRECT, + INCORRECT, + BLANK, + NOT_APPLICABLE + } + + public enum QuestionTypeCapability { + SUBMISSION_OUTCOME, + TOTAL_SCORES_TALLY, + DETAILED_STATISTICS, + DETAILED_STATISTICS_INDIVIDUAL_ANSWERS, + ANSWER_STATISTICS, + SCORE_STATISTICS, + SURVEY + } + + private static final EnumMap> QUESTION_TYPE_CAPABILITIES = buildQuestionTypeCapabilities(); + + private static EnumMap> buildQuestionTypeCapabilities() { + EnumMap> capabilities = new EnumMap<>(TypeId.class); + + registerTypeCapabilities(capabilities, TypeId.TRUE_FALSE_ID, + QuestionTypeCapability.SUBMISSION_OUTCOME, + QuestionTypeCapability.TOTAL_SCORES_TALLY, + QuestionTypeCapability.DETAILED_STATISTICS, + QuestionTypeCapability.DETAILED_STATISTICS_INDIVIDUAL_ANSWERS, + QuestionTypeCapability.ANSWER_STATISTICS); + registerTypeCapabilities(capabilities, TypeId.MULTIPLE_CHOICE_ID, + QuestionTypeCapability.SUBMISSION_OUTCOME, + QuestionTypeCapability.TOTAL_SCORES_TALLY, + QuestionTypeCapability.DETAILED_STATISTICS, + QuestionTypeCapability.DETAILED_STATISTICS_INDIVIDUAL_ANSWERS, + QuestionTypeCapability.ANSWER_STATISTICS); + registerTypeCapabilities(capabilities, TypeId.MULTIPLE_CORRECT_ID, + QuestionTypeCapability.SUBMISSION_OUTCOME, + QuestionTypeCapability.TOTAL_SCORES_TALLY, + QuestionTypeCapability.DETAILED_STATISTICS, + QuestionTypeCapability.DETAILED_STATISTICS_INDIVIDUAL_ANSWERS, + QuestionTypeCapability.ANSWER_STATISTICS); + registerTypeCapabilities(capabilities, TypeId.MULTIPLE_CORRECT_SINGLE_SELECTION_ID, + QuestionTypeCapability.SUBMISSION_OUTCOME, + QuestionTypeCapability.TOTAL_SCORES_TALLY, + QuestionTypeCapability.DETAILED_STATISTICS, + QuestionTypeCapability.DETAILED_STATISTICS_INDIVIDUAL_ANSWERS, + QuestionTypeCapability.ANSWER_STATISTICS); + registerTypeCapabilities(capabilities, TypeId.FILL_IN_BLANK_ID, + QuestionTypeCapability.SUBMISSION_OUTCOME, + QuestionTypeCapability.TOTAL_SCORES_TALLY, + QuestionTypeCapability.DETAILED_STATISTICS, + QuestionTypeCapability.DETAILED_STATISTICS_INDIVIDUAL_ANSWERS, + QuestionTypeCapability.ANSWER_STATISTICS); + registerTypeCapabilities(capabilities, TypeId.FILL_IN_NUMERIC_ID, + QuestionTypeCapability.SUBMISSION_OUTCOME, + QuestionTypeCapability.TOTAL_SCORES_TALLY, + QuestionTypeCapability.DETAILED_STATISTICS, + QuestionTypeCapability.DETAILED_STATISTICS_INDIVIDUAL_ANSWERS, + QuestionTypeCapability.ANSWER_STATISTICS); + registerTypeCapabilities(capabilities, TypeId.MATCHING_ID, + QuestionTypeCapability.SUBMISSION_OUTCOME, + QuestionTypeCapability.TOTAL_SCORES_TALLY, + QuestionTypeCapability.DETAILED_STATISTICS, + QuestionTypeCapability.DETAILED_STATISTICS_INDIVIDUAL_ANSWERS, + QuestionTypeCapability.ANSWER_STATISTICS); + registerTypeCapabilities(capabilities, TypeId.EXTENDED_MATCHING_ITEMS_ID, + QuestionTypeCapability.SUBMISSION_OUTCOME, + QuestionTypeCapability.ANSWER_STATISTICS); + registerTypeCapabilities(capabilities, TypeId.CALCULATED_QUESTION_ID, + QuestionTypeCapability.SUBMISSION_OUTCOME, + QuestionTypeCapability.TOTAL_SCORES_TALLY, + QuestionTypeCapability.DETAILED_STATISTICS, + QuestionTypeCapability.DETAILED_STATISTICS_INDIVIDUAL_ANSWERS, + QuestionTypeCapability.ANSWER_STATISTICS); + registerTypeCapabilities(capabilities, TypeId.IMAGEMAP_QUESTION_ID, + QuestionTypeCapability.SUBMISSION_OUTCOME, + QuestionTypeCapability.TOTAL_SCORES_TALLY, + QuestionTypeCapability.DETAILED_STATISTICS, + QuestionTypeCapability.DETAILED_STATISTICS_INDIVIDUAL_ANSWERS, + QuestionTypeCapability.ANSWER_STATISTICS); + registerTypeCapabilities(capabilities, TypeId.MULTIPLE_CHOICE_SURVEY_ID, + QuestionTypeCapability.DETAILED_STATISTICS, + QuestionTypeCapability.DETAILED_STATISTICS_INDIVIDUAL_ANSWERS, + QuestionTypeCapability.ANSWER_STATISTICS, + QuestionTypeCapability.SURVEY); + registerTypeCapabilities(capabilities, TypeId.MATRIX_CHOICES_SURVEY_ID, + QuestionTypeCapability.DETAILED_STATISTICS, + QuestionTypeCapability.DETAILED_STATISTICS_INDIVIDUAL_ANSWERS, + QuestionTypeCapability.ANSWER_STATISTICS, + QuestionTypeCapability.SURVEY); + registerTypeCapabilities(capabilities, TypeId.ESSAY_QUESTION_ID, QuestionTypeCapability.SCORE_STATISTICS); + registerTypeCapabilities(capabilities, TypeId.FILE_UPLOAD_ID, QuestionTypeCapability.SCORE_STATISTICS); + registerTypeCapabilities(capabilities, TypeId.AUDIO_RECORDING_ID, QuestionTypeCapability.SCORE_STATISTICS); + + return capabilities; + } + + private static void registerTypeCapabilities(EnumMap> capabilities, + TypeId typeId, QuestionTypeCapability... questionTypeCapabilities) { + capabilities.put(typeId, EnumSet.copyOf(List.of(questionTypeCapabilities))); + } + + public static boolean hasQuestionTypeCapability(Long typeId, QuestionTypeCapability capability) { + return hasQuestionTypeCapability(toTypeId(typeId), capability); + } + + public static boolean hasQuestionTypeCapability(String typeId, QuestionTypeCapability capability) { + return hasQuestionTypeCapability(toTypeId(typeId), capability); + } + + private static boolean hasQuestionTypeCapability(TypeId typeId, QuestionTypeCapability capability) { + if (typeId == null || capability == null) { + return false; + } + + EnumSet capabilities = QUESTION_TYPE_CAPABILITIES.get(typeId); + return capabilities != null && capabilities.contains(capability); + } + + public static Set getQuestionTypeCapabilities(Long typeId) { + TypeId resolvedTypeId = toTypeId(typeId); + if (resolvedTypeId == null) { + return Collections.emptySet(); + } + + EnumSet capabilities = QUESTION_TYPE_CAPABILITIES.get(resolvedTypeId); + if (capabilities == null || capabilities.isEmpty()) { + return Collections.emptySet(); + } + return EnumSet.copyOf(capabilities); + } + + public static boolean supportsSubmissionOutcome(Long typeId) { + return hasQuestionTypeCapability(typeId, QuestionTypeCapability.SUBMISSION_OUTCOME); + } + + public static boolean supportsTotalScoresTally(Long typeId) { + return hasQuestionTypeCapability(typeId, QuestionTypeCapability.TOTAL_SCORES_TALLY); + } + + public static boolean includesInDetailedStatistics(String typeId) { + return hasQuestionTypeCapability(typeId, QuestionTypeCapability.DETAILED_STATISTICS); + } + + public static boolean showsIndividualAnswersInDetailedStatistics(String typeId) { + return hasQuestionTypeCapability(typeId, QuestionTypeCapability.DETAILED_STATISTICS_INDIVIDUAL_ANSWERS); + } + + public static boolean supportsAnswerStatistics(String typeId) { + return hasQuestionTypeCapability(typeId, QuestionTypeCapability.ANSWER_STATISTICS); + } + + public static boolean supportsScoreStatistics(String typeId) { + return hasQuestionTypeCapability(typeId, QuestionTypeCapability.SCORE_STATISTICS); + } + + public static boolean isSurveyQuestionType(String typeId) { + return hasQuestionTypeCapability(typeId, QuestionTypeCapability.SURVEY); + } + + private static TypeId toTypeId(Long typeId) { + if (typeId == null || !TypeId.isValidId(typeId.longValue())) { + return null; + } + return TypeId.getInstance(typeId.longValue()); + } + + private static TypeId toTypeId(String typeId) { + if (StringUtils.isBlank(typeId)) { + return null; + } + + long parsedTypeId; + try { + parsedTypeId = Long.parseLong(typeId); + } catch (NumberFormatException e) { + return null; + } + + if (!TypeId.isValidId(parsedTypeId)) { + return null; + } + return TypeId.getInstance(parsedTypeId); + } + public static final String QP_STATISTICS_CACHE_NAME = StatisticsService.class.getPackageName() + "." + QuestionPoolStatistics.CACHE_NAME; @@ -167,42 +354,223 @@ public long getUsageCount(@NonNull Collection items) { .count(); } - private ItemStatistics getItemStatistics(ItemDataIfc item, Set gradingData, Set answers) { - Long itemType = item.getTypeId(); - if (itemType == null || !TypeId.isValidId(itemType.longValue())) { - log.warn("Can not create TypeId from type id {}", itemType); - return null; + public SubmissionOutcome classifySubmission(ItemDataIfc item, Collection submissionGradingData, + Map answerMap) { + Long itemType = item == null ? null : item.getTypeId(); + if (!supportsSubmissionOutcome(itemType)) { + return SubmissionOutcome.NOT_APPLICABLE; + } + + Set submissionSet = submissionGradingData == null ? Collections.emptySet() : submissionGradingData + .stream() + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (submissionSet.isEmpty()) { + return SubmissionOutcome.BLANK; + } + + Set publishedAnswers = toPublishedAnswerSet(item, submissionSet, answerMap); + ItemStatistics itemStatistics; + TypeId resolvedTypeId = toTypeId(itemType); + if (resolvedTypeId == null) { + return SubmissionOutcome.NOT_APPLICABLE; } - switch (TypeId.getInstance(itemType)) { + switch (resolvedTypeId) { case TRUE_FALSE_ID: case MULTIPLE_CHOICE_ID: - return getItemStatisticsForItemWithOneCorrectAnswer(gradingData, answers); + itemStatistics = getItemStatisticsForItemWithOneCorrectAnswer(submissionSet, publishedAnswers); + break; case MULTIPLE_CORRECT_ID: + itemStatistics = getItemStatisticsForItemWithMultipleCorrectAnswers(submissionSet, publishedAnswers); + break; case MULTIPLE_CORRECT_SINGLE_SELECTION_ID: - return getItemStatisticsForItemWithMultipleCorrectAnswers(gradingData, answers); + itemStatistics = getItemStatisticsForMultipleCorrectSingleSelectionItem(submissionSet, publishedAnswers); + break; case FILL_IN_BLANK_ID: case FILL_IN_NUMERIC_ID: - return getItemStatisticsForFillInItem(item, gradingData, answers); + itemStatistics = getItemStatisticsForFillInItem(item, submissionSet, publishedAnswers); + break; case MATCHING_ID: - return getItemStatisticsForMatchingItem(gradingData, answers); + itemStatistics = getItemStatisticsForMatchingItem(submissionSet, publishedAnswers); + break; case EXTENDED_MATCHING_ITEMS_ID: - return getItemStatisticsForExtendedMatchingItem(item, gradingData, answers); + itemStatistics = getItemStatisticsForExtendedMatchingItem(item, submissionSet, publishedAnswers); + break; case CALCULATED_QUESTION_ID: - return getItemStatisticsForCalculatedQuestion(item, gradingData); + itemStatistics = getItemStatisticsForCalculatedQuestion(item, submissionSet); + break; case IMAGEMAP_QUESTION_ID: - return getItemStatisticsForHotSpotItem(item, gradingData); - case ESSAY_QUESTION_ID: - case FILE_UPLOAD_ID: - case AUDIO_RECORDING_ID: - case MATRIX_CHOICES_SURVEY_ID: - case MULTIPLE_CHOICE_SURVEY_ID: - log.debug("Ignored type with id {}", itemType); - return ItemStatistics.builder().build(); + itemStatistics = getItemStatisticsForHotSpotItem(item, submissionSet); + break; default: - log.warn("Unhandled type with id {}", itemType); - return ItemStatistics.builder().build(); + return SubmissionOutcome.NOT_APPLICABLE; + } + + return toSubmissionOutcome(itemStatistics); + } + + private SubmissionOutcome toSubmissionOutcome(ItemStatistics itemStatistics) { + if (itemStatistics == null) { + return SubmissionOutcome.NOT_APPLICABLE; + } + + long incorrectResponses = itemStatistics.getIncorrectResponses() == null ? 0 : itemStatistics.getIncorrectResponses(); + if (incorrectResponses > 0) { + return SubmissionOutcome.INCORRECT; + } + + long correctResponses = itemStatistics.getCorrectResponses() == null ? 0 : itemStatistics.getCorrectResponses(); + if (correctResponses > 0) { + return SubmissionOutcome.CORRECT; + } + + long blankResponses = itemStatistics.getBlankResponses() == null ? 0 : itemStatistics.getBlankResponses(); + if (blankResponses > 0) { + return SubmissionOutcome.BLANK; + } + + return SubmissionOutcome.NOT_APPLICABLE; + } + + private Set toPublishedAnswerSet(ItemDataIfc item, Set submissionSet, + Map answerMap) { + Map publishedAnswers = new HashMap<>(); + Long itemId = item == null ? null : item.getItemId(); + Set selectedAnswerIds = submissionSet.stream() + .map(ItemGradingData::getPublishedAnswerId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + List itemAnswers = getItemAnswers(item); + boolean hasItemAnswers = !itemAnswers.isEmpty(); + + for (AnswerIfc answer : itemAnswers) { + PublishedAnswer publishedAnswer = toPublishedAnswer(answer); + if (publishedAnswer != null && publishedAnswer.getId() != null) { + publishedAnswers.put(publishedAnswer.getId(), publishedAnswer); + } + } + + if (answerMap != null) { + if (hasItemAnswers) { + for (Long answerId : selectedAnswerIds) { + AnswerIfc answer = answerMap.get(answerId); + PublishedAnswer publishedAnswer = toPublishedAnswer(answer); + if (publishedAnswer != null && publishedAnswer.getId() != null) { + publishedAnswers.put(publishedAnswer.getId(), publishedAnswer); + } + } + } else { + for (AnswerIfc answer : answerMap.values()) { + if (answer == null || answer.getId() == null) { + continue; + } + boolean sameItem = answer.getItem() != null && itemId != null + && itemId.equals(answer.getItem().getItemId()); + if (!sameItem && !selectedAnswerIds.contains(answer.getId())) { + continue; + } + PublishedAnswer publishedAnswer = toPublishedAnswer(answer); + if (publishedAnswer != null) { + publishedAnswers.put(publishedAnswer.getId(), publishedAnswer); + } + } + } + } + + return new LinkedHashSet<>(publishedAnswers.values()); + } + + private List getItemAnswers(ItemDataIfc item) { + List answers = new ArrayList<>(); + if (item == null || item.getItemTextSet() == null) { + return answers; + } + + for (ItemTextIfc itemText : item.getItemTextSet()) { + if (itemText == null || itemText.getAnswerSet() == null) { + continue; + } + for (Object answerObject : itemText.getAnswerSet()) { + AnswerIfc answer = (AnswerIfc) answerObject; + if (answer != null) { + answers.add(answer); + } + } + } + return answers; + } + + private PublishedAnswer toPublishedAnswer(AnswerIfc answer) { + if (answer == null) { + return null; + } + + if (answer instanceof PublishedAnswer) { + return (PublishedAnswer) answer; + } + + PublishedAnswer publishedAnswer = new PublishedAnswer(); + publishedAnswer.setId(answer.getId()); + publishedAnswer.setIsCorrect(answer.getIsCorrect()); + publishedAnswer.setScore(answer.getScore()); + return publishedAnswer; + } + + private ItemStatistics getItemStatistics(ItemDataIfc item, Set gradingData, Set answers) { + Long itemType = item == null ? null : item.getTypeId(); + if (itemType == null || !TypeId.isValidId(itemType.longValue())) { + log.warn("Can not create TypeId from type id {}", itemType); + return null; + } + + Map answerMap = answers.stream() + .collect(Collectors.toMap(PublishedAnswer::getId, Function.identity(), (existing, replacement) -> existing, HashMap::new)); + + Map> gradingBySubmission = groupGradingDataBySubmission(gradingData); + long correctResponses = 0; + long incorrectResponses = 0; + long blankResponses = 0; + + for (List submissionGradingData : gradingBySubmission.values()) { + SubmissionOutcome submissionOutcome = classifySubmission(item, submissionGradingData, answerMap); + if (submissionOutcome == SubmissionOutcome.CORRECT) { + correctResponses++; + } else if (submissionOutcome == SubmissionOutcome.INCORRECT) { + incorrectResponses++; + } else if (submissionOutcome == SubmissionOutcome.BLANK) { + blankResponses++; + } + } + + long attemptedResponses = correctResponses + incorrectResponses; + + return ItemStatistics.builder() + .attemptedResponses(attemptedResponses) + .correctResponses(correctResponses) + .incorrectResponses(incorrectResponses) + .blankResponses(blankResponses) + .calcDifficulty() + .build(); + } + + private Map> groupGradingDataBySubmission(Set gradingData) { + Map> grouped = new HashMap<>(); + long syntheticKey = Long.MIN_VALUE; + for (ItemGradingData itemGradingData : gradingData) { + if (itemGradingData == null) { + continue; + } + + Long assessmentGradingId = itemGradingData.getAssessmentGradingId(); + if (assessmentGradingId == null) { + Long itemGradingId = itemGradingData.getItemGradingId(); + assessmentGradingId = itemGradingId == null ? syntheticKey++ : -Math.abs(itemGradingId); + } + + grouped.computeIfAbsent(assessmentGradingId, key -> new ArrayList<>()).add(itemGradingData); } + return grouped; } // Item is considered correct if one of the answers is correct @@ -221,19 +589,65 @@ private ItemStatistics getItemStatisticsForItemWithOneCorrectAnswer(Set gradingData, Set answers) { + Map> itemgradingDataByAssessmentGradingId = gradingData.stream() + .collect(Collectors.groupingBy(ItemGradingData::getAssessmentGradingId, Collectors.toSet())); + + Map answerMap = answers.stream() + .collect(Collectors.toMap(PublishedAnswer::getId, Function.identity())); + + long correctResponses = 0; + long incorrectResponses = 0; + long blankResponses = 0; + + for (Set submissionItemGradingData : itemgradingDataByAssessmentGradingId.values()) { + boolean hasAnsweredOption = false; + boolean hasCorrectAnswer = false; + boolean hasIncorrectAnswer = false; + for (ItemGradingData itemGradingData : submissionItemGradingData) { + if (!isMcssResponsePresent(itemGradingData)) { + continue; + } + + hasAnsweredOption = true; + if (isMcssSelectionCorrect(itemGradingData, answerMap)) { + hasCorrectAnswer = true; + } else { + hasIncorrectAnswer = true; + } + + if (hasIncorrectAnswer) { + break; + } + } + + if (!hasAnsweredOption) { + blankResponses++; continue; } - if (answerCorrect) { + if (hasCorrectAnswer && !hasIncorrectAnswer) { correctResponses++; } else { incorrectResponses++; @@ -251,6 +665,89 @@ private ItemStatistics getItemStatisticsForItemWithOneCorrectAnswer(Set answerMap) { + if (itemGradingData.getIsCorrect() != null) { + return itemGradingData.getIsCorrect(); + } + + Double autoScore = itemGradingData.getAutoScore(); + if (autoScore != null) { + return autoScore > 0; + } + + Long selectedAnswerId = itemGradingData.getPublishedAnswerId(); + if (selectedAnswerId == null) { + return false; + } + + PublishedAnswer selectedAnswer = answerMap.get(selectedAnswerId); + if (selectedAnswer != null && selectedAnswer.getIsCorrect() != null) { + return selectedAnswer.getIsCorrect(); + } + + if (selectedAnswer == null) { + log.warn(LOG_GRADING_DATA_ANSWER_NOT_FOUND, selectedAnswerId, itemGradingData.getItemGradingId()); + } else { + log.warn(LOG_ANSWER_IS_CORRECT_IS_NULL, selectedAnswer.getId()); + } + + if (selectedAnswer != null && selectedAnswer.getScore() != null) { + return selectedAnswer.getScore() > 0; + } + + return false; + } + + private boolean isSelectedAnswerCorrect(ItemGradingData itemGradingData, Map answerMap) { + Long selectedAnswerId = itemGradingData.getPublishedAnswerId(); + if (selectedAnswerId == null) { + return false; + } + + PublishedAnswer selectedAnswer = answerMap.get(selectedAnswerId); + if (selectedAnswer != null && selectedAnswer.getIsCorrect() != null) { + return selectedAnswer.getIsCorrect(); + } + + if (selectedAnswer == null) { + log.warn(LOG_GRADING_DATA_ANSWER_NOT_FOUND, selectedAnswerId, itemGradingData.getItemGradingId()); + } else { + log.warn(LOG_ANSWER_IS_CORRECT_IS_NULL, selectedAnswer.getId()); + } + + if (itemGradingData.getIsCorrect() != null) { + return itemGradingData.getIsCorrect(); + } + + Double autoScore = itemGradingData.getAutoScore(); + if (autoScore != null) { + return autoScore > 0; + } + + if (selectedAnswer != null && selectedAnswer.getScore() != null) { + return selectedAnswer.getScore() > 0; + } + + return false; + } + // Item is considered correct, if all the selected answers are correct and the // number of selected answers is correct private ItemStatistics getItemStatisticsForFillInItem(ItemDataIfc item, Set gradingData, @@ -271,13 +768,14 @@ private ItemStatistics getItemStatisticsForFillInItem(ItemDataIfc item, Set submissionItemGradingData : itemgradingDataByAssessmentGradingId.values()) { int selectedAnswerCount = submissionItemGradingData.size(); - Boolean hasIncorrectAnswer = null; + boolean hasIncorrectAnswer = false; + boolean hasBlankAnswer = false; for (ItemGradingData itemGradingData : submissionItemGradingData) { Long selectedAnswerId = itemGradingData.getPublishedAnswerId(); @@ -430,35 +934,40 @@ private ItemStatistics getItemStatisticsForItemWithMultipleCorrectAnswers(Set 0) { + incorrectResponses++; } } @@ -493,48 +1002,57 @@ private ItemStatistics getItemStatisticsForExtendedMatchingItem(ItemDataIfc item for (Map> submissionItemGradingData : groupedGradingData.values()) { long requiredOptionsCount = 0; int correctOptions = 0; - boolean isBlank = false; + boolean hasAnsweredOption = false; + boolean hasIncorrectOption = false; // One option of an EMI item can have multiple answers, so we need to iterate // through the grading data by item text - submissionLoop: for (Map.Entry> submissionGradingDataEntry : submissionItemGradingData.entrySet()) { Long itemTextId = submissionGradingDataEntry.getKey(); - ItemTextIfc itemText = itemTextMap.get(itemTextId); - Set itemTextGradingData = submissionGradingDataEntry.getValue(); - requiredOptionsCount++; + ItemTextIfc itemText = itemTextMap.get(itemTextId); + if (itemText == null) { + log.warn("Could not find ItemText with id {} for EMI grading data", itemTextId); + if (itemTextGradingData.stream().anyMatch(option -> option.getPublishedAnswerId() != null)) { + hasAnsweredOption = true; + } + hasIncorrectOption = true; + continue; + } + Integer requiredAnswerCount = itemText.getRequiredOptionsCount(); if (requiredAnswerCount == null) { log.warn("requiredOptionsCount is null on ItemText with id {}", itemTextId); + hasIncorrectOption = true; continue; } int correctAnswers = 0; int incorrectAnswers = 0; + boolean hasBlankAnswer = false; for (ItemGradingData optionGradingData : itemTextGradingData) { Long selectedAnswerId = optionGradingData.getPublishedAnswerId(); if (selectedAnswerId == null) { - // With a blank answer there should only one ItemGradingData per submission - // But to be safe, let's break out of the loop to avoid double counting - blankResponses++; - isBlank = true; - break submissionLoop; + hasBlankAnswer = true; + continue; } + hasAnsweredOption = true; PublishedAnswer selectedAnswer = answerMap.get(selectedAnswerId); if (selectedAnswer == null) { log.warn(LOG_GRADING_DATA_ANSWER_NOT_FOUND, selectedAnswerId, optionGradingData.getItemGradingId()); + incorrectAnswers++; continue; } Boolean answerCorrect = selectedAnswer.getIsCorrect(); if (answerCorrect == null) { log.warn(LOG_ANSWER_IS_CORRECT_IS_NULL, selectedAnswerId); + incorrectAnswers++; continue; } @@ -545,17 +1063,22 @@ private ItemStatistics getItemStatisticsForExtendedMatchingItem(ItemDataIfc item } } - if (incorrectAnswers <= 0 && correctAnswers >= requiredAnswerCount) { + if (!hasBlankAnswer && incorrectAnswers <= 0 && correctAnswers >= requiredAnswerCount) { correctOptions++; + } else { + hasIncorrectOption = true; } } - if (!isBlank) { - if (correctOptions == requiredOptionsCount) { - correctResponses++; - } else { - incorrectResponses++; - } + if (!hasAnsweredOption) { + blankResponses++; + continue; + } + + if (!hasIncorrectOption && correctOptions == requiredOptionsCount) { + correctResponses++; + } else { + incorrectResponses++; } } @@ -572,7 +1095,7 @@ private ItemStatistics getItemStatisticsForExtendedMatchingItem(ItemDataIfc item private ItemStatistics getItemStatisticsForCalculatedQuestion(ItemDataIfc item, Set gradingData) { Map> itemGradingDataByAssessmentGradingId = gradingData.stream() - .sorted(Comparator.comparing(ItemGradingData::getPublishedAnswerId)) + .sorted(Comparator.comparing(ItemGradingData::getPublishedAnswerId, Comparator.nullsLast(Long::compareTo))) .collect(Collectors.groupingBy(ItemGradingData::getAssessmentGradingId, Collectors.toCollection(LinkedHashSet::new))); long correctResponses = 0; @@ -611,10 +1134,11 @@ private ItemStatistics getItemStatisticsForCalculatedQuestion(ItemDataIfc item, } int maxAnswerSize = submissionItemGradingData.size(); - if (maxAnswerSize == correctAnswers) { - correctResponses++; - } else if (maxAnswerSize == blankAnswers) { + long attemptedAnswers = maxAnswerSize - blankAnswers; + if (attemptedAnswers <= 0) { blankResponses++; + } else if (attemptedAnswers == correctAnswers) { + correctResponses++; } else { incorrectResponses++; } @@ -665,16 +1189,17 @@ private ItemStatistics getItemStatisticsForHotSpotItem(ItemDataIfc item, Set gradedItemAnswers = Set.of( + answer(0L, true), + answer(1L, false) + ); + + Set gradedItemData = Set.of( + gradingData(0L, 0L), // correct + gradingData(1L, 1L), // incorrect + gradingData(2L, null) // blank + ); + + ItemGradingData ignoredSubmissionA = gradingData(10L, null, 10L); + ignoredSubmissionA.setAnswerText("essay answer a"); + ItemGradingData ignoredSubmissionB = gradingData(11L, null, 11L); + ignoredSubmissionB.setAnswerText("essay answer b"); + Set ignoredItemData = Set.of(ignoredSubmissionA, ignoredSubmissionB); + + List items = List.of(gradedItem, ignoredItem); + Map> answerMap = Map.of( + gradedItemId, gradedItemAnswers, + ignoredItemId, Collections.emptySet() + ); + Map> gradingDataMap = Map.of( + gradedItemId, gradedItemData, + ignoredItemId, ignoredItemData + ); + + stubData(items, answerMap, gradingDataMap); + + QuestionPoolStatistics poolStatistics = statisticsService.getQuestionPoolStatistics(0L); + ItemStatistics itemStatistics = poolStatistics.getAggregatedItemStatistics(); + + assertEquals(Long.valueOf(2), itemStatistics.getAttemptedResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getCorrectResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getIncorrectResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getBlankResponses()); + assertEquals(Integer.valueOf(67), itemStatistics.getDifficulty()); + } + @Test public void testTrueFalseItem() { long itemId = 0L; @@ -276,6 +367,342 @@ public void testMultipleChoiceMultipleSelectionsItem() { assertEquals(Integer.valueOf(71), itemStatistics.getDifficulty()); } + @Test + public void testMultipleChoiceMultipleCorrectSingleSelectionItem() { + long itemId = 0L; + + PublishedItemData item = item(itemId, TypeIfc.MULTIPLE_CORRECT_SINGLE_SELECTION); + + Set itemAnswers = Set.of( + answer(0L, true), + answer(1L, false), + answer(2L, true), + answer(3L, false) + ); + + Set gradingData = Set.of( + // Correct + gradingData(0L, 0L, 0L), + // Incorrect + gradingData(1L, 1L, 1L), + // Blank + gradingData(2L, null, 2L), + // Correct + gradingData(3L, 2L, 3L), + // Incorrect + gradingData(4L, 3L, 4L), + // Blank + gradingData(5L, null, 5L), + // Mixed records in one submission => incorrect + gradingData(6L, 0L, 6L), + gradingData(7L, 1L, 6L) + ); + + List items = Collections.singletonList(item); + Map> gradingDataMap = Map.of(itemId, gradingData); + Map> answerMap = Map.of(itemId, itemAnswers); + + stubData(items, answerMap, gradingDataMap); + + QuestionPoolStatistics poolStatistics = statisticsService.getQuestionPoolStatistics(0L); + ItemStatistics itemStatistics = poolStatistics.getAggregatedItemStatistics(); + + assertEquals(Long.valueOf(5), itemStatistics.getAttemptedResponses()); + assertEquals(Long.valueOf(2), itemStatistics.getCorrectResponses()); + assertEquals(Long.valueOf(3), itemStatistics.getIncorrectResponses()); + assertEquals(Long.valueOf(2), itemStatistics.getBlankResponses()); + assertEquals(Integer.valueOf(71), itemStatistics.getDifficulty()); + } + + @Test + public void testMultipleChoiceMultipleCorrectSingleSelectionItemUsesFallbackWhenAnswerCorrectFlagMissing() { + long itemId = 0L; + PublishedItemData item = item(itemId, TypeIfc.MULTIPLE_CORRECT_SINGLE_SELECTION); + + PublishedAnswer missingCorrectFlagAnswer = new PublishedAnswer(); + missingCorrectFlagAnswer.setId(0L); + missingCorrectFlagAnswer.setIsCorrect(null); + missingCorrectFlagAnswer.setScore(1d); + + PublishedAnswer incorrectAnswer = new PublishedAnswer(); + incorrectAnswer.setId(1L); + incorrectAnswer.setIsCorrect(false); + incorrectAnswer.setScore(0d); + + Set itemAnswers = Set.of(missingCorrectFlagAnswer, incorrectAnswer); + + ItemGradingData correctViaAutoScore = gradingData(0L, 0L, 0L); + correctViaAutoScore.setAutoScore(1d); + ItemGradingData incorrect = gradingData(1L, 1L, 1L); + incorrect.setAutoScore(0d); + ItemGradingData blank = gradingData(2L, null, 2L); + + Set gradingData = new HashSet<>(); + gradingData.add(correctViaAutoScore); + gradingData.add(incorrect); + gradingData.add(blank); + + List items = Collections.singletonList(item); + Map> gradingDataMap = Map.of(itemId, gradingData); + Map> answerMap = Map.of(itemId, itemAnswers); + + stubData(items, answerMap, gradingDataMap); + + QuestionPoolStatistics poolStatistics = statisticsService.getQuestionPoolStatistics(0L); + ItemStatistics itemStatistics = poolStatistics.getAggregatedItemStatistics(); + + assertEquals(Long.valueOf(2), itemStatistics.getAttemptedResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getCorrectResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getIncorrectResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getBlankResponses()); + assertEquals(Integer.valueOf(67), itemStatistics.getDifficulty()); + } + + @Test + public void testMultipleChoiceMultipleCorrectSingleSelectionUsesGradingFallbackWithoutAnswerId() { + long itemId = 0L; + PublishedItemData item = item(itemId, TypeIfc.MULTIPLE_CORRECT_SINGLE_SELECTION); + + Set itemAnswers = Set.of( + answer(0L, true), + answer(1L, false) + ); + + ItemGradingData correctWithoutAnswerId = gradingData(0L, null, 0L); + correctWithoutAnswerId.setAnswerText("selected"); + correctWithoutAnswerId.setIsCorrect(true); + correctWithoutAnswerId.setAutoScore(1d); + + ItemGradingData incorrect = gradingData(1L, 1L, 1L); + incorrect.setAutoScore(0d); + + ItemGradingData blank = gradingData(2L, null, 2L); + + Set gradingData = Set.of(correctWithoutAnswerId, incorrect, blank); + + List items = Collections.singletonList(item); + Map> gradingDataMap = Map.of(itemId, gradingData); + Map> answerMap = Map.of(itemId, itemAnswers); + + stubData(items, answerMap, gradingDataMap); + + QuestionPoolStatistics poolStatistics = statisticsService.getQuestionPoolStatistics(0L); + ItemStatistics itemStatistics = poolStatistics.getAggregatedItemStatistics(); + + assertEquals(Long.valueOf(2), itemStatistics.getAttemptedResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getCorrectResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getIncorrectResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getBlankResponses()); + assertEquals(Integer.valueOf(67), itemStatistics.getDifficulty()); + } + + @Test + public void testMultipleChoiceMultipleCorrectSingleSelectionUsesGradingCorrectnessBeforeAnswerMetadata() { + long itemId = 0L; + PublishedItemData item = item(itemId, TypeIfc.MULTIPLE_CORRECT_SINGLE_SELECTION); + + Set itemAnswers = Set.of( + answer(0L, false), + answer(1L, false) + ); + + ItemGradingData correctViaItemGrading = gradingData(0L, 0L, 0L); + correctViaItemGrading.setIsCorrect(true); + correctViaItemGrading.setAutoScore(1d); + + ItemGradingData incorrect = gradingData(1L, 1L, 1L); + incorrect.setIsCorrect(false); + incorrect.setAutoScore(0d); + + ItemGradingData blank = gradingData(2L, null, 2L); + + Set gradingData = Set.of(correctViaItemGrading, incorrect, blank); + + List items = Collections.singletonList(item); + Map> gradingDataMap = Map.of(itemId, gradingData); + Map> answerMap = Map.of(itemId, itemAnswers); + + stubData(items, answerMap, gradingDataMap); + + QuestionPoolStatistics poolStatistics = statisticsService.getQuestionPoolStatistics(0L); + ItemStatistics itemStatistics = poolStatistics.getAggregatedItemStatistics(); + + assertEquals(Long.valueOf(2), itemStatistics.getAttemptedResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getCorrectResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getIncorrectResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getBlankResponses()); + assertEquals(Integer.valueOf(67), itemStatistics.getDifficulty()); + } + + @Test + public void testMultipleChoiceSingleSelectionUsesFallbackWhenAnswerDataIsIncomplete() { + long itemId = 0L; + PublishedItemData item = item(itemId, TypeIfc.MULTIPLE_CHOICE); + + PublishedAnswer missingCorrectFlagAnswer = new PublishedAnswer(); + missingCorrectFlagAnswer.setId(0L); + missingCorrectFlagAnswer.setIsCorrect(null); + missingCorrectFlagAnswer.setScore(1d); + + Set itemAnswers = Set.of(missingCorrectFlagAnswer); + + ItemGradingData correctViaAutoScore = gradingData(0L, 0L, 0L); + correctViaAutoScore.setAutoScore(1d); + + ItemGradingData correctViaIsCorrect = gradingData(1L, 99L, 1L); + correctViaIsCorrect.setIsCorrect(true); + + ItemGradingData incorrectViaFallback = gradingData(2L, 98L, 2L); + incorrectViaFallback.setAutoScore(0d); + + ItemGradingData blank = gradingData(3L, null, 3L); + + Set gradingData = Set.of(correctViaAutoScore, correctViaIsCorrect, incorrectViaFallback, blank); + + List items = Collections.singletonList(item); + Map> gradingDataMap = Map.of(itemId, gradingData); + Map> answerMap = Map.of(itemId, itemAnswers); + + stubData(items, answerMap, gradingDataMap); + + QuestionPoolStatistics poolStatistics = statisticsService.getQuestionPoolStatistics(0L); + ItemStatistics itemStatistics = poolStatistics.getAggregatedItemStatistics(); + + assertEquals(Long.valueOf(3), itemStatistics.getAttemptedResponses()); + assertEquals(Long.valueOf(2), itemStatistics.getCorrectResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getIncorrectResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getBlankResponses()); + assertEquals(Integer.valueOf(50), itemStatistics.getDifficulty()); + } + + @Test + public void testMultipleChoiceMultipleSelectionsTreatsUnknownAnswerCorrectnessAsIncorrect() { + long itemId = 0L; + PublishedItemData item = item(itemId, TypeIfc.MULTIPLE_CORRECT); + + Set itemAnswers = Set.of( + answer(0L, true), + answer(1L, true) + ); + + Set gradingData = Set.of( + // Correct submission + gradingData(0L, 0L, 0L), + gradingData(1L, 1L, 0L), + // Submission with one correct and one unresolved selected answer -> incorrect + gradingData(2L, 0L, 1L), + gradingData(3L, 99L, 1L), + // Blank submission + gradingData(4L, null, 2L) + ); + + List items = Collections.singletonList(item); + Map> gradingDataMap = Map.of(itemId, gradingData); + Map> answerMap = Map.of(itemId, itemAnswers); + + stubData(items, answerMap, gradingDataMap); + + QuestionPoolStatistics poolStatistics = statisticsService.getQuestionPoolStatistics(0L); + ItemStatistics itemStatistics = poolStatistics.getAggregatedItemStatistics(); + + assertEquals(Long.valueOf(2), itemStatistics.getAttemptedResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getCorrectResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getIncorrectResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getBlankResponses()); + assertEquals(Integer.valueOf(67), itemStatistics.getDifficulty()); + } + + @Test + public void testImageMapQuestionWithNullCorrectnessIsCountedAsIncorrect() { + long itemId = 0L; + PublishedItemData item = item(itemId, TypeIfc.IMAGEMAP_QUESTION); + + ItemGradingData incorrect = gradingData(0L, 0L, 0L); + incorrect.setAnswerText("10,10"); + incorrect.setIsCorrect(null); + + ItemGradingData blank = gradingData(1L, null, 1L); + blank.setAnswerText("undefined"); + + Set gradingData = Set.of(incorrect, blank); + + List items = Collections.singletonList(item); + Map> gradingDataMap = Map.of(itemId, gradingData); + Map> answerMap = Map.of(itemId, Collections.emptySet()); + + stubData(items, answerMap, gradingDataMap); + + QuestionPoolStatistics poolStatistics = statisticsService.getQuestionPoolStatistics(0L); + ItemStatistics itemStatistics = poolStatistics.getAggregatedItemStatistics(); + + assertEquals(Long.valueOf(1), itemStatistics.getAttemptedResponses()); + assertEquals(Long.valueOf(0), itemStatistics.getCorrectResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getIncorrectResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getBlankResponses()); + assertEquals(Integer.valueOf(100), itemStatistics.getDifficulty()); + } + + @Test + public void testClassifyCalculatedSubmissionTreatsBlankPartsAsBlank() { + GradingService gradingService = mock(GradingService.class); + StatisticsService service = new StatisticsService(gradingService, memoryService, questionPoolService, statisticsFacadeQueries); + + PublishedItemData item = item(0L, TypeIfc.CALCULATED_QUESTION); + + ItemGradingData withNullAnswerId = gradingData(0L, null, 0L); + withNullAnswerId.setAnswerText(""); + + ItemGradingData withAnswerIdButBlankText = gradingData(1L, 10L, 0L); + withAnswerIdButBlankText.setAnswerText(" "); + + SubmissionOutcome outcome = service.classifySubmission(item, + List.of(withNullAnswerId, withAnswerIdButBlankText), Collections.emptyMap()); + + assertEquals(SubmissionOutcome.BLANK, outcome); + } + + @Test + public void testClassifyCalculatedSubmissionCanBeCorrect() { + GradingService gradingService = mock(GradingService.class); + doReturn(true).when(gradingService).getCalcQResult(any(), any(), any(), anyInt()); + StatisticsService service = new StatisticsService(gradingService, memoryService, questionPoolService, statisticsFacadeQueries); + + PublishedItemData item = item(0L, TypeIfc.CALCULATED_QUESTION); + ItemGradingData attemptedPart = gradingData(0L, 10L, 0L); + attemptedPart.setAnswerText("42"); + + SubmissionOutcome outcome = service.classifySubmission(item, + Collections.singletonList(attemptedPart), Collections.emptyMap()); + + assertEquals(SubmissionOutcome.CORRECT, outcome); + } + + @Test + public void testClassifyHotSpotSubmissionWithTrueCorrectnessIsCorrect() { + PublishedItemData item = item(0L, TypeIfc.IMAGEMAP_QUESTION); + ItemGradingData grading = gradingData(0L, 0L, 0L); + grading.setAnswerText("10,10"); + grading.setIsCorrect(Boolean.TRUE); + + SubmissionOutcome outcome = statisticsService.classifySubmission(item, + Collections.singletonList(grading), Collections.emptyMap()); + + assertEquals(SubmissionOutcome.CORRECT, outcome); + } + + @Test + public void testClassifyHotSpotSubmissionUndefinedCoordinatesIsBlank() { + PublishedItemData item = item(0L, TypeIfc.IMAGEMAP_QUESTION); + ItemGradingData grading = gradingData(0L, 0L, 0L); + grading.setAnswerText("undefined"); + grading.setIsCorrect(Boolean.TRUE); + + SubmissionOutcome outcome = statisticsService.classifySubmission(item, + Collections.singletonList(grading), Collections.emptyMap()); + + assertEquals(SubmissionOutcome.BLANK, outcome); + } + @Test public void testMatchingItem() { long itemId = 0L; @@ -523,6 +950,89 @@ public void testExtendedMatchingItem() { assertEquals(Integer.valueOf(86), itemStatistics.getDifficulty()); } + @Test + public void testExtendedMatchingItemPartialBlankIsIncorrect() { + long itemTextA = 10L; + long itemTextB = 11L; + Set itemTexts = Set.of( + itemText(itemTextA, 1), + itemText(itemTextB, 1) + ); + + long itemId = 0L; + PublishedItemData item = item(itemId, TypeIfc.EXTENDED_MATCHING_ITEMS, itemTexts); + + Set itemAnswers = Set.of( + answer(100L, true), + answer(101L, false), + answer(200L, true), + answer(201L, false) + ); + + Set gradingData = Set.of( + // All blank - blank + gradingData(0L, null, 0L, itemTextA), + gradingData(1L, null, 0L, itemTextB), + // One answered, one blank - incorrect + gradingData(2L, 100L, 1L, itemTextA), + gradingData(3L, null, 1L, itemTextB), + // All correct - correct + gradingData(4L, 100L, 2L, itemTextA), + gradingData(5L, 200L, 2L, itemTextB) + ); + + List items = Collections.singletonList(item); + Map> gradingDataMap = Map.of(itemId, gradingData); + Map> answerMap = Map.of(itemId, itemAnswers); + + stubData(items, answerMap, gradingDataMap); + + QuestionPoolStatistics poolStatistics = statisticsService.getQuestionPoolStatistics(0L); + ItemStatistics itemStatistics = poolStatistics.getAggregatedItemStatistics(); + + assertEquals(Long.valueOf(2), itemStatistics.getAttemptedResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getCorrectResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getIncorrectResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getBlankResponses()); + assertEquals(Integer.valueOf(67), itemStatistics.getDifficulty()); + } + + @Test + public void testExtendedMatchingItemMissingItemTextIsIncorrectNotCrash() { + long itemTextA = 10L; + Set itemTexts = Set.of(itemText(itemTextA, 1)); + + long itemId = 0L; + PublishedItemData item = item(itemId, TypeIfc.EXTENDED_MATCHING_ITEMS, itemTexts); + + Set itemAnswers = Set.of( + answer(100L, true), + answer(200L, true) + ); + + Set gradingData = Set.of( + // Normal correct + gradingData(0L, 100L, 0L, itemTextA), + // Unknown itemText id - incorrect + gradingData(1L, 200L, 1L, 999L) + ); + + List items = Collections.singletonList(item); + Map> gradingDataMap = Map.of(itemId, gradingData); + Map> answerMap = Map.of(itemId, itemAnswers); + + stubData(items, answerMap, gradingDataMap); + + QuestionPoolStatistics poolStatistics = statisticsService.getQuestionPoolStatistics(0L); + ItemStatistics itemStatistics = poolStatistics.getAggregatedItemStatistics(); + + assertEquals(Long.valueOf(2), itemStatistics.getAttemptedResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getCorrectResponses()); + assertEquals(Long.valueOf(1), itemStatistics.getIncorrectResponses()); + assertEquals(Long.valueOf(0), itemStatistics.getBlankResponses()); + assertEquals(Integer.valueOf(50), itemStatistics.getDifficulty()); + } + private void stubData(List items, Map> answerMap, Map> gradingDataMap) {