diff --git a/docs/getting-started/db-insert-statements/insert-rubric-database.md b/docs/getting-started/db-insert-statements/insert-rubric-database.md index 79adee945..02ff5b477 100644 --- a/docs/getting-started/db-insert-statements/insert-rubric-database.md +++ b/docs/getting-started/db-insert-statements/insert-rubric-database.md @@ -10,7 +10,7 @@ The primary things that needs to be updated on an ongoing basis are: ## Statements > [!NOTE] -> Updated as of **WINTER 2026** +> Updated as of **SUMMER 2026** ```mysql INSERT INTO rubric_config (phase, type, category, criteria, points, rubric_id) VALUES @@ -23,16 +23,16 @@ INSERT INTO rubric_config (phase, type, category, criteria, points, rubric_id) V ('Phase3', 'GIT_COMMITS', 'Git Commits', 'Necessary commit amount', 0, '90344_2520'), ('Phase3', 'PASSOFF_TESTS', 'Web API Works', 'All pass off test cases in StandardAPITests.java succeed', 125, '_5202'), ('Phase3', 'QUALITY', 'Code Quality', 'Chess Code Quality Rubric (see GitHub)', 30, '_3003'), -('Phase3', 'UNIT_TESTS', 'Unit Tests', 'All test cases pass\nEach public method on your Service classes has two test cases, one positive test and one negative test\nEvery test case includes an Assert statement of some type', 25, '90344_776'), +('Phase3', 'UNIT_TESTS', 'Unit Tests', 'All test cases pass\nCode coverage on service package must be at least 80% line coverage\nEvery test case includes an Assert statement of some type', 25, '90344_776'), ('Phase4', 'GIT_COMMITS', 'Git Commits', 'Necessary commit amount', 0, '90346_6245'), ('Phase4', 'PASSOFF_TESTS', 'Functionality', 'All pass off test cases succeed', 100, '_2614'), ('Phase4', 'QUALITY', 'Code Quality', 'Chess Code Quality Rubric (see GitHub)', 30, '90346_8398'), -('Phase4', 'UNIT_TESTS', 'Unit Tests', 'All test cases pass\nEach public method on DAO classes has two test cases, one positive test and one negative test\nEvery test case includes an Assert statement of some type', 25, '90346_5755'), +('Phase4', 'UNIT_TESTS', 'Unit Tests', 'All test cases pass\nCode coverage on dataaccess package must be at least 80% line coverage\nEvery test case includes an Assert statement of some type', 25, '90346_5755'), ('Phase5', 'GIT_COMMITS', 'Git Commits', 'Necessary commit amount', 0, '90347_8497'), ('Phase5', 'QUALITY', 'Code Quality', 'Chess Code Quality Rubric (see GitHub)', 30, '90347_9378'), -('Phase5', 'UNIT_TESTS', 'Unit Tests', 'All test cases pass\nEach public method on the Server Facade class has two test cases, one positive test and one negative test\nEvery test case includes an Assert statement of some type', 25, '90347_2215'), +('Phase5', 'UNIT_TESTS', 'Unit Tests', 'All test cases pass\nCode coverage on facade package must be at least 80% line coverage\nEvery test case includes an Assert statement of some type', 25, '90347_2215'), ('Phase6', 'GIT_COMMITS', 'Git Commits', 'Necessary commit amount', 0, '90348_9048'), -('Phase6', 'PASSOFF_TESTS', 'Automated Pass Off Test Cases', 'Each provided test case passed is worth a proportional number of points ((passed / total) * 50).', 50, '90348_899'), +('Phase6', 'PASSOFF_TESTS', 'Automated Pass Off Test Cases', 'All pass off test cases succeed', 50, '90348_899'), ('Phase6', 'QUALITY', 'Code Quality', 'Chess Code Quality Rubric (see GitHub)', 30, '90348_3792'), ('Quality', 'QUALITY', 'Code Quality', 'Chess Code Quality Rubric (see GitHub)', 30, NULL); ``` diff --git a/src/main/java/edu/byu/cs/autograder/compile/CompileHelper.java b/src/main/java/edu/byu/cs/autograder/compile/CompileHelper.java index c2c5eb188..815839bd5 100644 --- a/src/main/java/edu/byu/cs/autograder/compile/CompileHelper.java +++ b/src/main/java/edu/byu/cs/autograder/compile/CompileHelper.java @@ -26,7 +26,8 @@ public CompileHelper(GradingContext gradingContext) { private final Collection currentVerifiers = List.of(new ProjectStructureVerifier(), new ModuleIndependenceVerifier(), new ModifiedTestFilesVerifier(), - new TestLocationVerifier(), new TestClassNameVerifier(), new ServerFacadeTestPortVerifier(), new ModifiedWebResourcesVerifier()); + new TestLocationVerifier(), new TestClassNameVerifier(), new ServerFacadeTestPortVerifier(), + new ModifiedWebResourcesVerifier(), new CodeCoverageVerifier()); private final Collection currentModifiers = diff --git a/src/main/java/edu/byu/cs/autograder/compile/verifers/CodeCoverageVerifier.java b/src/main/java/edu/byu/cs/autograder/compile/verifers/CodeCoverageVerifier.java new file mode 100644 index 000000000..7dec285b8 --- /dev/null +++ b/src/main/java/edu/byu/cs/autograder/compile/verifers/CodeCoverageVerifier.java @@ -0,0 +1,67 @@ +package edu.byu.cs.autograder.compile.verifers; + +import edu.byu.cs.autograder.GradingContext; +import edu.byu.cs.autograder.GradingException; +import edu.byu.cs.autograder.compile.StudentCodeReader; +import edu.byu.cs.autograder.compile.StudentCodeVerifier; +import edu.byu.cs.model.CoverageRequirement; +import edu.byu.cs.model.Phase; +import edu.byu.cs.util.PhaseUtils; + +import java.io.File; +import java.util.Set; +import java.util.TreeSet; + +/** + * Verifies that the necessary packages and classes exists to collect code coverage on unit tests + */ +public class CodeCoverageVerifier implements StudentCodeVerifier { + private final Set missingPackages = new TreeSet<>(); + private final Set missingFiles = new TreeSet<>(); + + @Override + public void verify(GradingContext context, StudentCodeReader reader) throws GradingException { + Phase currPhase = context.phase(); + CoverageRequirement coverage = PhaseUtils.unitTestCoverageRequirements(currPhase); + if (coverage.type() == CoverageRequirement.CoverageType.PACKAGE){ + File packageDirectory = new File(context.stageRepo(), coverage.name()); + if (!packageDirectory.isDirectory()){ + missingPackages.add(coverage.name()); + } + } + else{ + + if (reader.filesMatching(buildFileRegex(coverage.name())).findAny().isEmpty()){ + missingFiles.add(coverage.name()); + } + } + + String message = buildMessage(); + if (!message.isBlank()){ + context.observer().notifyWarning(message); + } + } + + private String buildMessage(){ + StringBuilder stringBuilder = new StringBuilder(); + if (!missingPackages.isEmpty()){ + stringBuilder.append("Missing package(s) for Code Coverage: ") + .append(String.join(", ", missingPackages)) + .append(".\n"); + } + if (!missingFiles.isEmpty()){ + stringBuilder.append("Missing file(s) for Code Coverage: ") + .append(String.join(", ", missingFiles)) + .append(".\n"); + } + if (!stringBuilder.isEmpty()){ + stringBuilder.append("Code coverage cannot be calculated without the proper project structure.\n") + .append("Please double check the phase's specification."); + } + return stringBuilder.toString(); + } + + private String buildFileRegex(String fileName){ + return "^.*[\\/]"+ fileName + "\\.java$|^"+ fileName + "\\.java$"; + } +} diff --git a/src/main/java/edu/byu/cs/autograder/test/CoverageAnalyzer.java b/src/main/java/edu/byu/cs/autograder/test/CoverageAnalyzer.java index 1d8ee404c..7c1c72073 100644 --- a/src/main/java/edu/byu/cs/autograder/test/CoverageAnalyzer.java +++ b/src/main/java/edu/byu/cs/autograder/test/CoverageAnalyzer.java @@ -1,9 +1,14 @@ package edu.byu.cs.autograder.test; import edu.byu.cs.autograder.GradingException; +import edu.byu.cs.dataAccess.DaoService; +import edu.byu.cs.dataAccess.DataAccessException; +import edu.byu.cs.dataAccess.daoInterface.ConfigurationDao; import edu.byu.cs.model.ClassCoverageAnalysis; import edu.byu.cs.model.CoverageAnalysis; import edu.byu.cs.util.FileUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.File; import java.util.Collection; @@ -11,19 +16,32 @@ /** * Parses the code coverage output stored in a JaCoCo CSV file into a - * {@link CoverageAnalysis} containing the appropriate coverage results + * {@link CoverageAnalysis} containing the appropriate coverage results. + *
+ * Supports Branch and Line coverage. Default coverage is Line Coverage. */ public class CoverageAnalyzer { - /** - * The type of coverage to be tested for. This is the only value that should be - * updated if coverage requirements change. - */ - private static final String COVERAGE_TESTED = "BRANCH"; - private static final String COVERAGE_MISSED_HEADER = COVERAGE_TESTED + "_MISSED"; - private static final String COVERAGE_COVERED_HEADER = COVERAGE_TESTED + "_COVERED"; + private final String coverageMissedHeader; + private final String coverageCoveredHeader; private static final String PACKAGE_HEADER = "PACKAGE"; private static final String CLASS_HEADER = "CLASS"; + private static final Logger LOGGER = LoggerFactory.getLogger(CoverageAnalyzer.class); + + public CoverageAnalyzer(){ + String coverageTested = "LINE"; + try{ + String config = DaoService.getConfigurationDao().getConfiguration(ConfigurationDao.Configuration.COVERAGE_TYPE, String.class); + if (config.equals("BRANCH")){ //ensure the value is valid + coverageTested = config; + } + } catch (DataAccessException e) { + LOGGER.error("Could not get coverage type, using line coverage", e); + } + coverageMissedHeader = coverageTested + "_MISSED"; + coverageCoveredHeader = coverageTested + "_COVERED"; + } + /** * Parses the output of a JaCoCo CSV file, if it exists, * and returns the coverage results as a {@link CoverageAnalysis} @@ -52,8 +70,14 @@ public CoverageAnalysis parse(File jacocoCsvOutput) throws GradingException { switch (s) { case CLASS_HEADER -> classHeader = i; case PACKAGE_HEADER -> packageHeader = i; - case COVERAGE_MISSED_HEADER -> coverageMissedHeader = i; - case COVERAGE_COVERED_HEADER -> coverageCoveredHeader = i; + default -> { + if (s.equals(this.coverageMissedHeader)){ + coverageMissedHeader = i; + } + else if (s.equals(this.coverageCoveredHeader)) { + coverageCoveredHeader = i; + } + } } } diff --git a/src/main/java/edu/byu/cs/autograder/test/TestGrader.java b/src/main/java/edu/byu/cs/autograder/test/TestGrader.java index 71195900f..a30aa7d79 100644 --- a/src/main/java/edu/byu/cs/autograder/test/TestGrader.java +++ b/src/main/java/edu/byu/cs/autograder/test/TestGrader.java @@ -71,7 +71,8 @@ public Rubric.Results runTests() throws GradingException, DataAccessException { } else { results = new TestHelper().runJUnitTests(new File(gradingContext.stageRepo(), "/" + module + "/target/" + module + "-test-dependencies.jar"), stageTestsPath, - packagesToTest(), ignoredTests(), modulesToCheckCoverage()); + packagesToTest(), ignoredTests(), modulesToCheckCoverage(), + PhaseUtils.unitTestCoverageRequirements(gradingContext.phase())); } if (results.root() == null) { diff --git a/src/main/java/edu/byu/cs/autograder/test/TestHelper.java b/src/main/java/edu/byu/cs/autograder/test/TestHelper.java index 3e2b1cc65..0cda6211f 100644 --- a/src/main/java/edu/byu/cs/autograder/test/TestHelper.java +++ b/src/main/java/edu/byu/cs/autograder/test/TestHelper.java @@ -3,7 +3,9 @@ import edu.byu.cs.autograder.GradingException; import edu.byu.cs.dataAccess.DaoService; import edu.byu.cs.dataAccess.daoInterface.ConfigurationDao; +import edu.byu.cs.model.ClassCoverageAnalysis; import edu.byu.cs.model.CoverageAnalysis; +import edu.byu.cs.model.CoverageRequirement; import edu.byu.cs.model.Rubric; import edu.byu.cs.model.TestNode; import edu.byu.cs.model.TestOutput; @@ -15,10 +17,8 @@ import java.io.File; import java.io.IOException; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Set; +import java.util.*; +import java.util.regex.Pattern; /** * A helper class for running common test operations @@ -161,7 +161,7 @@ private static List getCompileCommands(String stagePath, String chessJar * @return A TestNode object containing the results of the tests. */ TestOutput runJUnitTests(File uberJar, File compiledTests, Set packagesToTest, - Set ignoredTests, Set coverageModules) throws GradingException { + Set ignoredTests, Set coverageModules, CoverageRequirement coverageRequirement) throws GradingException { // Process cannot handle relative paths or wildcards, // so we need to only use absolute paths and find // to get the files @@ -199,6 +199,7 @@ TestOutput runJUnitTests(File uberJar, File compiledTests, Set packagesT File coverageOutput = new File(testOutputDirectory, "coverage.csv"); CoverageAnalysis coverage = coverageOutput.exists() ? new CoverageAnalyzer().parse(coverageOutput) : null; + coverage = removeUnmatchedPackages(coverageRequirement, coverage); TestNode testAnalysis = testAnalyzer.parse(junitXmlOutput, ignoredTests); return new TestOutput(testAnalysis, coverage, trimErrorOutput(error)); @@ -208,6 +209,29 @@ TestOutput runJUnitTests(File uberJar, File compiledTests, Set packagesT } } + private CoverageAnalysis removeUnmatchedPackages(CoverageRequirement requirement, CoverageAnalysis coverage) { + if (coverage == null || coverage.classAnalyses() == null || requirement == null) { + return coverage; + } + Pattern pattern = Pattern.compile("^" + requirement.name()); + if (pattern.pattern().isEmpty()){ + return coverage; + } + Collection matchedList = new ArrayList<>(); + for (ClassCoverageAnalysis classCoverageAnalysis : coverage.classAnalyses()) { + if (requirement.type() == CoverageRequirement.CoverageType.CLASS) { + if (pattern.matcher(classCoverageAnalysis.className()).find()) { + matchedList.add(classCoverageAnalysis); + } + } else { + if (pattern.matcher(classCoverageAnalysis.packageName()).find()) { + matchedList.add(classCoverageAnalysis); + } + } + } + return new CoverageAnalysis(matchedList); + } + private static List getRunCommands(Set packagesToTest, String uberJarPath) { List commands = new ArrayList<>(); commands.add("java"); diff --git a/src/main/java/edu/byu/cs/autograder/test/UnitTestGrader.java b/src/main/java/edu/byu/cs/autograder/test/UnitTestGrader.java index 0912d8a72..ce47645a0 100644 --- a/src/main/java/edu/byu/cs/autograder/test/UnitTestGrader.java +++ b/src/main/java/edu/byu/cs/autograder/test/UnitTestGrader.java @@ -1,21 +1,47 @@ package edu.byu.cs.autograder.test; +import java.io.File; +import java.util.Set; + import edu.byu.cs.autograder.GradingContext; import edu.byu.cs.autograder.GradingException; +import edu.byu.cs.dataAccess.DaoService; +import edu.byu.cs.dataAccess.DataAccessException; +import edu.byu.cs.dataAccess.daoInterface.ConfigurationDao; +import edu.byu.cs.model.ClassCoverageAnalysis; +import edu.byu.cs.model.CoverageAnalysis; import edu.byu.cs.model.Rubric; -import edu.byu.cs.model.TestOutput; import edu.byu.cs.model.TestNode; +import edu.byu.cs.model.TestOutput; import edu.byu.cs.util.PhaseUtils; - -import java.io.File; -import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Runs and scores the unit tests for the phase a submission is graded for */ public class UnitTestGrader extends TestGrader { + + private final float targetPercent; // if config can't load, 80% + private final float extraCreditPercent; // if config can't load, 90% + + private static final Logger LOGGER = LoggerFactory.getLogger(UnitTestGrader.class); + public UnitTestGrader(GradingContext gradingContext) { super(gradingContext); + float percent; + float extraPercent; + try{ + percent = DaoService.getConfigurationDao().getConfiguration(ConfigurationDao.Configuration.COVERAGE_PERCENT, Float.class); + extraPercent = DaoService.getConfigurationDao().getConfiguration(ConfigurationDao.Configuration.EXTRA_COVERAGE_PERCENT, Float.class); + } + catch (DataAccessException e){ + percent = 0.8F; + extraPercent = 0.9F; + LOGGER.error("Could not get coverage percents from config, using default values of 80% and 90%", e); + } + targetPercent = percent; + extraCreditPercent = extraPercent; } @Override @@ -45,25 +71,78 @@ protected float getScore(TestOutput testOutput) throws GradingException { if (totalTests == 0) return 0; - int minTests = PhaseUtils.minUnitTests(gradingContext.phase()); + if (testResults.getNumTestsFailed() > 0) { + return 0; + } - if (totalTests < minTests) return (float) testResults.getNumTestsPassed() / minTests; + float coveragePercent = getCoveragePercent(testOutput.coverage()); + if (Float.isNaN(coveragePercent)){ + return 0; + } - return testResults.getNumTestsPassed() / totalTests; + if (coveragePercent >= extraCreditPercent) { + return 1.05F; + } + if (coveragePercent >= targetPercent){ + return 1; + } + + return coveragePercent / targetPercent; } @Override - protected String getNotes(TestOutput testOutput) throws GradingException { + protected String getNotes(TestOutput testOutput) { TestNode testResults = testOutput.root(); - Integer totalTestsRun = testResults.getNumTestsFailed() + testResults.getNumTestsPassed(); - if (testResults.getNumTestsPassed() + testResults.getNumTestsFailed() < PhaseUtils.minUnitTests(gradingContext.phase())) - return "Not enough tests: each " + PhaseUtils.unitTestCodeUnderTest(gradingContext.phase()) + - " method should have a positive and negative test"; - return switch (testResults.getNumTestsFailed()) { - case 0 -> testResults.getNumTestsPassed() + "/" + totalTestsRun + " tests passed"; - case 1 -> "1/" + totalTestsRun + " test failed"; - default -> testResults.getNumTestsFailed() + "/" + totalTestsRun + " tests failed"; - }; + StringBuilder notes = new StringBuilder(); + + float totalTests = testResults.getNumTestsFailed() + testResults.getNumTestsPassed(); + if (totalTests == 0) { + notes.append("Did not find any unit tests. See Rubric for unit test requirement details") + .append("\n"); + return notes.toString(); + } + + if (testResults.getNumTestsFailed() > 0) { + notes.append(testResults.getNumTestsFailed() + " unit tests failed") + .append("\n") + .append("See Details for failed test") + .append("\n"); + return notes.toString(); + } + + float coverage = getCoveragePercent(testOutput.coverage()); + if (Float.isNaN(coverage)){ + notes.append("Could not calculate Coverage Percent. See Rubric for unit test requirement details") + .append("\n"); + return notes.toString(); + } + + notes.append("Coverage: " + coverage*100 + "%") + .append("\n"); + + + for (ClassCoverageAnalysis i : testOutput.coverage().classAnalyses()){ + var total = (i.covered() + i.missed()); + boolean isGoodCoverage = ((i.covered() * 1.0) / total) > targetPercent; + notes.append((isGoodCoverage) ? "✓" : "✗").append(" ") + .append(i.packageName()) + .append(".") + .append(i.className()) + .append("\n"); + } + notes.append("See Details for line coverage count") + .append("\n"); + return notes.toString(); + } + + private float getCoveragePercent(CoverageAnalysis coverage) { + float covered = 0; + float total = 0; + for (ClassCoverageAnalysis i : coverage.classAnalyses()){ + covered += i.covered(); + total += (i.covered() + i.missed()); + } + return covered / total; } @Override diff --git a/src/main/java/edu/byu/cs/dataAccess/DaoService.java b/src/main/java/edu/byu/cs/dataAccess/DaoService.java index 07b7524be..d0d959335 100644 --- a/src/main/java/edu/byu/cs/dataAccess/DaoService.java +++ b/src/main/java/edu/byu/cs/dataAccess/DaoService.java @@ -84,6 +84,9 @@ public static void initializeMemoryDAOs() { configurationDao.setConfiguration(ConfigurationDao.Configuration.PER_DAY_LATE_PENALTY, 0.1f, Float.class); configurationDao.setConfiguration(ConfigurationDao.Configuration.LINES_PER_COMMIT_REQUIRED, 5, Integer.class); configurationDao.setConfiguration(ConfigurationDao.Configuration.CLOCK_FORGIVENESS_MINUTES, 3, Integer.class); + configurationDao.setConfiguration(ConfigurationDao.Configuration.COVERAGE_PERCENT, 0.8f, Float.class); + configurationDao.setConfiguration(ConfigurationDao.Configuration.EXTRA_COVERAGE_PERCENT, 0.9f, Float.class); + configurationDao.setConfiguration(ConfigurationDao.Configuration.COVERAGE_TYPE, "LINE", String.class); rubricConfigDao.setRubricConfig(Phase.GitHub, RubricConfigDao.defaultGitHubConfig); rubricConfigDao.setRubricConfig(Phase.Phase0, RubricConfigDao.defaultPhase0Config); diff --git a/src/main/java/edu/byu/cs/dataAccess/daoInterface/ConfigurationDao.java b/src/main/java/edu/byu/cs/dataAccess/daoInterface/ConfigurationDao.java index 6e369499f..af1fe8443 100644 --- a/src/main/java/edu/byu/cs/dataAccess/daoInterface/ConfigurationDao.java +++ b/src/main/java/edu/byu/cs/dataAccess/daoInterface/ConfigurationDao.java @@ -36,6 +36,9 @@ enum Configuration { CLOCK_FORGIVENESS_MINUTES, MAX_ERROR_OUTPUT_CHARS, HOLIDAY_LIST, - SLACK_LINK + SLACK_LINK, + COVERAGE_PERCENT, + EXTRA_COVERAGE_PERCENT, + COVERAGE_TYPE } } diff --git a/src/main/java/edu/byu/cs/model/CoverageRequirement.java b/src/main/java/edu/byu/cs/model/CoverageRequirement.java new file mode 100644 index 000000000..7b334fa94 --- /dev/null +++ b/src/main/java/edu/byu/cs/model/CoverageRequirement.java @@ -0,0 +1,8 @@ +package edu.byu.cs.model; + +public record CoverageRequirement(CoverageType type, String name) { + public enum CoverageType{ + PACKAGE, + CLASS + } +} diff --git a/src/main/java/edu/byu/cs/model/PrivateConfig.java b/src/main/java/edu/byu/cs/model/PrivateConfig.java index 6a15b02e4..114cdd9d5 100644 --- a/src/main/java/edu/byu/cs/model/PrivateConfig.java +++ b/src/main/java/edu/byu/cs/model/PrivateConfig.java @@ -1,5 +1,7 @@ package edu.byu.cs.model; +import edu.byu.cs.model.request.ConfigPenaltyUpdateRequest; + import java.util.List; import java.util.Map; @@ -28,13 +30,19 @@ public record PrivateConfig( * @param linesChangedPerCommit the minimum number of lines needed for a commit to count * @param clockForgivenessMinutes the number of minutes a commit can be authored * past the time of submission + * @param coveragePercent the percentage of code coverage expected for a submission to receive full credit + * @param extraCoveragePercent the percentage of code coverage expected for a submission to receive extra credit + * @param coverageType the type of code coverage to use when calculating penalties (branch or line) */ public record PenaltyConfig( float perDayLatePenalty, float gitCommitPenalty, int maxLateDaysPenalized, int linesChangedPerCommit, - int clockForgivenessMinutes + int clockForgivenessMinutes, + float coveragePercent, + float extraCoveragePercent, + ConfigPenaltyUpdateRequest.CoverageType coverageType ){ } /** diff --git a/src/main/java/edu/byu/cs/model/request/ConfigPenaltyUpdateRequest.java b/src/main/java/edu/byu/cs/model/request/ConfigPenaltyUpdateRequest.java index 321ddecaf..a5b0f2387 100644 --- a/src/main/java/edu/byu/cs/model/request/ConfigPenaltyUpdateRequest.java +++ b/src/main/java/edu/byu/cs/model/request/ConfigPenaltyUpdateRequest.java @@ -9,11 +9,23 @@ * @param linesChangedPerCommit the minimum number of lines needed for a commit to count * @param clockForgivenessMinutes the number of minutes a commit can be authored * past the time of submission + * @param coveragePercent the percentage of code coverage expected for a submission to receive full credit + * @param extraCoveragePercent the percentage of code coverage expected for a submission to receive extra credit + * @param coverageType the type of code coverage to use when calculating penalties (branch or line) */ public record ConfigPenaltyUpdateRequest( float perDayLatePenalty, int maxLateDaysPenalized, float gitCommitPenalty, int linesChangedPerCommit, - int clockForgivenessMinutes -) {} + int clockForgivenessMinutes, + float coveragePercent, + float extraCoveragePercent, + CoverageType coverageType +) { + public enum CoverageType { + LINE, + BRANCH + } +} + diff --git a/src/main/java/edu/byu/cs/service/ConfigService.java b/src/main/java/edu/byu/cs/service/ConfigService.java index 4ccdad158..ac3a46b40 100644 --- a/src/main/java/edu/byu/cs/service/ConfigService.java +++ b/src/main/java/edu/byu/cs/service/ConfigService.java @@ -12,6 +12,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.ObjectInputFilter; import java.time.*; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; @@ -122,12 +123,17 @@ private static PublicConfig.ShutdownConfig generateShutdownConfig() throws DataA // PRIVATE CONFIG GENERATORS // private static PrivateConfig.PenaltyConfig generatePenaltyConfig() throws DataAccessException { + String coverageType = dao.getConfiguration(Configuration.COVERAGE_TYPE, String.class); return new PrivateConfig.PenaltyConfig( dao.getConfiguration(Configuration.PER_DAY_LATE_PENALTY, Float.class), dao.getConfiguration(Configuration.GIT_COMMIT_PENALTY, Float.class), dao.getConfiguration(Configuration.MAX_LATE_DAYS_TO_PENALIZE, Integer.class), dao.getConfiguration(Configuration.LINES_PER_COMMIT_REQUIRED, Integer.class), - dao.getConfiguration(Configuration.CLOCK_FORGIVENESS_MINUTES, Integer.class) + dao.getConfiguration(Configuration.CLOCK_FORGIVENESS_MINUTES, Integer.class), + dao.getConfiguration(Configuration.COVERAGE_PERCENT, Float.class), + dao.getConfiguration(Configuration.EXTRA_COVERAGE_PERCENT, Float.class), + coverageType.isEmpty() ? ConfigPenaltyUpdateRequest.CoverageType.LINE : + ConfigPenaltyUpdateRequest.CoverageType.valueOf(coverageType) ); } @@ -363,12 +369,18 @@ public static void processPenaltyUpdates(User user, ConfigPenaltyUpdateRequest r validateNonNegativeInt(request.clockForgivenessMinutes(), "Clock Forgiveness Minutes"); validateNonNegativeInt(request.maxLateDaysPenalized(), "Max Late Days Penalized"); validateNonNegativeInt(request.linesChangedPerCommit(), "Lines Changed Per Commit"); + validateValidPercentFloat(request.coveragePercent(), "Code Coverage Percent"); + validateValidPercentFloat(request.extraCoveragePercent(), "Extra Code Coverage Percent"); + validateEnum(request.coverageType().name(), ConfigPenaltyUpdateRequest.CoverageType.class); setConfigItem(user, Configuration.GIT_COMMIT_PENALTY, request.gitCommitPenalty(), Float.class); setConfigItem(user, Configuration.PER_DAY_LATE_PENALTY, request.perDayLatePenalty(), Float.class); setConfigItem(user, Configuration.CLOCK_FORGIVENESS_MINUTES, request.clockForgivenessMinutes(), Integer.class); setConfigItem(user, Configuration.MAX_LATE_DAYS_TO_PENALIZE, request.maxLateDaysPenalized(), Integer.class); setConfigItem(user, Configuration.LINES_PER_COMMIT_REQUIRED, request.linesChangedPerCommit(), Integer.class); + setConfigItem(user, Configuration.COVERAGE_PERCENT, request.coveragePercent(), Float.class); + setConfigItem(user, Configuration.EXTRA_COVERAGE_PERCENT, request.extraCoveragePercent(), Float.class); + setConfigItem(user, Configuration.COVERAGE_TYPE, request.coverageType().name(), String.class); } /** @@ -463,6 +475,10 @@ private static void validateNonNegativeInt(int value, String name) { } } + private static > void validateEnum(String value, ClassenumName){ + Enum.valueOf(enumName, value); + } + private static void setConfigItem(User admin, Configuration configKey, T value, Class type) throws DataAccessException { ConfigurationDao dao = DaoService.getConfigurationDao(); diff --git a/src/main/java/edu/byu/cs/util/PhaseUtils.java b/src/main/java/edu/byu/cs/util/PhaseUtils.java index 25497b216..cc91e4bf7 100644 --- a/src/main/java/edu/byu/cs/util/PhaseUtils.java +++ b/src/main/java/edu/byu/cs/util/PhaseUtils.java @@ -5,6 +5,7 @@ import edu.byu.cs.dataAccess.daoInterface.ConfigurationDao; import edu.byu.cs.dataAccess.DaoService; import edu.byu.cs.dataAccess.DataAccessException; +import edu.byu.cs.model.CoverageRequirement; import edu.byu.cs.model.Phase; import edu.byu.cs.model.Rubric; import edu.byu.cs.model.RubricConfig; @@ -198,6 +199,17 @@ public static int minUnitTests(Phase phase) throws GradingException { }; } + public static CoverageRequirement unitTestCoverageRequirements(Phase phase) throws GradingException { + return switch (phase){ + case Phase3 -> new CoverageRequirement( CoverageRequirement.CoverageType.PACKAGE,"service"); + case Phase4 -> new CoverageRequirement( CoverageRequirement.CoverageType.PACKAGE,"dataaccess.sql"); + case Phase5 -> new CoverageRequirement(CoverageRequirement.CoverageType.CLASS, "ServerFacade"); + default -> new CoverageRequirement(CoverageRequirement.CoverageType.PACKAGE,""); + // Originally was "client" which broke the code as there is no client package inside of + // the client module. So changed it to "" to get everything from the client module + }; + } + /** * Gets the modules needed to check for code coverage while testing student-written unit tests * diff --git a/src/main/resources/frontend/src/components/config/PenaltyConfigEditor.vue b/src/main/resources/frontend/src/components/config/PenaltyConfigEditor.vue index 293f2f7f7..e0d98bad7 100644 --- a/src/main/resources/frontend/src/components/config/PenaltyConfigEditor.vue +++ b/src/main/resources/frontend/src/components/config/PenaltyConfigEditor.vue @@ -14,6 +14,11 @@ const maxLateDays = ref(config.admin.penalty.maxLateDaysPenalized); const gitPenalty = ref(Math.round(config.admin.penalty.gitCommitPenalty * 100)); const linesChangedPerCommit = ref(config.admin.penalty.linesChangedPerCommit); const clockForgivenessMinutes = ref(config.admin.penalty.clockForgivenessMinutes); +const coveragePercent = ref(Math.round(config.admin.penalty.coveragePercent * 100)); +const extraCoveragePercent = ref( + Math.round(config.admin.penalty.extraCoveragePercent * 100), +); +const coverageType = ref<"LINE" | "BRANCH">(config.admin.penalty.coverageType); const valuesReady = () => { return ( @@ -23,7 +28,11 @@ const valuesReady = () => { latePenalty.value <= 100 && maxLateDays.value >= 0 && linesChangedPerCommit.value >= 0 && - clockForgivenessMinutes.value >= 0 + clockForgivenessMinutes.value >= 0 && + coveragePercent.value >= 0 && + coveragePercent.value <= 100 && + extraCoveragePercent.value >= 0 && + extraCoveragePercent.value <= 100 ); }; @@ -35,6 +44,9 @@ const submit = async () => { latePenalty.value / 100, linesChangedPerCommit.value, clockForgivenessMinutes.value, + coveragePercent.value / 100, + extraCoveragePercent.value / 100, + coverageType.value, ); closeEditor(); @@ -85,6 +97,26 @@ const submit = async () => {

minutes

+
+

Coverage Percent

+

+ The percentage of code coverage expected for unit tests to receive full credit. +

+

%

+
+
+

Extra Coverage Percent

+

+ The percentage of code coverage expected for unit tests to receive extra credit. +

+

%

+
+
+

Coverage Type

+

The type of coverage to be measured.

+

Line

+

Branch

+
diff --git a/src/main/resources/frontend/src/services/configService.ts b/src/main/resources/frontend/src/services/configService.ts index dddf181a9..53be99266 100644 --- a/src/main/resources/frontend/src/services/configService.ts +++ b/src/main/resources/frontend/src/services/configService.ts @@ -16,6 +16,9 @@ export const setPenalties = async ( perDayLatePenalty: number, linesChangedPerCommit: number, clockForgivenessMinutes: number, + coveragePercent: number, + extraCoveragePercent: number, + coverageType: "LINE" | "BRANCH", ) => { await doSetConfigItem("/api/admin/config/penalties", { maxLateDaysPenalized: maxLateDaysPenalized, @@ -23,6 +26,9 @@ export const setPenalties = async ( perDayLatePenalty: perDayLatePenalty, linesChangedPerCommit: linesChangedPerCommit, clockForgivenessMinutes: clockForgivenessMinutes, + coveragePercent: coveragePercent, + extraCoveragePercent: extraCoveragePercent, + coverageType: coverageType, }); }; diff --git a/src/main/resources/frontend/src/stores/config.ts b/src/main/resources/frontend/src/stores/config.ts index f5f840958..2941de642 100644 --- a/src/main/resources/frontend/src/stores/config.ts +++ b/src/main/resources/frontend/src/stores/config.ts @@ -39,6 +39,9 @@ export type PrivateConfig = { maxLateDaysPenalized: number; linesChangedPerCommit: number; clockForgivenessMinutes: number; + coveragePercent: number; + extraCoveragePercent: number; + coverageType: "LINE" | "BRANCH"; }; courseNumber: number; @@ -79,6 +82,9 @@ export const useConfigStore = defineStore("config", () => { maxLateDaysPenalized: -1, linesChangedPerCommit: -1, clockForgivenessMinutes: -1, + coveragePercent: -1, + extraCoveragePercent: -1, + coverageType: "LINE", }, courseNumber: -1, assignments: [], diff --git a/src/main/resources/frontend/src/views/AdminView/ConfigView.vue b/src/main/resources/frontend/src/views/AdminView/ConfigView.vue index 99ec2d926..bd1bbcad4 100644 --- a/src/main/resources/frontend/src/views/AdminView/ConfigView.vue +++ b/src/main/resources/frontend/src/views/AdminView/ConfigView.vue @@ -160,6 +160,18 @@ onMounted(async () => { Clock Forgiveness: {{ config.admin.penalty.clockForgivenessMinutes }} minutes

+

+ Coverage Percent: {{ Math.round(config.admin.penalty.coveragePercent * 100) }}% +

+

+ Extra Coverage Percent: {{ Math.round(config.admin.penalty.extraCoveragePercent * 100) }}% +

+

+ Coverage Type: {{ config.admin.penalty.coverageType.toLocaleLowerCase() }} +

diff --git a/src/main/resources/frontend/src/views/StudentView/RubricItemResultsView.vue b/src/main/resources/frontend/src/views/StudentView/RubricItemResultsView.vue index 99bcdc2d6..f662edc79 100644 --- a/src/main/resources/frontend/src/views/StudentView/RubricItemResultsView.vue +++ b/src/main/resources/frontend/src/views/StudentView/RubricItemResultsView.vue @@ -31,7 +31,6 @@ const areErrorDetailsOpen = ref(false); "LINE"; case STUDENT_SUBMISSIONS_ENABLED -> random.nextBoolean(); case GRADER_SHUTDOWN_DATE, HOLIDAY_LIST, BANNER_EXPIRATION-> Instant.now(); };