/********************************************************************************** * $URL: https://source.sakaiproject.org/svn/sam/trunk/samigo-app/src/java/org/sakaiproject/tool/assessment/ui/listener/author/CalculatedQuestionExtractListener.java $ * $Id: CalculatedQuestionExtractListener.java 124724 2013-05-21 12:43:56Z azeckoski@unicon.net $ *********************************************************************************** * * Copyright (c) 2004, 2005, 2006, 2008, 2009 The Sakai 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://www.opensource.org/licenses/ECL-2.0 * * 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.author; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import javax.faces.application.FacesMessage; import javax.faces.context.FacesContext; import javax.faces.event.AbortProcessingException; import javax.faces.event.ActionEvent; import javax.faces.event.ActionListener; import org.sakaiproject.tool.assessment.services.GradingService; import org.sakaiproject.tool.assessment.ui.bean.author.CalculatedQuestionCalculationBean; import org.sakaiproject.tool.assessment.ui.bean.author.CalculatedQuestionFormulaBean; import org.sakaiproject.tool.assessment.ui.bean.author.CalculatedQuestionVariableBean; import org.sakaiproject.tool.assessment.ui.bean.author.ItemAuthorBean; import org.sakaiproject.tool.assessment.ui.bean.author.ItemBean; import org.sakaiproject.tool.assessment.ui.bean.author.CalculatedQuestionBean; import org.sakaiproject.tool.assessment.ui.listener.util.ContextUtil; import org.sakaiproject.tool.assessment.util.SamigoExpressionError; public class CalculatedQuestionExtractListener implements ActionListener{ private static final String ERROR_MESSAGE_BUNDLE = "org.sakaiproject.tool.assessment.bundle.AuthorMessages"; /** * This listener will read in the instructions, parse any variables and * formula names it finds, and then check to see if there are any errors * in the configuration for the question. * * <p>Errors include <ul><li>no variables or formulas named in the instructions</li> * <li>variables and formulas sharing a name</li> * <li>variables with invalid ranges of values</li> * <li>formulas that are syntactically wrong</li></ul> * Any errors are written to the context messager * <p>The validate formula is also called directly from the ItemAddListner, before * saving a calculated question, to ensure any last minute changes are caught. */ public void processAction(ActionEvent arg0) throws AbortProcessingException { ItemAuthorBean itemauthorbean = (ItemAuthorBean) ContextUtil.lookupBean("itemauthor"); ItemBean item = itemauthorbean.getCurrentItem(); List<String> errors = this.validate(item); if (errors.size() > 0) { item.setOutcome("calculatedQuestion"); item.setPoolOutcome("calculatedQuestion"); FacesContext context=FacesContext.getCurrentInstance(); for (String error : errors) { context.addMessage(null, new FacesMessage(error)); } context.renderResponse(); } } /** * validate() returns a list of error strings to display to the context. * <p>Errors include <ul><li>no variables or formulas named in the instructions</li> * <li>variables and formulas sharing a name</li> * <li>variables with invalid ranges of values</li> * <li>formulas that are syntactically wrong</li></ul> * Any errors are written to the context messager * <p>The validate formula is also called directly from the ItemAddListner, before * saving a calculated question, to ensure any last minute changes are caught. * @param item - an ItemBean, which contains all of the needed information * about the CalculatedQuestion * @returns a List<String> of error messages to be displayed in the context messager. */ public List<String> validate(ItemBean item) { List<String> errors = new ArrayList<String>(); // prepare any already existing variables and formula for new extracts this.initializeVariables(item); this.initializeFormulas(item); GradingService service = new GradingService(); String instructions = item.getInstruction(); List<String> formulaNames = service.extractFormulas(instructions); List<String> variableNames = service.extractVariables(instructions); errors.addAll(validateExtractedNames(variableNames, formulaNames)); // add new variables and formulas // verify that at least one variable and formula are defined if (errors.size() == 0) { errors.addAll(createFormulasFromInstructions(item, formulaNames)); errors.addAll(createVariablesFromInstructions(item, variableNames)); errors.addAll(createCalculationsFromInstructions(item.getCalculatedQuestion(), instructions, service)); } // validate variable min and max and formula tolerance if (errors.size() == 0) { errors.addAll(validateMinMax(item)); errors.addAll(validateTolerance(item)); } // don't bother looking at formulas if any data validations have failed if (errors.size() == 0) { errors.addAll(validateFormulas(item, service)); errors.addAll(validateCalculations(item.getCalculatedQuestion(), service)); } else { errors.add(getErrorMessage("formulas_not_validated")); } return errors; } /** * initializeVariables() prepares any previously defined variables for updates * that occur when extracting new variables from instructions * @param item */ private void initializeVariables(ItemBean item) { Map<String, CalculatedQuestionVariableBean> variables = item.getCalculatedQuestion().getVariables(); for (CalculatedQuestionVariableBean bean : variables.values()) { bean.setActive(false); bean.setValidMax(true); bean.setValidMin(true); } } /** * initializeFormulas() prepares any previously defined formulas for updates * that occur when extracting new formulas from instructions * @param item */ private void initializeFormulas(ItemBean item) { Map<String, CalculatedQuestionFormulaBean> formulas = item.getCalculatedQuestion().getFormulas(); for (CalculatedQuestionFormulaBean bean : formulas.values()) { bean.setActive(false); bean.setValidFormula(true); bean.setValidTolerance(true); } } /** * createFormulasFromInstructions adds any formulas that exist in the list of formulaNames * but do not already exist in the question * @param item * @param formulaNames */ private List<String> createFormulasFromInstructions(ItemBean item, List<String> formulaNames) { List<String> errors = new ArrayList<String>(); Map<String, CalculatedQuestionFormulaBean> formulas = item.getCalculatedQuestion().getFormulas(); Map<String, CalculatedQuestionVariableBean> variables = item.getCalculatedQuestion().getVariables(); // add any missing formulas for (String formulaName : formulaNames) { if (!formulas.containsKey(formulaName)) { CalculatedQuestionFormulaBean bean = new CalculatedQuestionFormulaBean(); bean.setName(formulaName); bean.setSequence(Long.valueOf(variables.size() + formulas.size() + 1)); item.getCalculatedQuestion().addFormula(bean); } else { CalculatedQuestionFormulaBean bean = formulas.get(formulaName); bean.setActive(true); } } if (item.getCalculatedQuestion().getActiveFormulas().size() == 0) { errors.add(getErrorMessage("no_formulas_defined")); } return errors; } /** * createVariablesFromInstructions adds any variables that exist in the list * of variableNames but do not already exist in the question * @param item * @param variableNames */ private List<String> createVariablesFromInstructions(ItemBean item, List<String> variableNames) { List<String> errors = new ArrayList<String>(); Map<String, CalculatedQuestionFormulaBean> formulas = item.getCalculatedQuestion().getFormulas(); Map<String, CalculatedQuestionVariableBean> variables = item.getCalculatedQuestion().getVariables(); // add any missing variables for (String variableName : variableNames) { if (!variables.containsKey(variableName)) { CalculatedQuestionVariableBean bean = new CalculatedQuestionVariableBean(); bean.setName(variableName); bean.setSequence(Long.valueOf(variables.size() + formulas.size() + 1)); item.getCalculatedQuestion().addVariable(bean); } else { CalculatedQuestionVariableBean bean = variables.get(variableName); bean.setActive(true); } } if (item.getCalculatedQuestion().getActiveVariables().size() == 0) { errors.add(getErrorMessage("no_variables_defined")); } return errors; } /** * Finds the calculations in the instructions and places them in the CalculatedQuestionBean * (destroys anything that was already there) * * @param calculatedQuestionBean * @param instructions * @param service * @return list of error messages (empty if there are none) */ static List<String> createCalculationsFromInstructions(CalculatedQuestionBean calculatedQuestionBean, String instructions, GradingService service) { List<String> errors = new ArrayList<String>(); calculatedQuestionBean.clearCalculations(); // reset the current set and extract a new one List<String> calculations = service.extractCalculations(instructions); if (!calculations.isEmpty()) { for (String calculation : calculations) { CalculatedQuestionCalculationBean calc = new CalculatedQuestionCalculationBean(calculation); calculatedQuestionBean.addCalculation(calc); } } return errors; } /** * validateExtractedNames looks through all of the variable and formula names defined * in the instructions and determines if the names are valid, and if the formula and * variable names overlap. * @param item * @return a list of validation errors to display */ private List<String> validateExtractedNames(List<String> variableNames, List<String> formulaNames) { List<String> errors = new ArrayList<String>(); // formula validations for (String formula : formulaNames) { if (formula == null || formula.length() == 0) { errors.add(getErrorMessage("formula_name_empty")); } else { if (!formula.matches("[a-zA-Z]\\w*")) { errors.add(getErrorMessage("formula_name_invalid")); } } } // variable validations for (String variable : variableNames) { if (variable == null || variable.length() == 0) { errors.add(getErrorMessage("variable_name_empty")); } else { if (!variable.matches("[a-zA-Z]\\w*")) { errors.add(getErrorMessage("variable_name_invalid")); } } } // throw an error if variables and formulas share any names // don't continue processing if there are problems with the extract if (!Collections.disjoint(formulaNames, variableNames)) { errors.add(getErrorMessage("unique_names")); } return errors; } /** * validateTolerance() verifies that formula tolerances are positive numbers * @param item * @return a list of validation errors to display */ private List<String> validateTolerance(ItemBean item) { List<String> errors = new ArrayList<String>(); CalculatedQuestionBean question = item.getCalculatedQuestion(); // formula tolerances must be numbers or percentages for (CalculatedQuestionFormulaBean formula : question.getActiveFormulas().values()) { String toleranceStr = formula.getTolerance().trim(); // cannot be blank if (toleranceStr == null || toleranceStr.length() == 0) { errors.add(getErrorMessage("empty_field")); formula.setValidTolerance(false); } // no non-number characters (although percentage is allowed // allow a negative here, we'll catch negative tolerances in another place if (formula.getValidTolerance()) { if (!toleranceStr.matches("[0-9\\.\\-\\%]+")) { errors.add(getErrorMessage("invalid_tolerance")); formula.setValidTolerance(false); } } // if not a percentage, try to convert to a double to validate // format if (formula.getValidTolerance()) { if (!toleranceStr.matches("[0-9]+\\.?[0-9]*\\%")) { try { double tolerance = Double.parseDouble(toleranceStr); // this strips out any leading spaces or zeroes formula.setTolerance(Double.toString(tolerance)); if (tolerance < 0) { errors.add(getErrorMessage("tolerance_negative")); formula.setValidTolerance(false); } } catch (NumberFormatException n) { errors.add(getErrorMessage("invalid_tolerance")); formula.setValidTolerance(false); } } } } return errors; } /** * validateMinMax() looks at each variable and ensures that the * min and max are valid numbers. It also verifies that the min is less * than the max. * @param item * @return a list of validation errors to display */ private List<String> validateMinMax(ItemBean item) { List<String> errors = new ArrayList<String>(); CalculatedQuestionBean question = item.getCalculatedQuestion(); for (CalculatedQuestionVariableBean variable : question.getActiveVariables().values()) { // decimal String decimalStr = variable.getDecimalPlaces().trim(); int decimals; try { decimals = Integer.parseInt(decimalStr); } catch (NumberFormatException e) { decimals = 2; // default when decimals is not known } // min String minStr = variable.getMin().trim(); double min = 0d; if (minStr == null || minStr.length() == 0) { errors.add(getErrorMessage("empty_field")); variable.setValidMin(false); } if (variable.getValidMin()) { try { BigDecimal bd = new BigDecimal(minStr); bd = bd.setScale(decimals); bd = bd.stripTrailingZeros(); min = bd.doubleValue(); String value = bd.toPlainString(); variable.setMin(value); } catch (NumberFormatException n) { errors.add(getErrorMessage("invalid_min")); variable.setValidMin(false); } } // max String maxStr = variable.getMax().trim(); double max = 0d; if (maxStr == null || maxStr.length() == 0) { errors.add(getErrorMessage("empty_field")); variable.setValidMax(false); } if (variable.getValidMax()) { try { BigDecimal bd = new BigDecimal(maxStr); bd = bd.setScale(decimals); bd = bd.stripTrailingZeros(); max = bd.doubleValue(); String value = bd.toPlainString(); variable.setMax(value); } catch (NumberFormatException n) { errors.add(getErrorMessage("invalid_max")); variable.setValidMax(false); } } // max < min if (variable.getValidMax() && variable.getValidMin()) { if (max < min) { errors.add(getErrorMessage("max_less_than_min")); variable.setValidMin(false); variable.setValidMax(false); } } } return errors; } /** * Takes a populated calculatedQuestionBean and validates the calculations and populates the * calculation values and sample data (and status) * * @param calculatedQuestionBean * @param service * @return a list of validation errors to display */ static List<String> validateCalculations(CalculatedQuestionBean calculatedQuestionBean, GradingService service) { List<String> errors = new ArrayList<String>(); if (service == null) { service = new GradingService(); } // list of variables to substitute Map<String, String> variableRangeMap = new HashMap<String, String>(); for (CalculatedQuestionVariableBean variable : calculatedQuestionBean.getActiveVariables().values()) { String match = variable.getMin() + "|" + variable.getMax() + "," + variable.getDecimalPlaces(); variableRangeMap.put(variable.getName(), match); } int attemptCnt = 0; while (attemptCnt < 100 && errors.size() == 0) { // create random values for the variables to substitute into the formulas (using dummy values) Map<String, String> answersMap = service.determineRandomValuesForRanges(variableRangeMap, 1, 1, "dummy", attemptCnt); // evaluate each calculation for (CalculatedQuestionCalculationBean cqcb : calculatedQuestionBean.getCalculationsList()) { String formulaStr = GradingService.cleanFormula(cqcb.getText()); if (formulaStr == null || formulaStr.length() == 0) { String msg = getErrorMessage("empty_field"); cqcb.setStatus(msg); errors.add(msg); } else { String substitutedFormulaStr = service.replaceMappedVariablesWithNumbers(formulaStr, answersMap); cqcb.setFormula(substitutedFormulaStr); // look for wrapped variables that haven't been replaced (undefined variable) List<String> unwrappedVariables = service.extractVariables(substitutedFormulaStr); if (unwrappedVariables.size() > 0) { String msg = getErrorMessage("samigo_formula_error_9"); cqcb.setStatus(msg); errors.add(msg + " :"+substitutedFormulaStr); } else { try { if (service.isNegativeSqrt(substitutedFormulaStr)) { String msg = getErrorMessage("samigo_formula_error_8"); cqcb.setStatus(msg); errors.add(msg + " :"+substitutedFormulaStr); } else { String formulaValue = service.processFormulaIntoValue(substitutedFormulaStr, 5); // throws exceptions if formula is invalid cqcb.setValue(formulaValue); cqcb.setText(formulaStr); } } catch (SamigoExpressionError e) { String msg = getErrorMessage("samigo_formula_error_" + Integer.valueOf(e.get_id())); cqcb.setStatus(msg); errors.add(msg + " :"+substitutedFormulaStr); } catch (Exception e) { String msg = getErrorMessage("samigo_formula_error_500"); cqcb.setStatus(msg); errors.add(msg + " :"+substitutedFormulaStr); } } } } attemptCnt++; } return errors; } /** * validateFormulas() iterates through all of the formula definitions. It * creates valid dummy values for all of the defined variables, substitutes those * variables into the formula, then executes the formula to determine if a value * is returned. This is a syntax checker; a syntactically valid formula can * definitely return the wrong value if the author enters a wrong formula. * @param item * @return a map of errors. The Key is an integer value, set by the SamigoExpressionParser, the * value is the string result of that error message. */ private List<String> validateFormulas(ItemBean item, GradingService service) { List<String> errors = new ArrayList<String>(); if (service == null) { service = new GradingService(); } // list of variables to substitute Map<String, String> variableRangeMap = new HashMap<String, String>(); for (CalculatedQuestionVariableBean variable : item.getCalculatedQuestion().getActiveVariables().values()) { String match = variable.getMin() + "|" + variable.getMax() + "," + variable.getDecimalPlaces(); variableRangeMap.put(variable.getName(), match); } // dummy variables needed to generate random values within ranges for the variables long dummyItemId = 1; long dummyGradingId = 1; String dummyAgentId = "dummy"; int attemptCnt = 0; while (attemptCnt < 100 && errors.size() == 0) { // create random values for the variables to substitute into the formulas Map<String, String> answersMap = service.determineRandomValuesForRanges(variableRangeMap, dummyItemId, dummyGradingId, dummyAgentId, attemptCnt); // evaluate each formula for (CalculatedQuestionFormulaBean formulaBean : item.getCalculatedQuestion().getActiveFormulas().values()) { String formulaStr = formulaBean.getText(); if (formulaStr == null || formulaStr.length() == 0) { formulaBean.setValidFormula(false); errors.add(getErrorMessage("empty_field")); } else { String substitutedFormulaStr = service.replaceMappedVariablesWithNumbers(formulaStr, answersMap); // look for wrapped variables that haven't been replaced (undefined variable) List<String> unwrappedVariables = service.extractVariables(substitutedFormulaStr); if (unwrappedVariables.size() > 0) { formulaBean.setValidFormula(false); errors.add(getErrorMessage("samigo_formula_error_9") + " :"+substitutedFormulaStr); } else { try { if (service.isNegativeSqrt(substitutedFormulaStr)) { formulaBean.setValidFormula(false); errors.add(getErrorMessage("samigo_formula_error_8") + " :"+substitutedFormulaStr); } else { service.processFormulaIntoValue(substitutedFormulaStr, 5); // throws exceptions on failure } } catch (SamigoExpressionError e) { formulaBean.setValidFormula(false); String msg = getErrorMessage("samigo_formula_error_" + Integer.valueOf(e.get_id())); errors.add(msg + " :"+substitutedFormulaStr); } catch (Exception e) { formulaBean.setValidFormula(false); errors.add(getErrorMessage("samigo_formula_error_500") + " :"+substitutedFormulaStr); } } } } attemptCnt++; } return errors; } /** * getErrorMessage() retrieves the localized error message associated with * the errorCode * @param errorCode * @return */ private static String getErrorMessage(String errorCode) { String err = ContextUtil.getLocalizedString(ERROR_MESSAGE_BUNDLE, errorCode); return err; } }