From 6aa05cfb9acd2596c13d88e3f2dd44078f499ed0 Mon Sep 17 00:00:00 2001 From: Shamim Rezaie Date: Mon, 4 Sep 2023 00:41:36 +1000 Subject: [PATCH] MDL-78217 grade: Refine front-end weight handling and add validation In this commit, the front-end experience for grade item weights in the gradebook setup page is refined, building upon the improvements made in the previous commit. Firstly, overridden weights are excluded from automatic adjustments. This enhancement guarantees that user-specified weights remain unchanged by front-end calculations, thereby preserving the flexibility of custom weight assignments. Additionally, front-end form validation has been introduced to enhance the user experience. Now, users will see validation errors if they submit invalid values, giving them the opportunity to correct the values themselves. This ensures that weights are not automatically normalised by the back-end code, preserving the values as entered by the user. --- grade/amd/build/edittree_weights.min.js | 4 +- grade/amd/build/edittree_weights.min.js.map | 2 +- grade/amd/src/edittree_weights.js | 48 ++++++-- .../templates/weight_override_field.mustache | 1 + grade/tests/behat/grade_aggregation.feature | 40 +++---- .../behat/grade_natural_normalisation.feature | 112 +++++++++--------- ...ade_natural_normalisation_20150619.feature | 33 +++--- lang/en/grades.php | 2 + 8 files changed, 134 insertions(+), 108 deletions(-) diff --git a/grade/amd/build/edittree_weights.min.js b/grade/amd/build/edittree_weights.min.js index 92d9ba3c1ff..2a5d7396628 100644 --- a/grade/amd/build/edittree_weights.min.js +++ b/grade/amd/build/edittree_weights.min.js @@ -1,4 +1,4 @@ -define("core_grades/edittree_weights",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0; +define("core_grades/edittree_weights",["exports","core/str","core/prefetch"],(function(_exports,_str,_prefetch){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0; /** * This module provides functionality for managing weight calculations and adjustments for grade items. * @@ -6,6 +6,6 @@ define("core_grades/edittree_weights",["exports"],(function(_exports){Object.def * @copyright 2023 Shamim Rezaie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -const selectors_weightOverrideCheckbox='input[type="checkbox"][name^="weightoverride_"]',selectors_weightOverrideInput='input[type="text"][name^="weight_"]',selectors_childrenByCategory=category=>'tr[data-parent-category="'.concat(category,'"]'),selectors_categoryByIdentifier=identifier=>'tr.category[data-category="'.concat(identifier,'"]'),grade_aggregation={sum:13};let decimalSeparator,oldExtraCreditCalculation;const formatWeight=weight=>weight.toFixed(3).replace(/0{0,2}$/,"").replace(".",decimalSeparator),parseWeight=weightString=>{const normalizedWeightString=weightString.replace(decimalSeparator,".");return isNaN(Number(normalizedWeightString))?0:parseFloat(normalizedWeightString||0)};_exports.init=(decSep,oldCalculation)=>{decimalSeparator=decSep,oldExtraCreditCalculation=oldCalculation,document.addEventListener("change",(e=>{if(e.target.matches(selectors_weightOverrideInput)||e.target.matches(selectors_weightOverrideCheckbox)){const gradeItemRow=e.target.closest("tr"),categoryElement=document.querySelector(selectors_categoryByIdentifier(gradeItemRow.dataset.parentCategory));if(parseInt(categoryElement.dataset.aggregation)===grade_aggregation.sum){const weightElement=gradeItemRow.querySelector(selectors_weightOverrideInput);weightElement.value=formatWeight(parseWeight(weightElement.value)),(categoryElement=>{const childElements=document.querySelectorAll(selectors_childrenByCategory(categoryElement.dataset.category));let totalGradeMax=0,totalOverriddenWeight=0,totalOverriddenGradeMax=0,automaticGradeItemsPresent=!1,requiresNormalising=!1;const overrideArray={};for(const childElement of childElements){const weightInput=childElement.querySelector(selectors_weightOverrideInput),weightCheckbox=childElement.querySelector(selectors_weightOverrideCheckbox);if(!weightInput)continue;const itemWeight=parseWeight(weightInput.value),itemAggregationCoefficient=parseInt(childElement.dataset.aggregationcoef),itemGradeMax=parseFloat(childElement.dataset.grademax);overrideArray[childElement.dataset.itemid]={extraCredit:itemAggregationCoefficient,weight:itemWeight,weightOverride:weightCheckbox.checked},weightCheckbox.checked||0!==itemAggregationCoefficient||(automaticGradeItemsPresent=!0),itemAggregationCoefficient>0||weightCheckbox.checked&&itemWeight<=0||(totalGradeMax+=itemGradeMax,weightCheckbox.checked&&(totalOverriddenWeight+=itemWeight,totalOverriddenGradeMax+=itemGradeMax))}let normaliseTotal=0,overriddenTotal=0;for(const gradeItemDetail of Object.values(overrideArray))gradeItemDetail.extraCredit||(normaliseTotal+=gradeItemDetail.weight),gradeItemDetail.weightOverride&&!gradeItemDetail.extraCredit&&gradeItemDetail.weight>0&&(overriddenTotal+=gradeItemDetail.weight);overriddenTotal>100&&(requiresNormalising=!0,normaliseTotal=overriddenTotal);const totalNonOverriddenGradeMax=totalGradeMax-totalOverriddenGradeMax;for(const childElement of childElements){const weightInput=childElement.querySelector(selectors_weightOverrideInput),weightCheckbox=childElement.querySelector(selectors_weightOverrideCheckbox),itemAggregationCoefficient=parseInt(childElement.dataset.aggregationcoef),itemGradeMax=parseFloat(childElement.dataset.grademax);weightInput&&(!oldExtraCreditCalculation&&itemAggregationCoefficient>0&&weightCheckbox.checked||(!oldExtraCreditCalculation&&itemAggregationCoefficient>0&&!weightCheckbox.checked?weightInput.value=totalGradeMax?formatWeight(100*itemGradeMax/totalGradeMax):0:weightCheckbox.checked?(!automaticGradeItemsPresent&&100!==normaliseTotal||requiresNormalising||overrideArray[childElement.dataset.itemid].weight<0)&&(0===normaliseTotal||overrideArray[childElement.dataset.itemid].weight<0?weightInput.value=formatWeight(0):weightInput.value=formatWeight(100*overrideArray[childElement.dataset.itemid].weight/normaliseTotal)):weightInput.value=formatWeight(totalOverriddenWeight>=100||0===totalNonOverriddenGradeMax||0===itemGradeMax?0:itemGradeMax/totalNonOverriddenGradeMax*(100-totalOverriddenWeight))))}})(categoryElement)}}}))}})); +const selectors_weightOverrideCheckbox='input[type="checkbox"][name^="weightoverride_"]',selectors_weightOverrideInput='input[type="text"][name^="weight_"]',selectors_childrenByCategory=category=>'tr[data-parent-category="'.concat(category,'"]'),selectors_categoryByIdentifier=identifier=>'tr.category[data-category="'.concat(identifier,'"]'),grade_aggregation={sum:13};let decimalSeparator,oldExtraCreditCalculation;const formatWeight=weight=>weight.toFixed(3).replace(/0{0,2}$/,"").replace(".",decimalSeparator),parseWeight=weightString=>{const normalizedWeightString=weightString.replace(decimalSeparator,".");return isNaN(Number(normalizedWeightString))?0:parseFloat(normalizedWeightString||0)};_exports.init=(decSep,oldCalculation)=>{decimalSeparator=decSep,oldExtraCreditCalculation=oldCalculation,(0,_prefetch.prefetchStrings)("core_grades",["erroroverweight","errorunderweight"]),document.addEventListener("change",(e=>{if(e.target.matches(selectors_weightOverrideInput)||e.target.matches(selectors_weightOverrideCheckbox)){const gradeItemRow=e.target.closest("tr"),categoryElement=document.querySelector(selectors_categoryByIdentifier(gradeItemRow.dataset.parentCategory));if(parseInt(categoryElement.dataset.aggregation)===grade_aggregation.sum){const weightElement=gradeItemRow.querySelector(selectors_weightOverrideInput);weightElement.value=formatWeight(parseWeight(weightElement.value)),(categoryElement=>{const childElements=document.querySelectorAll(selectors_childrenByCategory(categoryElement.dataset.category));let totalGradeMax=0,totalOverriddenWeight=0,totalOverriddenGradeMax=0,automaticGradeItemsPresent=!1,requiresNormalising=!1;const overrideArray={};for(const childElement of childElements){const weightInput=childElement.querySelector(selectors_weightOverrideInput),weightCheckbox=childElement.querySelector(selectors_weightOverrideCheckbox);if(!weightInput)continue;const itemWeight=parseWeight(weightInput.value),itemAggregationCoefficient=parseInt(childElement.dataset.aggregationcoef),itemGradeMax=parseFloat(childElement.dataset.grademax);overrideArray[childElement.dataset.itemid]={extraCredit:itemAggregationCoefficient,weight:itemWeight,weightOverride:weightCheckbox.checked},weightCheckbox.checked||0!==itemAggregationCoefficient||(automaticGradeItemsPresent=!0),itemAggregationCoefficient>0||weightCheckbox.checked&&itemWeight<=0||(totalGradeMax+=itemGradeMax,weightCheckbox.checked&&(totalOverriddenWeight+=itemWeight,totalOverriddenGradeMax+=itemGradeMax))}let normaliseTotal=0,overriddenTotal=0;for(const gradeItemDetail of Object.values(overrideArray))!gradeItemDetail.extraCredit&&gradeItemDetail.weight>0&&(normaliseTotal+=gradeItemDetail.weight),gradeItemDetail.weightOverride&&!gradeItemDetail.extraCredit&&gradeItemDetail.weight>0&&(overriddenTotal+=gradeItemDetail.weight);overriddenTotal>100&&(requiresNormalising=!0,normaliseTotal=overriddenTotal);const totalNonOverriddenGradeMax=totalGradeMax-totalOverriddenGradeMax;for(const childElement of childElements){const weightInput=childElement.querySelector(selectors_weightOverrideInput),weightCheckbox=childElement.querySelector(selectors_weightOverrideCheckbox),itemAggregationCoefficient=parseInt(childElement.dataset.aggregationcoef),itemGradeMax=parseFloat(childElement.dataset.grademax);if(!weightInput)continue;if(!oldExtraCreditCalculation&&itemAggregationCoefficient>0&&weightCheckbox.checked)continue;weightInput.classList.remove("is-invalid");const errorArea=weightInput.closest("td").querySelector(".invalid-feedback");if(errorArea.textContent="",!oldExtraCreditCalculation&&itemAggregationCoefficient>0&&!weightCheckbox.checked)weightInput.value=totalGradeMax?formatWeight(100*itemGradeMax/totalGradeMax):0;else if(weightCheckbox.checked){if(!automaticGradeItemsPresent&&100!==normaliseTotal||requiresNormalising||overrideArray[childElement.dataset.itemid].weight<0)if(overrideArray[childElement.dataset.itemid].weight<0)weightInput.value=formatWeight(0);else{const error=normaliseTotal>100?"erroroverweight":"errorunderweight";(0,_str.getString)(error,"core_grades").then((errorString=>{errorArea.textContent=errorString})),weightInput.classList.add("is-invalid")}}else weightInput.value=formatWeight(totalOverriddenWeight>=100||0===totalNonOverriddenGradeMax||0===itemGradeMax?0:itemGradeMax/totalNonOverriddenGradeMax*(100-totalOverriddenWeight))}})(categoryElement)}}})),document.addEventListener("submit",(e=>{if(e.target.matches("#gradetreeform")){const firstInvalidWeightInput=e.target.querySelector("input.is-invalid");if(firstInvalidWeightInput){const firstFocusableInvalidWeightInput=e.target.querySelector("input.is-invalid:enabled");firstFocusableInvalidWeightInput?firstFocusableInvalidWeightInput.focus():firstInvalidWeightInput.scrollIntoView({block:"center"}),e.preventDefault()}}}))}})); //# sourceMappingURL=edittree_weights.min.js.map \ No newline at end of file diff --git a/grade/amd/build/edittree_weights.min.js.map b/grade/amd/build/edittree_weights.min.js.map index 68f27b7d90f..949a3328ff5 100644 --- a/grade/amd/build/edittree_weights.min.js.map +++ b/grade/amd/build/edittree_weights.min.js.map @@ -1 +1 @@ -{"version":3,"file":"edittree_weights.min.js","sources":["../src/edittree_weights.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * This module provides functionality for managing weight calculations and adjustments for grade items.\n *\n * @module core_grades/edittree_weight\n * @copyright 2023 Shamim Rezaie \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n/**\n * Selectors.\n *\n * @type {Object}\n */\nconst selectors = {\n weightOverrideCheckbox: 'input[type=\"checkbox\"][name^=\"weightoverride_\"]',\n weightOverrideInput: 'input[type=\"text\"][name^=\"weight_\"]',\n childrenByCategory: category => `tr[data-parent-category=\"${category}\"]`,\n categoryByIdentifier: identifier => `tr.category[data-category=\"${identifier}\"]`,\n};\n\n/**\n * An object representing grading-related constants.\n * The same as what's defined in lib/grade/constants.php.\n *\n * @type {Object}\n * @property {Object} aggregation Aggregation settings.\n * @property {number} aggregation.sum Aggregation method: sum.\n * @property {Object} type Grade type settings.\n * @property {number} type.none Grade type: none.\n * @property {number} type.value Grade type: value.\n * @property {number} type.scale Grade type: scale.\n */\nconst grade = {\n aggregation: {\n sum: 13,\n },\n};\n\n/**\n * The character used as the decimal separator for number formatting.\n *\n * @type {string}\n */\nlet decimalSeparator;\n\n/**\n * This setting indicates if we should use algorithm prior to MDL-49257 fix for calculating extra credit weights.\n * Even though the old algorithm has bugs in it, we need to preserve existing grades.\n *\n * @type {boolean}\n */\nlet oldExtraCreditCalculation;\n\n/**\n * Recalculates the natural weights for grade items within a given category.\n *\n * @param {HTMLElement} categoryElement The DOM element representing the category.\n */\n// Suppress 'complexity' linting rule to keep this function as close to grade_category::auto_update_weights.\n// eslint-disable-next-line complexity\nconst recalculateNaturalWeights = (categoryElement) => {\n const childElements = document.querySelectorAll(selectors.childrenByCategory(categoryElement.dataset.category));\n\n // Calculate the sum of the grademax's of all the items within this category.\n let totalGradeMax = 0;\n\n // Out of 100, how much weight has been manually overridden by a user?\n let totalOverriddenWeight = 0;\n let totalOverriddenGradeMax = 0;\n\n // Has every assessment in this category been overridden?\n let automaticGradeItemsPresent = false;\n // Does the grade item require normalising?\n let requiresNormalising = false;\n\n // This array keeps track of the id and weight of every grade item that has been overridden.\n const overrideArray = {};\n\n for (const childElement of childElements) {\n const weightInput = childElement.querySelector(selectors.weightOverrideInput);\n const weightCheckbox = childElement.querySelector(selectors.weightOverrideCheckbox);\n\n // There are cases where a grade item should be excluded from calculations:\n // - If the item's grade type is 'text' or 'none'.\n // - If the grade item is an outcome item and the settings are set to not aggregate outcome items.\n // - If the item's grade type is 'scale' and the settings are set to ignore scales in aggregations.\n // All these cases are already taken care of in the backend, and no 'weight' input element is rendered on the page\n // if a grade item should not have a weight.\n if (!weightInput) {\n continue;\n }\n\n const itemWeight = parseWeight(weightInput.value);\n const itemAggregationCoefficient = parseInt(childElement.dataset.aggregationcoef);\n const itemGradeMax = parseFloat(childElement.dataset.grademax);\n\n // Record the ID and the weight for this grade item.\n overrideArray[childElement.dataset.itemid] = {\n extraCredit: itemAggregationCoefficient,\n weight: itemWeight,\n weightOverride: weightCheckbox.checked,\n };\n // If this item has had its weight overridden then set the flag to true, but\n // only if all previous items were also overridden. Note that extra credit items\n // are counted as overridden grade items.\n if (!weightCheckbox.checked && itemAggregationCoefficient === 0) {\n automaticGradeItemsPresent = true;\n }\n\n if (itemAggregationCoefficient > 0) {\n // An extra credit grade item doesn't contribute to totalOverriddenGradeMax.\n continue;\n } else if (weightCheckbox.checked && itemWeight <= 0) {\n // An overridden item that defines a weight of 0 does not contribute to totalOverriddenGradeMax.\n continue;\n }\n\n totalGradeMax += itemGradeMax;\n if (weightCheckbox.checked) {\n totalOverriddenWeight += itemWeight;\n totalOverriddenGradeMax += itemGradeMax;\n }\n }\n\n // Initialise this variable (used to keep track of the weight override total).\n let normaliseTotal = 0;\n // Keep a record of how much the override total is to see if it is above 100. If it is then we need to set the\n // other weights to zero and normalise the others.\n let overriddenTotal = 0;\n // Total up all the weights.\n for (const gradeItemDetail of Object.values(overrideArray)) {\n // If the grade item has extra credit, then don't add it to normaliseTotal.\n if (!gradeItemDetail.extraCredit) {\n normaliseTotal += gradeItemDetail.weight;\n }\n // The overridden total comprises items that are set as overridden, are not extra credit, and have a value\n // greater than zero.\n if (gradeItemDetail.weightOverride && !gradeItemDetail.extraCredit && gradeItemDetail.weight > 0) {\n // Add overridden weights up to see if they are greater than 1.\n overriddenTotal += gradeItemDetail.weight;\n }\n }\n if (overriddenTotal > 100) {\n // Make sure that this category of weights gets normalised.\n requiresNormalising = true;\n // The normalised weights are only the overridden weights, so we just use the total of those.\n normaliseTotal = overriddenTotal;\n }\n\n const totalNonOverriddenGradeMax = totalGradeMax - totalOverriddenGradeMax;\n\n for (const childElement of childElements) {\n const weightInput = childElement.querySelector(selectors.weightOverrideInput);\n const weightCheckbox = childElement.querySelector(selectors.weightOverrideCheckbox);\n const itemAggregationCoefficient = parseInt(childElement.dataset.aggregationcoef);\n const itemGradeMax = parseFloat(childElement.dataset.grademax);\n\n if (!weightInput) {\n continue;\n } else if (!oldExtraCreditCalculation && itemAggregationCoefficient > 0 && weightCheckbox.checked) {\n // For an item with extra credit ignore other weights and overrides but do not change anything at all\n // if its weight was already overridden.\n continue;\n }\n\n if (!oldExtraCreditCalculation && itemAggregationCoefficient > 0 && !weightCheckbox.checked) {\n // For an item with extra credit ignore other weights and overrides.\n weightInput.value = totalGradeMax ? formatWeight(itemGradeMax * 100 / totalGradeMax) : 0;\n } else if (!weightCheckbox.checked) {\n // Calculations with a grade maximum of zero will cause problems. Just set the weight to zero.\n if (totalOverriddenWeight >= 100 || totalNonOverriddenGradeMax === 0 || itemGradeMax === 0) {\n // There is no more weight to distribute.\n weightInput.value = formatWeight(0);\n } else {\n // Calculate this item's weight as a percentage of the non-overridden total grade maxes\n // then convert it to a proportion of the available non-overridden weight.\n weightInput.value = formatWeight((itemGradeMax / totalNonOverriddenGradeMax) * (100 - totalOverriddenWeight));\n }\n } else if ((!automaticGradeItemsPresent && normaliseTotal !== 100) || (requiresNormalising) ||\n overrideArray[childElement.dataset.itemid].weight < 0) {\n // Just divide the overridden weight for this item against the total weight override of all\n // items in this category.\n if (normaliseTotal === 0 || overrideArray[childElement.dataset.itemid].weight < 0) {\n // If the normalised total equals zero, or the weight value is less than zero,\n // set the weight for the grade item to zero.\n weightInput.value = formatWeight(0);\n } else {\n weightInput.value = formatWeight(100 * overrideArray[childElement.dataset.itemid].weight / normaliseTotal);\n }\n }\n }\n};\n\n/**\n * Formats a weight value as a string with up to 3 decimal places.\n *\n * @param {number} weight The weight value to be formatted.\n * @returns {string} The formatted weight value with the specified decimal places.\n */\nconst formatWeight = (weight) => {\n return weight.toFixed(3).replace(/0{0,2}$/, '').replace('.', decimalSeparator);\n};\n\n/**\n * Parses a weight string and returns a normalized float value.\n *\n * @param {string} weightString The weight as a string, possibly with localized formatting.\n * @returns {number} The parsed weight as a float. If parsing fails, returns 0.\n */\nconst parseWeight = (weightString) => {\n const normalizedWeightString = weightString.replace(decimalSeparator, '.');\n return isNaN(Number(normalizedWeightString)) ? 0 : parseFloat(normalizedWeightString || 0);\n};\n\n/**\n * Initializes the weight management module with optional configuration.\n *\n * @param {string} decSep The character used as the decimal separator for number formatting.\n * @param {boolean} oldCalculation A flag indicating whether to use the old (pre MDL-49257) extra credit calculation.\n */\nexport const init = (decSep, oldCalculation) => {\n decimalSeparator = decSep;\n oldExtraCreditCalculation = oldCalculation;\n\n document.addEventListener('change', e => {\n // Update the weights of all grade items in the category when the weight of any grade item in the category is changed.\n if (e.target.matches(selectors.weightOverrideInput) || e.target.matches(selectors.weightOverrideCheckbox)) {\n // The following is named gradeItemRow, but it may also be a row that's representing a grade category.\n // It's ok because it serves as the categories associated grade item in our calculations.\n const gradeItemRow = e.target.closest('tr');\n const categoryElement = document.querySelector(selectors.categoryByIdentifier(gradeItemRow.dataset.parentCategory));\n\n // This is only required if we are using natural weights.\n if (parseInt(categoryElement.dataset.aggregation) === grade.aggregation.sum) {\n const weightElement = gradeItemRow.querySelector(selectors.weightOverrideInput);\n weightElement.value = formatWeight(parseWeight(weightElement.value));\n recalculateNaturalWeights(categoryElement);\n }\n }\n });\n};\n"],"names":["selectors","category","identifier","grade","sum","decimalSeparator","oldExtraCreditCalculation","formatWeight","weight","toFixed","replace","parseWeight","weightString","normalizedWeightString","isNaN","Number","parseFloat","decSep","oldCalculation","document","addEventListener","e","target","matches","gradeItemRow","closest","categoryElement","querySelector","dataset","parentCategory","parseInt","aggregation","weightElement","value","childElements","querySelectorAll","totalGradeMax","totalOverriddenWeight","totalOverriddenGradeMax","automaticGradeItemsPresent","requiresNormalising","overrideArray","childElement","weightInput","weightCheckbox","itemWeight","itemAggregationCoefficient","aggregationcoef","itemGradeMax","grademax","itemid","extraCredit","weightOverride","checked","normaliseTotal","overriddenTotal","gradeItemDetail","Object","values","totalNonOverriddenGradeMax","recalculateNaturalWeights"],"mappings":";;;;;;;;MA4BMA,iCACsB,kDADtBA,8BAEmB,sCAFnBA,6BAGkBC,6CAAwCA,eAH1DD,+BAIoBE,iDAA4CA,iBAehEC,kBACW,CACTC,IAAK,QASTC,iBAQAC,gCAoJEC,aAAgBC,QACXA,OAAOC,QAAQ,GAAGC,QAAQ,UAAW,IAAIA,QAAQ,IAAKL,kBAS3DM,YAAeC,qBACXC,uBAAyBD,aAAaF,QAAQL,iBAAkB,YAC/DS,MAAMC,OAAOF,yBAA2B,EAAIG,WAAWH,wBAA0B,kBASxE,CAACI,OAAQC,kBACzBb,iBAAmBY,OACnBX,0BAA4BY,eAE5BC,SAASC,iBAAiB,UAAUC,OAE5BA,EAAEC,OAAOC,QAAQvB,gCAAkCqB,EAAEC,OAAOC,QAAQvB,kCAAmC,OAGjGwB,aAAeH,EAAEC,OAAOG,QAAQ,MAChCC,gBAAkBP,SAASQ,cAAc3B,+BAA+BwB,aAAaI,QAAQC,oBAG/FC,SAASJ,gBAAgBE,QAAQG,eAAiB5B,kBAAkBC,IAAK,OACnE4B,cAAgBR,aAAaG,cAAc3B,+BACjDgC,cAAcC,MAAQ1B,aAAaI,YAAYqB,cAAcC,QA/K1CP,CAAAA,wBACzBQ,cAAgBf,SAASgB,iBAAiBnC,6BAA6B0B,gBAAgBE,QAAQ3B,eAGjGmC,cAAgB,EAGhBC,sBAAwB,EACxBC,wBAA0B,EAG1BC,4BAA6B,EAE7BC,qBAAsB,QAGpBC,cAAgB,OAEjB,MAAMC,gBAAgBR,cAAe,OAChCS,YAAcD,aAAaf,cAAc3B,+BACzC4C,eAAiBF,aAAaf,cAAc3B,sCAQ7C2C,2BAICE,WAAalC,YAAYgC,YAAYV,OACrCa,2BAA6BhB,SAASY,aAAad,QAAQmB,iBAC3DC,aAAehC,WAAW0B,aAAad,QAAQqB,UAGrDR,cAAcC,aAAad,QAAQsB,QAAU,CACzCC,YAAaL,2BACbtC,OAAQqC,WACRO,eAAgBR,eAAeS,SAK9BT,eAAeS,SAA0C,IAA/BP,6BAC3BP,4BAA6B,GAG7BO,2BAA6B,GAGtBF,eAAeS,SAAWR,YAAc,IAKnDT,eAAiBY,aACbJ,eAAeS,UACfhB,uBAAyBQ,WACzBP,yBAA2BU,mBAK/BM,eAAiB,EAGjBC,gBAAkB,MAEjB,MAAMC,mBAAmBC,OAAOC,OAAOjB,eAEnCe,gBAAgBL,cACjBG,gBAAkBE,gBAAgBhD,QAIlCgD,gBAAgBJ,iBAAmBI,gBAAgBL,aAAeK,gBAAgBhD,OAAS,IAE3F+C,iBAAmBC,gBAAgBhD,QAGvC+C,gBAAkB,MAElBf,qBAAsB,EAEtBc,eAAiBC,uBAGfI,2BAA6BvB,cAAgBE,4BAE9C,MAAMI,gBAAgBR,cAAe,OAChCS,YAAcD,aAAaf,cAAc3B,+BACzC4C,eAAiBF,aAAaf,cAAc3B,kCAC5C8C,2BAA6BhB,SAASY,aAAad,QAAQmB,iBAC3DC,aAAehC,WAAW0B,aAAad,QAAQqB,UAEhDN,eAEOrC,2BAA6BwC,2BAA6B,GAAKF,eAAeS,WAMrF/C,2BAA6BwC,2BAA6B,IAAMF,eAAeS,QAEhFV,YAAYV,MAAQG,cAAgB7B,aAA4B,IAAfyC,aAAqBZ,eAAiB,EAC/EQ,eAAeS,UAUdd,4BAAiD,MAAnBe,gBAA4Bd,qBAC/DC,cAAcC,aAAad,QAAQsB,QAAQ1C,OAAS,KAGjC,IAAnB8C,gBAAwBb,cAAcC,aAAad,QAAQsB,QAAQ1C,OAAS,EAG5EmC,YAAYV,MAAQ1B,aAAa,GAEjCoC,YAAYV,MAAQ1B,aAAa,IAAMkC,cAAcC,aAAad,QAAQsB,QAAQ1C,OAAS8C,iBAf3FX,YAAYV,MAAQ1B,aAFpB8B,uBAAyB,KAAsC,IAA/BsB,4BAAqD,IAAjBX,aAEnC,EAICA,aAAeW,4BAA+B,IAAMtB,4BA4DtFuB,CAA0BlC"} \ No newline at end of file +{"version":3,"file":"edittree_weights.min.js","sources":["../src/edittree_weights.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * This module provides functionality for managing weight calculations and adjustments for grade items.\n *\n * @module core_grades/edittree_weight\n * @copyright 2023 Shamim Rezaie \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {getString} from 'core/str';\nimport {prefetchStrings} from 'core/prefetch';\n\n/**\n * Selectors.\n *\n * @type {Object}\n */\nconst selectors = {\n weightOverrideCheckbox: 'input[type=\"checkbox\"][name^=\"weightoverride_\"]',\n weightOverrideInput: 'input[type=\"text\"][name^=\"weight_\"]',\n childrenByCategory: category => `tr[data-parent-category=\"${category}\"]`,\n categoryByIdentifier: identifier => `tr.category[data-category=\"${identifier}\"]`,\n};\n\n/**\n * An object representing grading-related constants.\n * The same as what's defined in lib/grade/constants.php.\n *\n * @type {Object}\n * @property {Object} aggregation Aggregation settings.\n * @property {number} aggregation.sum Aggregation method: sum.\n * @property {Object} type Grade type settings.\n * @property {number} type.none Grade type: none.\n * @property {number} type.value Grade type: value.\n * @property {number} type.scale Grade type: scale.\n */\nconst grade = {\n aggregation: {\n sum: 13,\n },\n};\n\n/**\n * The character used as the decimal separator for number formatting.\n *\n * @type {string}\n */\nlet decimalSeparator;\n\n/**\n * This setting indicates if we should use algorithm prior to MDL-49257 fix for calculating extra credit weights.\n * Even though the old algorithm has bugs in it, we need to preserve existing grades.\n *\n * @type {boolean}\n */\nlet oldExtraCreditCalculation;\n\n/**\n * Recalculates the natural weights for grade items within a given category.\n *\n * @param {HTMLElement} categoryElement The DOM element representing the category.\n */\n// Suppress 'complexity' linting rule to keep this function as close to grade_category::auto_update_weights.\n// eslint-disable-next-line complexity\nconst recalculateNaturalWeights = (categoryElement) => {\n const childElements = document.querySelectorAll(selectors.childrenByCategory(categoryElement.dataset.category));\n\n // Calculate the sum of the grademax's of all the items within this category.\n let totalGradeMax = 0;\n\n // Out of 100, how much weight has been manually overridden by a user?\n let totalOverriddenWeight = 0;\n let totalOverriddenGradeMax = 0;\n\n // Has every assessment in this category been overridden?\n let automaticGradeItemsPresent = false;\n // Does the grade item require normalising?\n let requiresNormalising = false;\n\n // This array keeps track of the id and weight of every grade item that has been overridden.\n const overrideArray = {};\n\n for (const childElement of childElements) {\n const weightInput = childElement.querySelector(selectors.weightOverrideInput);\n const weightCheckbox = childElement.querySelector(selectors.weightOverrideCheckbox);\n\n // There are cases where a grade item should be excluded from calculations:\n // - If the item's grade type is 'text' or 'none'.\n // - If the grade item is an outcome item and the settings are set to not aggregate outcome items.\n // - If the item's grade type is 'scale' and the settings are set to ignore scales in aggregations.\n // All these cases are already taken care of in the backend, and no 'weight' input element is rendered on the page\n // if a grade item should not have a weight.\n if (!weightInput) {\n continue;\n }\n\n const itemWeight = parseWeight(weightInput.value);\n const itemAggregationCoefficient = parseInt(childElement.dataset.aggregationcoef);\n const itemGradeMax = parseFloat(childElement.dataset.grademax);\n\n // Record the ID and the weight for this grade item.\n overrideArray[childElement.dataset.itemid] = {\n extraCredit: itemAggregationCoefficient,\n weight: itemWeight,\n weightOverride: weightCheckbox.checked,\n };\n // If this item has had its weight overridden then set the flag to true, but\n // only if all previous items were also overridden. Note that extra credit items\n // are counted as overridden grade items.\n if (!weightCheckbox.checked && itemAggregationCoefficient === 0) {\n automaticGradeItemsPresent = true;\n }\n\n if (itemAggregationCoefficient > 0) {\n // An extra credit grade item doesn't contribute to totalOverriddenGradeMax.\n continue;\n } else if (weightCheckbox.checked && itemWeight <= 0) {\n // An overridden item that defines a weight of 0 does not contribute to totalOverriddenGradeMax.\n continue;\n }\n\n totalGradeMax += itemGradeMax;\n if (weightCheckbox.checked) {\n totalOverriddenWeight += itemWeight;\n totalOverriddenGradeMax += itemGradeMax;\n }\n }\n\n // Initialise this variable (used to keep track of the weight override total).\n let normaliseTotal = 0;\n // Keep a record of how much the override total is to see if it is above 100. If it is then we need to set the\n // other weights to zero and normalise the others.\n let overriddenTotal = 0;\n // Total up all the weights.\n for (const gradeItemDetail of Object.values(overrideArray)) {\n // Exclude grade items with extra credit or negative weights (which will be set to zero later).\n if (!gradeItemDetail.extraCredit && gradeItemDetail.weight > 0) {\n normaliseTotal += gradeItemDetail.weight;\n }\n // The overridden total includes items that are marked as overridden, not extra credit, and have a positive weight.\n if (gradeItemDetail.weightOverride && !gradeItemDetail.extraCredit && gradeItemDetail.weight > 0) {\n // Add overridden weights up to see if they are greater than 1.\n overriddenTotal += gradeItemDetail.weight;\n }\n }\n if (overriddenTotal > 100) {\n // Make sure that this category of weights gets normalised.\n requiresNormalising = true;\n // The normalised weights are only the overridden weights, so we just use the total of those.\n normaliseTotal = overriddenTotal;\n }\n\n const totalNonOverriddenGradeMax = totalGradeMax - totalOverriddenGradeMax;\n\n for (const childElement of childElements) {\n const weightInput = childElement.querySelector(selectors.weightOverrideInput);\n const weightCheckbox = childElement.querySelector(selectors.weightOverrideCheckbox);\n const itemAggregationCoefficient = parseInt(childElement.dataset.aggregationcoef);\n const itemGradeMax = parseFloat(childElement.dataset.grademax);\n\n if (!weightInput) {\n continue;\n } else if (!oldExtraCreditCalculation && itemAggregationCoefficient > 0 && weightCheckbox.checked) {\n // For an item with extra credit ignore other weights and overrides but do not change anything at all\n // if its weight was already overridden.\n continue;\n }\n\n // Remove any error messages and classes.\n weightInput.classList.remove('is-invalid');\n const errorArea = weightInput.closest('td').querySelector('.invalid-feedback');\n errorArea.textContent = '';\n\n if (!oldExtraCreditCalculation && itemAggregationCoefficient > 0 && !weightCheckbox.checked) {\n // For an item with extra credit ignore other weights and overrides.\n weightInput.value = totalGradeMax ? formatWeight(itemGradeMax * 100 / totalGradeMax) : 0;\n } else if (!weightCheckbox.checked) {\n // Calculations with a grade maximum of zero will cause problems. Just set the weight to zero.\n if (totalOverriddenWeight >= 100 || totalNonOverriddenGradeMax === 0 || itemGradeMax === 0) {\n // There is no more weight to distribute.\n weightInput.value = formatWeight(0);\n } else {\n // Calculate this item's weight as a percentage of the non-overridden total grade maxes\n // then convert it to a proportion of the available non-overridden weight.\n weightInput.value = formatWeight((itemGradeMax / totalNonOverriddenGradeMax) * (100 - totalOverriddenWeight));\n }\n } else if ((!automaticGradeItemsPresent && normaliseTotal !== 100) || requiresNormalising ||\n overrideArray[childElement.dataset.itemid].weight < 0) {\n if (overrideArray[childElement.dataset.itemid].weight < 0) {\n weightInput.value = formatWeight(0);\n } else {\n const error = normaliseTotal > 100 ? 'erroroverweight' : 'errorunderweight';\n // eslint-disable-next-line promise/always-return,promise/catch-or-return\n getString(error, 'core_grades').then((errorString) => {\n errorArea.textContent = errorString;\n });\n weightInput.classList.add('is-invalid');\n }\n }\n }\n};\n\n/**\n * Formats a weight value as a string with up to 3 decimal places.\n *\n * @param {number} weight The weight value to be formatted.\n * @returns {string} The formatted weight value with the specified decimal places.\n */\nconst formatWeight = (weight) => {\n return weight.toFixed(3).replace(/0{0,2}$/, '').replace('.', decimalSeparator);\n};\n\n/**\n * Parses a weight string and returns a normalized float value.\n *\n * @param {string} weightString The weight as a string, possibly with localized formatting.\n * @returns {number} The parsed weight as a float. If parsing fails, returns 0.\n */\nconst parseWeight = (weightString) => {\n const normalizedWeightString = weightString.replace(decimalSeparator, '.');\n return isNaN(Number(normalizedWeightString)) ? 0 : parseFloat(normalizedWeightString || 0);\n};\n\n/**\n * Initializes the weight management module with optional configuration.\n *\n * @param {string} decSep The character used as the decimal separator for number formatting.\n * @param {boolean} oldCalculation A flag indicating whether to use the old (pre MDL-49257) extra credit calculation.\n */\nexport const init = (decSep, oldCalculation) => {\n decimalSeparator = decSep;\n oldExtraCreditCalculation = oldCalculation;\n prefetchStrings('core_grades', ['erroroverweight', 'errorunderweight']);\n\n document.addEventListener('change', e => {\n // Update the weights of all grade items in the category when the weight of any grade item in the category is changed.\n if (e.target.matches(selectors.weightOverrideInput) || e.target.matches(selectors.weightOverrideCheckbox)) {\n // The following is named gradeItemRow, but it may also be a row that's representing a grade category.\n // It's ok because it serves as the categories associated grade item in our calculations.\n const gradeItemRow = e.target.closest('tr');\n const categoryElement = document.querySelector(selectors.categoryByIdentifier(gradeItemRow.dataset.parentCategory));\n\n // This is only required if we are using natural weights.\n if (parseInt(categoryElement.dataset.aggregation) === grade.aggregation.sum) {\n const weightElement = gradeItemRow.querySelector(selectors.weightOverrideInput);\n weightElement.value = formatWeight(parseWeight(weightElement.value));\n recalculateNaturalWeights(categoryElement);\n }\n }\n });\n\n document.addEventListener('submit', e => {\n // If the form is being submitted, then we need to ensure that the weight input fields are all set to\n // a valid value.\n if (e.target.matches('#gradetreeform')) {\n const firstInvalidWeightInput = e.target.querySelector('input.is-invalid');\n if (firstInvalidWeightInput) {\n const firstFocusableInvalidWeightInput = e.target.querySelector('input.is-invalid:enabled');\n if (firstFocusableInvalidWeightInput) {\n firstFocusableInvalidWeightInput.focus();\n } else {\n firstInvalidWeightInput.scrollIntoView({block: 'center'});\n }\n e.preventDefault();\n }\n }\n });\n};\n"],"names":["selectors","category","identifier","grade","sum","decimalSeparator","oldExtraCreditCalculation","formatWeight","weight","toFixed","replace","parseWeight","weightString","normalizedWeightString","isNaN","Number","parseFloat","decSep","oldCalculation","document","addEventListener","e","target","matches","gradeItemRow","closest","categoryElement","querySelector","dataset","parentCategory","parseInt","aggregation","weightElement","value","childElements","querySelectorAll","totalGradeMax","totalOverriddenWeight","totalOverriddenGradeMax","automaticGradeItemsPresent","requiresNormalising","overrideArray","childElement","weightInput","weightCheckbox","itemWeight","itemAggregationCoefficient","aggregationcoef","itemGradeMax","grademax","itemid","extraCredit","weightOverride","checked","normaliseTotal","overriddenTotal","gradeItemDetail","Object","values","totalNonOverriddenGradeMax","classList","remove","errorArea","textContent","error","then","errorString","add","recalculateNaturalWeights","firstInvalidWeightInput","firstFocusableInvalidWeightInput","focus","scrollIntoView","block","preventDefault"],"mappings":"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"} \ No newline at end of file diff --git a/grade/amd/src/edittree_weights.js b/grade/amd/src/edittree_weights.js index e1f2889edcc..f12c718e029 100644 --- a/grade/amd/src/edittree_weights.js +++ b/grade/amd/src/edittree_weights.js @@ -21,6 +21,9 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +import {getString} from 'core/str'; +import {prefetchStrings} from 'core/prefetch'; + /** * Selectors. * @@ -144,12 +147,11 @@ const recalculateNaturalWeights = (categoryElement) => { let overriddenTotal = 0; // Total up all the weights. for (const gradeItemDetail of Object.values(overrideArray)) { - // If the grade item has extra credit, then don't add it to normaliseTotal. - if (!gradeItemDetail.extraCredit) { + // Exclude grade items with extra credit or negative weights (which will be set to zero later). + if (!gradeItemDetail.extraCredit && gradeItemDetail.weight > 0) { normaliseTotal += gradeItemDetail.weight; } - // The overridden total comprises items that are set as overridden, are not extra credit, and have a value - // greater than zero. + // The overridden total includes items that are marked as overridden, not extra credit, and have a positive weight. if (gradeItemDetail.weightOverride && !gradeItemDetail.extraCredit && gradeItemDetail.weight > 0) { // Add overridden weights up to see if they are greater than 1. overriddenTotal += gradeItemDetail.weight; @@ -178,6 +180,11 @@ const recalculateNaturalWeights = (categoryElement) => { continue; } + // Remove any error messages and classes. + weightInput.classList.remove('is-invalid'); + const errorArea = weightInput.closest('td').querySelector('.invalid-feedback'); + errorArea.textContent = ''; + if (!oldExtraCreditCalculation && itemAggregationCoefficient > 0 && !weightCheckbox.checked) { // For an item with extra credit ignore other weights and overrides. weightInput.value = totalGradeMax ? formatWeight(itemGradeMax * 100 / totalGradeMax) : 0; @@ -191,16 +198,17 @@ const recalculateNaturalWeights = (categoryElement) => { // then convert it to a proportion of the available non-overridden weight. weightInput.value = formatWeight((itemGradeMax / totalNonOverriddenGradeMax) * (100 - totalOverriddenWeight)); } - } else if ((!automaticGradeItemsPresent && normaliseTotal !== 100) || (requiresNormalising) || + } else if ((!automaticGradeItemsPresent && normaliseTotal !== 100) || requiresNormalising || overrideArray[childElement.dataset.itemid].weight < 0) { - // Just divide the overridden weight for this item against the total weight override of all - // items in this category. - if (normaliseTotal === 0 || overrideArray[childElement.dataset.itemid].weight < 0) { - // If the normalised total equals zero, or the weight value is less than zero, - // set the weight for the grade item to zero. + if (overrideArray[childElement.dataset.itemid].weight < 0) { weightInput.value = formatWeight(0); } else { - weightInput.value = formatWeight(100 * overrideArray[childElement.dataset.itemid].weight / normaliseTotal); + const error = normaliseTotal > 100 ? 'erroroverweight' : 'errorunderweight'; + // eslint-disable-next-line promise/always-return,promise/catch-or-return + getString(error, 'core_grades').then((errorString) => { + errorArea.textContent = errorString; + }); + weightInput.classList.add('is-invalid'); } } } @@ -236,6 +244,7 @@ const parseWeight = (weightString) => { export const init = (decSep, oldCalculation) => { decimalSeparator = decSep; oldExtraCreditCalculation = oldCalculation; + prefetchStrings('core_grades', ['erroroverweight', 'errorunderweight']); document.addEventListener('change', e => { // Update the weights of all grade items in the category when the weight of any grade item in the category is changed. @@ -253,4 +262,21 @@ export const init = (decSep, oldCalculation) => { } } }); + + document.addEventListener('submit', e => { + // If the form is being submitted, then we need to ensure that the weight input fields are all set to + // a valid value. + if (e.target.matches('#gradetreeform')) { + const firstInvalidWeightInput = e.target.querySelector('input.is-invalid'); + if (firstInvalidWeightInput) { + const firstFocusableInvalidWeightInput = e.target.querySelector('input.is-invalid:enabled'); + if (firstFocusableInvalidWeightInput) { + firstFocusableInvalidWeightInput.focus(); + } else { + firstInvalidWeightInput.scrollIntoView({block: 'center'}); + } + e.preventDefault(); + } + } + }); }; diff --git a/grade/templates/weight_override_field.mustache b/grade/templates/weight_override_field.mustache index 7d01cc418c3..39dc5540829 100644 --- a/grade/templates/weight_override_field.mustache +++ b/grade/templates/weight_override_field.mustache @@ -39,4 +39,5 @@ % + diff --git a/grade/tests/behat/grade_aggregation.feature b/grade/tests/behat/grade_aggregation.feature index 63a2f350f6d..b99dc35141a 100644 --- a/grade/tests/behat/grade_aggregation.feature +++ b/grade/tests/behat/grade_aggregation.feature @@ -453,40 +453,40 @@ Feature: We can use calculated grade totals And I should see "270.00 (24.77 %)" in the ".course" "css_element" Scenario: Natural aggregation from the setup screen - And I navigate to "Setup > Gradebook setup" in the course gradebook - + When I navigate to "Setup > Gradebook setup" in the course gradebook And I set the following settings for grade item "Course 1" of type "course" on "setup" page: | Aggregation | Natural | And I set the following settings for grade item "Sub category 1" of type "category" on "setup" page: | Aggregation | Natural | And I set the following settings for grade item "Sub category 2 &" of type "category" on "setup" page: | Aggregation | Natural | - - And I set the field "Override weight of Test assignment one &" to "1" And the field "Weight of Test assignment one &" matches value "37.975" - And I set the field "Weight of Test assignment one &" to "10" - - And I set the field "Override weight of Test assignment two" to "1" And the field "Weight of Test assignment two" matches value "12.658" + And the field "Weight of Sub category 1" matches value "5.696" + And the field "Weight of Sub category 2" matches value "5.696" + And I set the field "Override weight of Test assignment one &" to "1" + And I set the field "Weight of Test assignment one &" to "10" + And the field "Weight of Test assignment two" matches value "18.367" + And the field "Weight of Sub category 1" matches value "8.265" + And the field "Weight of Sub category 2" matches value "8.265" + And I set the field "Override weight of Test assignment two" to "1" And I set the field "Override weight of Test assignment two" to "0" - - And I set the field "Override weight of Test assignment six" to "1" And the field "Weight of Test assignment six" matches value "22.222" + And I set the field "Override weight of Test assignment six" to "1" And I set the field "Weight of Test assignment six" to "50" And I set the field "Override weight of Test assignment six" to "0" - - And I set the field "Override weight of Test assignment ten" to "1" + And the field "Weight of Test assignment six" matches value "22.222" And the field "Weight of Test assignment ten" matches value "33.333" + And I set the field "Override weight of Test assignment ten" to "1" And I set the field "Weight of Test assignment ten" to "50" - And I set the field "Override weight of Sub category 1" to "1" - And the field "Weight of Sub category 1" matches value "5.696" And I set the field "Weight of Sub category 1" to "15" - - When I press "Save changes" - And I set the field "Override weight of Test assignment two" to "1" - And I set the field "Override weight of Test assignment six" to "1" - + Then the field "Weight of Test assignment one &" matches value "10.0" + And the field "Weight of Test assignment two" matches value "16.854" + And the field "Weight of Test assignment six" matches value "22.222" + And the field "Weight of Test assignment ten" matches value "50.0" + And the field "Weight of Sub category 1" matches value "15.0" + And I press "Save changes" Then the field "Weight of Test assignment one &" matches value "10.0" And the field "Weight of Test assignment two" matches value "16.854" And the field "Weight of Test assignment six" matches value "22.222" @@ -496,9 +496,9 @@ Feature: We can use calculated grade totals And I set the field "Override weight of Test assignment two" to "0" And I set the field "Override weight of Test assignment six" to "0" And I set the field "Override weight of Sub category 1" to "0" + And the field "Weight of Test assignment one &" matches value "37.975" + And the field "Weight of Sub category 1" matches value "5.696" And I press "Save changes" - And I set the field "Override weight of Test assignment one &" to "1" - And I set the field "Override weight of Sub category 1" to "1" And the field "Weight of Test assignment one &" matches value "37.975" And the field "Weight of Sub category 1" matches value "5.696" And I reset weights for grade category "Sub category 2 &" diff --git a/grade/tests/behat/grade_natural_normalisation.feature b/grade/tests/behat/grade_natural_normalisation.feature index 78965723b7e..7ed0f9e0b64 100644 --- a/grade/tests/behat/grade_natural_normalisation.feature +++ b/grade/tests/behat/grade_natural_normalisation.feature @@ -34,7 +34,6 @@ Feature: We can use natural aggregation and weights will be normalised to a tota @javascript Scenario: Setting all weights in a category to exactly one hundred in total. - And the field "Weight of Test assignment five" matches value "44.444" And the field "Weight of Test assignment six" matches value "22.222" And the field "Weight of Test assignment seven" matches value "33.333" @@ -45,57 +44,55 @@ Feature: We can use natural aggregation and weights will be normalised to a tota And I set the field "Weight of Test assignment six" to "50" And I set the field "Weight of Test assignment seven" to "20" And I press "Save changes" - Then I should not see "Your weights have been adjusted to total 100." And the field "Weight of Test assignment five" matches value "30.0" And the field "Weight of Test assignment six" matches value "50.0" And the field "Weight of Test assignment seven" matches value "20.0" @javascript - Scenario: Setting all weights in a category to less than one hundred is normalised. - + Scenario: Setting all weights in a category to less than one hundred is prevented. When I set the field "Override weight of Test assignment five" to "1" And I set the field "Override weight of Test assignment six" to "1" And I set the field "Override weight of Test assignment seven" to "1" And I set the field "Weight of Test assignment five" to "1" And I set the field "Weight of Test assignment six" to "1" And I set the field "Weight of Test assignment seven" to "2" + Then I should see "Weight total is less than 100%." in the "Test assignment five" "table_row" + And I should see "Weight total is less than 100%." in the "Test assignment six" "table_row" + And I should see "Weight total is less than 100%." in the "Test assignment seven" "table_row" + And I start watching to see if a new page loads And I press "Save changes" - - Then I should see "Your weights have been adjusted to total 100." - And the field "Weight of Test assignment five" matches value "25.0" - And the field "Weight of Test assignment six" matches value "25.0" - And the field "Weight of Test assignment seven" matches value "50.0" + And a new page should not have loaded since I started watching @javascript - Scenario: Set one of the grade item weights to a figure over one hundred. - + Scenario: Set one of the grade item weights to a figure over one hundred is prevented. When I set the field "Override weight of Test assignment five" to "1" And I set the field "Weight of Test assignment five" to "120" - And I press "Save changes" - - Then I should see "Your weights have been adjusted to total 100." - And the field "Weight of Test assignment five" matches value "100.0" + Then I should see "Weight total exceeds 100%." in the "Test assignment five" "table_row" + And the field "Weight of Test assignment five" matches value "120.0" And the field "Weight of Test assignment six" matches value "0.0" And the field "Weight of Test assignment seven" matches value "0.0" + And I start watching to see if a new page loads + And I press "Save changes" + And a new page should not have loaded since I started watching @javascript - Scenario: Setting several but not all grade item weights to over one hundred each. - + Scenario: Setting several but not all grade item weights to over one hundred each is prevented. When I set the field "Override weight of Test assignment five" to "1" And I set the field "Override weight of Test assignment six" to "1" And I set the field "Weight of Test assignment five" to "150" And I set the field "Weight of Test assignment six" to "150" - And I press "Save changes" - - Then I should see "Your weights have been adjusted to total 100." - And the field "Weight of Test assignment five" matches value "50.000" - And the field "Weight of Test assignment six" matches value "50.000" + Then I should see "Weight total exceeds 100%." in the "Test assignment five" "table_row" + And I should see "Weight total exceeds 100%." in the "Test assignment six" "table_row" + And the field "Weight of Test assignment five" matches value "150.0" + And the field "Weight of Test assignment six" matches value "150.0" And the field "Weight of Test assignment seven" matches value "0.0" + And I start watching to see if a new page loads + And I press "Save changes" + And a new page should not have loaded since I started watching @javascript Scenario: Grade items weights are not normalised when all grade item weights are overridden (sum exactly 100). Extra credit is set respectful to number of items. - When I set the following settings for grade item "Test assignment seven" of type "gradeitem" on "setup" page: | Extra credit | 1 | And the field "Weight of Test assignment five" matches value "66.667" @@ -106,7 +103,6 @@ Feature: We can use natural aggregation and weights will be normalised to a tota And I set the field "Weight of Test assignment five" to "60" And I set the field "Weight of Test assignment six" to "40" And I press "Save changes" - Then I should not see "Your weights have been adjusted to total 100." And the field "Weight of Test assignment five" matches value "60.000" And the field "Weight of Test assignment six" matches value "40.000" @@ -117,40 +113,42 @@ Feature: We can use natural aggregation and weights will be normalised to a tota And the field "Weight of Test assignment seven" matches value "50.0" @javascript - Scenario: Grade items weights are normalised when all grade item weights are overridden (sum over 100). Extra credit is set respectful to number of items. - + Scenario: Setting grade items weights is prevented when all grade item weights are overridden (sum over 100). Extra credit is set respectful to number of items. When I set the following settings for grade item "Test assignment seven" of type "gradeitem" on "setup" page: | Extra credit | 1 | And I set the field "Override weight of Test assignment five" to "1" And I set the field "Override weight of Test assignment six" to "1" And I set the field "Weight of Test assignment five" to "60" And I set the field "Weight of Test assignment six" to "50" - And I press "Save changes" - - Then I should see "Your weights have been adjusted to total 100." - And the field "Weight of Test assignment five" matches value "54.545" - And the field "Weight of Test assignment six" matches value "45.455" + Then I should see "Weight total exceeds 100%." in the "Test assignment five" "table_row" + And I should see "Weight total exceeds 100%." in the "Test assignment six" "table_row" + And the field "Weight of Test assignment five" matches value "60.0" + And the field "Weight of Test assignment six" matches value "50.0" And the field "Weight of Test assignment seven" matches value "50.0" + And I start watching to see if a new page loads + And I press "Save changes" + And a new page should not have loaded since I started watching And I reset weights for grade category "Sub category 1" And the field "Weight of Test assignment five" matches value "66.667" And the field "Weight of Test assignment six" matches value "33.333" And the field "Weight of Test assignment seven" matches value "50.0" @javascript - Scenario: Grade items weights are normalised when all grade item weights are overridden (sum under 100). Extra credit is set respectful to number of items. - + Scenario: Setting grade items weights is prevented when all grade item weights are overridden (sum under 100). Extra credit is set respectful to number of items. When I set the following settings for grade item "Test assignment seven" of type "gradeitem" on "setup" page: | Extra credit | 1 | And I set the field "Override weight of Test assignment five" to "1" And I set the field "Override weight of Test assignment six" to "1" And I set the field "Weight of Test assignment five" to "40" And I set the field "Weight of Test assignment six" to "30" - And I press "Save changes" - - Then I should see "Your weights have been adjusted to total 100." - And the field "Weight of Test assignment five" matches value "57.143" - And the field "Weight of Test assignment six" matches value "42.857" + Then I should see "Weight total is less than 100%." in the "Test assignment five" "table_row" + And I should see "Weight total is less than 100%." in the "Test assignment six" "table_row" + And the field "Weight of Test assignment five" matches value "40.0" + And the field "Weight of Test assignment six" matches value "30.0" And the field "Weight of Test assignment seven" matches value "50.0" + And I start watching to see if a new page loads + And I press "Save changes" + And a new page should not have loaded since I started watching And I reset weights for grade category "Sub category 1" And the field "Weight of Test assignment five" matches value "66.667" And the field "Weight of Test assignment six" matches value "33.333" @@ -158,13 +156,11 @@ Feature: We can use natural aggregation and weights will be normalised to a tota @javascript Scenario: Grade items weights are normalised when not all grade item weights are overridden. Extra credit is set respectful to number of items. - When I set the following settings for grade item "Test assignment seven" of type "gradeitem" on "setup" page: | Extra credit | 1 | And I set the field "Override weight of Test assignment five" to "1" And I set the field "Weight of Test assignment five" to "40" And I press "Save changes" - Then I should see "Your weights have been adjusted to total 100." And the field "Weight of Test assignment five" matches value "40.00" And the field "Weight of Test assignment six" matches value "60.000" @@ -175,9 +171,7 @@ Feature: We can use natural aggregation and weights will be normalised to a tota And the field "Weight of Test assignment seven" matches value "50.0" @javascript - Scenario: The extra credit grade item weight is overridden to a figure over one hundred and then - the grade item is set to normal. - + Scenario: The extra credit grade item weight is overridden to a figure over one hundred and then the grade item is set to normal. When I set the following settings for grade item "Test assignment seven" of type "gradeitem" on "setup" page: | Extra credit | 1 | And I set the field "Override weight of Test assignment seven" to "1" @@ -190,40 +184,37 @@ Feature: We can use natural aggregation and weights will be normalised to a tota When I set the following settings for grade item "Test assignment seven" of type "gradeitem" on "setup" page: | Extra credit | 0 | And I should see "Your weights have been adjusted to total 100." - And the field "Weight of Test assignment five" matches value "0.0" And the field "Weight of Test assignment six" matches value "0.0" And the field "Weight of Test assignment seven" matches value "100.0" @javascript - Scenario: The extra credit grade item weight is overridden to a figure over one hundred and then - the grade category is reset. - + Scenario: The extra credit grade item weight is overridden to a figure over one hundred and then the grade category is reset. When I set the following settings for grade item "Test assignment seven" of type "gradeitem" on "setup" page: | Extra credit | 1 | And I set the field "Override weight of Test assignment seven" to "1" And I set the field "Weight of Test assignment seven" to "105" And I press "Save changes" - And I reset weights for grade category "Sub category 1" And the field "Weight of Test assignment five" matches value "66.667" And the field "Weight of Test assignment six" matches value "33.333" And the field "Weight of Test assignment seven" matches value "50.0" @javascript - Scenario: Two out of three grade items weights are overridden and one is not. - The overridden grade item weights total over one hundred. - + Scenario: Two out of three grade items weights are overridden and one is not. The overridden grade item weights total over one hundred. Given I set the field "Override weight of Test assignment six" to "1" And I set the field "Override weight of Test assignment seven" to "1" And I set the field "Weight of Test assignment six" to "55" And I set the field "Weight of Test assignment seven" to "65" + Then I should not see "Weight total exceeds 100%." in the "Test assignment five" "table_row" + And I should see "Weight total exceeds 100%." in the "Test assignment six" "table_row" + And I should see "Weight total exceeds 100%." in the "Test assignment seven" "table_row" + And the field "Weight of Test assignment five" matches value "0.0" + And the field "Weight of Test assignment six" matches value "55.0" + And the field "Weight of Test assignment seven" matches value "65.0" + And I start watching to see if a new page loads And I press "Save changes" - And I should see "Your weights have been adjusted to total 100." - - Then the field "Weight of Test assignment five" matches value "0.0" - And the field "Weight of Test assignment six" matches value "45.833" - And the field "Weight of Test assignment seven" matches value "54.167" + And a new page should not have loaded since I started watching @javascript Scenario: With one grade item set as extra credit, when I reset the weights for a category they return to the natural weights. @@ -249,7 +240,6 @@ Feature: We can use natural aggregation and weights will be normalised to a tota And the field "Weight of Test assignment seven" matches value "60.0" And I set the field "Override weight of Test assignment six" to "1" And I set the field "Weight of Test assignment six" to "-25" - And I press "Save changes" And the field "Weight of Test assignment six" matches value "0.0" And the field "Weight of Test assignment seven" matches value "100.0" And I reset weights for grade category "Sub category 1" @@ -257,7 +247,11 @@ Feature: We can use natural aggregation and weights will be normalised to a tota And I set the field "Override weight of Test assignment six" to "1" And I set the field "Weight of Test assignment five" to "-10" And I set the field "Weight of Test assignment six" to "120" - And I press "Save changes" + And I should see "Weight total exceeds 100%." in the "Test assignment five" "table_row" + And I should see "Weight total exceeds 100%." in the "Test assignment six" "table_row" And the field "Weight of Test assignment five" matches value "0.0" - And the field "Weight of Test assignment six" matches value "100.0" + And the field "Weight of Test assignment six" matches value "120.0" And the field "Weight of Test assignment seven" matches value "0.0" + And I start watching to see if a new page loads + And I press "Save changes" + And a new page should not have loaded since I started watching diff --git a/grade/tests/behat/grade_natural_normalisation_20150619.feature b/grade/tests/behat/grade_natural_normalisation_20150619.feature index ccc07a71525..67f0791740d 100644 --- a/grade/tests/behat/grade_natural_normalisation_20150619.feature +++ b/grade/tests/behat/grade_natural_normalisation_20150619.feature @@ -57,43 +57,47 @@ Feature: Gradebook calculations for natural weights normalisation before the fix And the field "Weight of Test assignment seven" matches value "50.0" @javascript - Scenario: Grade items weights are normalised when all grade item weights are overridden (sum over 100). Extra credit is set to zero (before the fix 20150619). + Scenario: Setting grade items weights is prevented when all grade item weights are overridden (sum over 100). Extra credit is set to zero (before the fix 20150619). When I set the following settings for grade item "Test assignment seven" of type "gradeitem" on "setup" page: | Extra credit | 1 | And I set the field "Override weight of Test assignment five" to "1" And I set the field "Override weight of Test assignment six" to "1" And I set the field "Weight of Test assignment five" to "60" And I set the field "Weight of Test assignment six" to "50" - And I press "Save changes" - - Then I should see "Your weights have been adjusted to total 100." - And the field "Weight of Test assignment five" matches value "54.545" - And the field "Weight of Test assignment six" matches value "45.455" + Then I should see "Weight total exceeds 100%." in the "Test assignment five" "table_row" + And I should see "Weight total exceeds 100%." in the "Test assignment six" "table_row" + And the field "Weight of Test assignment five" matches value "60.0" + And the field "Weight of Test assignment six" matches value "50.0" And the field "Weight of Test assignment seven" matches value "0.0" - # The weight of "seven" should be 15/30=50% (15 is the maxgrade for "seven" and 30 are max grades for this category (max grade of "five" plus max grade of "six") + And I start watching to see if a new page loads + And I press "Save changes" + And a new page should not have loaded since I started watching And I reset weights for grade category "Sub category 1" And the field "Weight of Test assignment five" matches value "66.667" And the field "Weight of Test assignment six" matches value "33.333" + # The weight of "seven" should be 15/30=50% (15 is the maxgrade for "seven" and 30 are max grades for this category (max grade of "five" plus max grade of "six") And the field "Weight of Test assignment seven" matches value "50.0" @javascript - Scenario: Grade items weights are normalised when all grade item weights are overridden (sum under 100). Extra credit is set to zero (before the fix 20150619). + Scenario: Setting grade items weights is prevented when all grade item weights are overridden (sum under 100). Extra credit is set to zero (before the fix 20150619). When I set the following settings for grade item "Test assignment seven" of type "gradeitem" on "setup" page: | Extra credit | 1 | And I set the field "Override weight of Test assignment five" to "1" And I set the field "Override weight of Test assignment six" to "1" And I set the field "Weight of Test assignment five" to "40" And I set the field "Weight of Test assignment six" to "30" - And I press "Save changes" - - Then I should see "Your weights have been adjusted to total 100." - And the field "Weight of Test assignment five" matches value "57.143" - And the field "Weight of Test assignment six" matches value "42.857" + Then I should see "Weight total is less than 100%." in the "Test assignment five" "table_row" + And I should see "Weight total is less than 100%." in the "Test assignment six" "table_row" + And the field "Weight of Test assignment five" matches value "40.0" + And the field "Weight of Test assignment six" matches value "30.0" And the field "Weight of Test assignment seven" matches value "0.0" - # The weight of "seven" should be 15/30=50% (15 is the maxgrade for "seven" and 30 are max grades for this category (max grade of "five" plus max grade of "six") + And I start watching to see if a new page loads + And I press "Save changes" + And a new page should not have loaded since I started watching And I reset weights for grade category "Sub category 1" And the field "Weight of Test assignment five" matches value "66.667" And the field "Weight of Test assignment six" matches value "33.333" + # The weight of "seven" should be 15/30=50% (15 is the maxgrade for "seven" and 30 are max grades for this category (max grade of "five" plus max grade of "six") And the field "Weight of Test assignment seven" matches value "50.0" @javascript @@ -103,7 +107,6 @@ Feature: Gradebook calculations for natural weights normalisation before the fix And I set the field "Override weight of Test assignment five" to "1" And I set the field "Weight of Test assignment five" to "40" And I press "Save changes" - Then I should see "Your weights have been adjusted to total 100." And the field "Weight of Test assignment five" matches value "40.00" And the field "Weight of Test assignment six" matches value "60.000" diff --git a/lang/en/grades.php b/lang/en/grades.php index 0edfa6b4bf3..67356c0bcb5 100644 --- a/lang/en/grades.php +++ b/lang/en/grades.php @@ -187,9 +187,11 @@ $string['errorgradevaluenonnumeric'] = 'Received non-numeric for low or high gra $string['errornocalculationallowed'] = 'Calculations are not allowed for this item'; $string['errornocategorisedid'] = 'Could not get an uncategorised id!'; $string['errornocourse'] = 'Could not get course information'; +$string['erroroverweight'] = 'Weight total exceeds 100%.'; $string['errorreprintheadersnonnumeric'] = 'Received non-numeric value for reprint-headers'; $string['errorsavegrade'] = 'Could not save grade, sorry.'; $string['errorsettinggrade'] = 'Error saving "{$a->itemname}" grade for userid {$a->userid}'; +$string['errorunderweight'] = 'Weight total is less than 100%.'; $string['errorupdatinggradecategoryaggregateonlygraded'] = 'Error updating the "Aggregate only non-empty grades" setting of grade category ID {$a->id}'; $string['errorupdatinggradecategoryaggregateoutcomes'] = 'Error updating the "Include outcomes in aggregation" setting of grade category ID {$a->id}'; $string['errorupdatinggradecategoryaggregation'] = 'Error updating the aggregation type of grade category ID {$a->id}';