diff --git a/features/find-unrecorded-submission-grades.md b/features/find-unrecorded-submission-grades.md new file mode 100644 index 000000000..b38ae65cc --- /dev/null +++ b/features/find-unrecorded-submission-grades.md @@ -0,0 +1,264 @@ +## Feature Description + +This feature provides a way to identify submission grades that have not been recorded in the system. It helps +instructors ensure that all student submissions are accounted for and graded appropriately. + +This is a new feature of the FindUngradedSubmissions tool, which scans through student submissions and compares them +against recorded grades to identify any discrepancies. + +This will feature will require a new command line parameter to the main() method of FindUngradedSubmissions. The first +parameter will now be the name of a gradebook XML file that contains the recorded grades. Subsequent parameters will +continue to be the names of one or more submission files/directories to scan for untested, ungraded, and now unrecorded +grades. + +Here's the new command line usage: + +``` +$ java -jar target/grader-1.5.2-SNAPSHOT.jar findUngradedSubmissions +Usage: java FindUngradedSubmissions -includeReason gradeBookXmlFile submissionZipOrDirectory+ +``` + +The FindUngradedSubmissions class will now output the names of the .out files whose grades do not match what has been +recorded in the student's gradebook XML file. + +``` +0 submissions need to be tested: +0 submissions need to be graded: +3 submissions have unrecorded grades: + ./fred.out + ./jane.out + ./alex.out +``` + +## Implementation Ideas + +The `SubmissionAnalysis` record will need a new gradeNeedsToBeRecorded() property. + +The gradebook XML file should be parsed into a GradeBook object. Information about student grades can be obtained from +the GradeBook. + +The name of the testOutput file begins with the student's login ID, which can be used to look up the recorded grade in +the GradeBook XML file. For instance, the student id of `fred` corresponds to the `fred.out` test output file. + +In order to determine if a submission's grade is unrecorded, we'll need to know which project assigment is associated +with the testOutput (`.out`) file. The project can be determined from a line that looks like this in the `.out` file: + +``` + The Joy of Coding Project 1: edu.pdx.cs410J.studentId.Project1 +``` + +In this example, the project assignment is "Project1". This is the name of the assignment in the GradeBook XML file. + +The grade should be considered unrecorded if the recorded grade is missing or different from the grade determined in the +GradeBook XML file. + +## Test Cases + +A submission is considered graded if there is a line like "4.6 out of 5.0" in the testOutput `.out` file, even if +that grade is not recorded in the GradeBook XML file. In this case, the submission should be considered unrecorded. + +A submission with a line like " out of 5.0" on line 7 is not considered to be graded. + +If a line like " out of 5.0" appears on a line after line 7, the submission should be considered graded. We can assume +that there is a note from the grade in the testOutput .out file that instructs the student to fix a fundamental flaw and +resubmit. Since there is no grade, the submission does not need to be recorded in the gradebook, and it should not be +considered unrecorded. + +If a submission hasn't been graded yet, it should not be considered unrecorded. + +If the submission has been graded (that is, a grade appears in the testOutput `.out` file) and there is no grade for the +assignment in the GradeBook, the submission should be considered unrecorded. + +If the submission has been graded and the grade in the GradeBook is different from the grade in the testOutput `.out`, +the submission should be considered unrecorded. + +If the submission has been graded and the grade in the GradeBook matches the grade in the testOutput `.out`, the +submission should not be considered unrecorded. + +If the submission has not been graded, it should not be considered unrecorded regardless of what is in the GradeBook. + +There should be an end-to-end integration test for that includes parsing a gradebook XML file and multiple submission +files (.out) that verify that some .out files have grades that need to be recorded and other .out files don't. This +test should be implemented in a new integration test class called `FindUnrecordedSubmissionGradesIT` in the src/it/java +directory. It should use the invokeMain() from InvokeMainTestCase to run the FindUngradedSubmissions main() method with +the appropriate command line arguments and validate that the expected output is written to standard output. The files +that the test uses should be placed in the src/it/resources directory. Or they could be generated programmatically by +the test and placed in a temporary directory injected with JUnit 5's @TempDir annotation. + +## Implementation Steps + +### 1. Update the `SubmissionAnalysis` record + +Add a new boolean property `gradeNeedsToBeRecorded` to the `SubmissionAnalysis` record (around line 196). + +**Changes:** + +- Add `boolean gradeNeedsToBeRecorded` as the fifth parameter to the record +- Update all places that construct `SubmissionAnalysis` objects to pass `false` for this parameter initially + +### 2. Create a new interface `GradeBookProvider` + +Add a new interface in the `FindUngradedSubmissions` class to provide access to the GradeBook. + +**Interface signature:** + +```java +interface GradeBookProvider { + Optional getGradeBook(); +} +``` + +This allows for testability by mocking the gradebook access in tests. + +### 3. Add GradeBookProvider to the constructor + +Update the `FindUngradedSubmissions` constructor to accept an optional `GradeBookProvider` parameter. + +**Changes:** + +- Add `GradeBookProvider` field to the class (around line 24-27) +- Update the `@VisibleForTesting` constructor (around line 30) to accept a `GradeBookProvider` parameter +- Update the default constructor (around line 36) to pass `null` for the gradebook provider + +### 4. Extend `TestOutputDetails` record + +Add fields to capture the project assignment name and the grade from the test output file. + +**Changes:** + +- Add `String projectName` field to the `TestOutputDetails` record (around line 193) +- Add `Double grade` field to the `TestOutputDetails` record (around line 193) +- Update all places that construct `TestOutputDetails` objects + +### 5. Update `TestOutputDetailsProviderFromTestOutputFile` + +Modify the `TestOutputDetailsProviderFromTestOutputFile` class (around line 233) to extract: + +- The project name from lines matching the pattern: `The Joy of Coding Project \\d+: edu.pdx.cs.\\w+.\\w+.(\\w+)` +- The grade value (already has `parseGrade` method at line 271) + +**Changes to `TestOutputDetailsCreator` inner class:** + +- Add `String projectName` field +- Add `Double grade` field +- In the `accept(String line)` method, add logic to: + - Extract project name using a regex pattern + - Capture the grade value (already extracts it, just need to store it) +- Update `createTestOutputDetails()` to include the new fields + +### 6. Update the `analyzeSubmission` method + +Modify the `analyzeSubmission` method (around line 38) to determine if a grade needs to be recorded. + +**New logic after determining `needsToBeGraded`:** + +```java +boolean gradeNeedsToBeRecorded = false; +if(!needsToBeGraded &&gradeBookProvider !=null){ +gradeNeedsToBeRecorded = + +checkIfGradeNeedsRecording(submission, testOutput); +} +``` + +### 7. Create a new method `checkIfGradeNeedsRecording` + +Add a new private method to check if a grade needs to be recorded. + +**Method signature:** + +```java +private boolean checkIfGradeNeedsRecording(SubmissionDetails submission, TestOutputDetails testOutput) +``` + +**Logic:** + +- Return false if testOutput doesn't have a project name or grade +- Get the GradeBook from the provider (return false if not available) +- Get the student from the gradebook using `submission.studentId()` +- If student not found, return true (unrecorded) +- Get the grade for the assignment using `student.getGrade(testOutput.projectName())` +- If grade is null, return true (unrecorded) +- If grade.getScore() != testOutput.grade(), return true (unrecorded) +- Otherwise return false + +### 8. Update the `main` method + +Modify the `main` method to: + +- Parse the new command-line parameter for the gradebook XML file +- Create a `GradeBookProvider` implementation that loads the gradebook +- Pass the provider to the `FindUngradedSubmissions` constructor + +**Changes:** + +- Update usage message (around line 89) to show the new syntax +- Parse the first non-option argument as the gradebook XML file path +- Remaining arguments are submission files/directories +- Create a `GradeBookProviderFromXmlFile` implementation +- Track `gradeNeedsToBeRecorded` submissions in the analysis loop (around line 111) +- Add a third call to `printOutAnalyses` for unrecorded grades + +### 9. Create `GradeBookProviderFromXmlFile` implementation + +Add a new static inner class that implements `GradeBookProvider`. + +**Implementation:** + +```java +private static class GradeBookProviderFromXmlFile implements GradeBookProvider { + private final String xmlFilePath; + private GradeBook gradeBook; + + GradeBookProviderFromXmlFile(String xmlFilePath) { + this.xmlFilePath = xmlFilePath; + } + + @Override + public Optional getGradeBook() { + if (gradeBook == null && xmlFilePath != null) { + try { + XmlGradeBookParser parser = new XmlGradeBookParser(xmlFilePath); + gradeBook = parser.parse(); + } catch (Exception e) { + System.err.println("Error loading gradebook: " + e.getMessage()); + return Optional.empty(); + } + } + return Optional.ofNullable(gradeBook); + } +} +``` + +### 10. Update test file imports and add new tests + +In `FindUngradedSubmissionsTest.java`, add test cases for the new functionality: + +**Test cases to add:** + +- `submissionWithUngradedTestOutputDoesNotNeedToBeRecorded()` - verify that if submission isn't graded, it's not + considered unrecorded +- `submissionWithGradeNotInGradeBookNeedsToBeRecorded()` - verify missing grade in gradebook is detected +- `submissionWithDifferentGradeInGradeBookNeedsToBeRecorded()` - verify mismatched grades are detected +- `submissionWithMatchingGradeInGradeBookDoesNotNeedToBeRecorded()` - verify matching grades are not flagged +- `parseProjectNameFromTestOutputLine()` - verify project name extraction works +- `testOutputDetailsIncludesProjectNameAndGrade()` - verify TestOutputDetails captures all needed info + +### 11. Integration Testing + +Create or update integration tests to verify: + +- Command-line parsing with the new gradebook parameter +- End-to-end flow with actual XML files +- Output formatting shows the three categories correctly + +### Summary of Key Classes to Modify + +1. `FindUngradedSubmissions.java` - main implementation +2. `FindUngradedSubmissionsTest.java` - unit tests +3. New imports needed: + - `edu.pdx.cs.joy.grader.gradebook.GradeBook` + - `edu.pdx.cs.joy.grader.gradebook.Grade` + - `edu.pdx.cs.joy.grader.gradebook.Student` + - `edu.pdx.cs.joy.grader.gradebook.XmlGradeBookParser` + - `java.util.Optional` diff --git a/grader/src/it/java/edu/pdx/cs/joy/grader/FindUngradedSubmissionGradesIT.java b/grader/src/it/java/edu/pdx/cs/joy/grader/FindUngradedSubmissionGradesIT.java new file mode 100644 index 000000000..6abb1c638 --- /dev/null +++ b/grader/src/it/java/edu/pdx/cs/joy/grader/FindUngradedSubmissionGradesIT.java @@ -0,0 +1,551 @@ +package edu.pdx.cs.joy.grader; + +import edu.pdx.cs.joy.grader.gradebook.Assignment; +import edu.pdx.cs.joy.grader.gradebook.Grade; +import edu.pdx.cs.joy.grader.gradebook.GradeBook; +import edu.pdx.cs.joy.grader.gradebook.Student; +import edu.pdx.cs.joy.grader.gradebook.XmlDumper; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +/** + * Integration test that validates the end-to-end functionality of finding unrecorded submission grades. + */ +public class FindUngradedSubmissionGradesIT { + + @Test + void findUnrecordedSubmissionGrades(@TempDir Path tempDir) throws IOException { + // Create a gradebook with assignments and students + Path gradebookFile = createGradebook(tempDir); + + // Create test output files: + // - fred.out: Grade 12.5 for Project1, matches gradebook (should NOT be flagged) + // - jane.out: Grade 15.0 for Project2, different from gradebook 10.0 (SHOULD be flagged) + // - alex.out: Grade 18.0 for Project3, not in gradebook (SHOULD be flagged) + // - bob.out: No grade yet, just test output (should NOT be flagged as unrecorded) + createTestOutputFile(tempDir, "fred", "Project1", 12.5, true); + createTestOutputFile(tempDir, "jane", "Project2", 15.0, true); + createTestOutputFile(tempDir, "alex", "Project3", 18.0, true); + createTestOutputFile(tempDir, "bob", "Project4", null, false); // Not graded yet + + // Create corresponding submission zip files + createSubmissionZip(tempDir, "fred", "Project1"); + createSubmissionZip(tempDir, "jane", "Project2"); + createSubmissionZip(tempDir, "alex", "Project3"); + createSubmissionZip(tempDir, "bob", "Project4"); + + // Invoke FindUngradedSubmissions main method + String[] args = { + gradebookFile.toString(), + tempDir.toString() + }; + + MainMethodResult result = invokeMain(FindUngradedSubmissions.class, args); + + String output = result.getTextWrittenToStandardOut(); + String errorOutput = result.getTextWrittenToStandardError(); + + // If there's an error output, it means something went wrong + if (!errorOutput.isEmpty()) { + System.err.println("Error output from main:"); + System.err.println(errorOutput); + } + + if (output.isEmpty()) { + System.err.println("No output was captured - this suggests System.exit() was called"); + } + + // Debug: print the actual output for analysis + System.out.println("=== ACTUAL OUTPUT ==="); + System.out.println(output); + System.out.println("=== END OUTPUT ==="); + + // Verify no errors + assertThat("Error output should not contain 'Error': " + errorOutput, + errorOutput, not(containsString("Error"))); + + // Verify output shows correct counts + assertThat(output, containsString("0 submissions need to be tested")); + assertThat(output, containsString("1 submission needs to be graded")); // bob + assertThat(output, containsString("2 submissions need to be recorded")); // jane and alex + + // Verify correct files are listed as needing recording + assertThat(output, containsString("jane.out")); + assertThat(output, containsString("alex.out")); + + // Verify fred.out is NOT listed (grade matches) + assertThat(output, not(containsString("fred.out"))); + } + + @Test + void invalidGradebookFileProducesError(@TempDir Path tempDir) throws IOException { + // Create an invalid XML file (not a valid gradebook) + Path invalidGradebook = tempDir.resolve("invalid.xml"); + Files.writeString(invalidGradebook, ""); + + // Create a submission file + createSubmissionZip(tempDir, "student", "Project1"); + + // Invoke FindUngradedSubmissions main method with invalid gradebook + String[] args = { + invalidGradebook.toString(), + tempDir.toString() + }; + + MainMethodResult result = invokeMain(FindUngradedSubmissions.class, args); + + String errorOutput = result.getTextWrittenToStandardError(); + + // Verify error message is produced + assertThat(errorOutput, containsString("Error: The first argument must be a valid gradebook XML file")); + assertThat(errorOutput, containsString("Cannot parse gradebook from:")); + assertThat(errorOutput, containsString("invalid.xml")); + } + + @Test + void nonExistentGradebookFileProducesError(@TempDir Path tempDir) throws IOException { + // Create a submission file + createSubmissionZip(tempDir, "student", "Project1"); + + // Use a non-existent gradebook file + String nonExistentGradebook = tempDir.resolve("nonexistent.xml").toString(); + + String[] args = { + nonExistentGradebook, + tempDir.toString() + }; + + MainMethodResult result = invokeMain(FindUngradedSubmissions.class, args); + + String errorOutput = result.getTextWrittenToStandardError(); + + // Verify error message is produced + assertThat(errorOutput, containsString("Error: The first argument must be a valid gradebook XML file")); + assertThat(errorOutput, containsString("Cannot parse gradebook from:")); + assertThat(errorOutput, containsString("nonexistent.xml")); + } + + @Test + void directoryAsGradebookProducesError(@TempDir Path tempDir) throws IOException { + // Create a submission file + createSubmissionZip(tempDir, "student", "Project1"); + + // Try to use the directory itself as the gradebook + String[] args = { + tempDir.toString(), + tempDir.toString() + }; + + MainMethodResult result = invokeMain(FindUngradedSubmissions.class, args); + + String errorOutput = result.getTextWrittenToStandardError(); + + // Verify error message is produced + assertThat(errorOutput, containsString("Error: The first argument must be a valid gradebook XML file")); + assertThat(errorOutput, containsString("Cannot parse gradebook from:")); + } + + @Test + void ungradedSubmissionIsNotListedAsNeedingRecording(@TempDir Path tempDir) throws IOException { + // Create a gradebook with one student who has a grade recorded + GradeBook book = new GradeBook("Test Course"); + + Assignment project1 = new Assignment("Project1", 6.0); + project1.setType(Assignment.AssignmentType.PROJECT); + project1.setDescription("First Project"); + project1.setDueDate(LocalDateTime.of(2025, 1, 15, 17, 30)); + book.addAssignment(project1); + + // Add student with a grade already recorded in gradebook + Student charlie = new Student("charlie"); + charlie.setFirstName("Charlie"); + charlie.setLastName("Brown"); + charlie.setEmail("charlie@test.edu"); + charlie.setEnrolledSection(Student.Section.UNDERGRADUATE); + charlie.setGrade("Project1", new Grade("Project1", 5.0)); + book.addStudent(charlie); + + // Write gradebook + Path gradebookFile = tempDir.resolve("gradebook.xml"); + XmlDumper dumper = new XmlDumper(gradebookFile.toFile()); + dumper.dump(book); + + // Create test output file with NO GRADE (ungraded submission) + // The line " out of 6.0" indicates no grade has been assigned yet + Path testOutputFile = tempDir.resolve("charlie.out"); + String content = """ + The Joy of Coding Project 1: edu.pdx.cs410J.charlie.Project1 + Submitted by charlie + Submitted on Wed Jan 15 10:30:00 AM PST 2025 + Graded on Wed Jan 15 11:00:00 AM PST 2025 + + out of 6.0 + + Test results follow... + """; + Files.writeString(testOutputFile, content); + + // Create submission zip file + createSubmissionZip(tempDir, "charlie", "Project1"); + + // Invoke FindUngradedSubmissions + String[] args = { + gradebookFile.toString(), + tempDir.toString() + }; + + MainMethodResult result = invokeMain(FindUngradedSubmissions.class, args); + + String output = result.getTextWrittenToStandardOut(); + String errorOutput = result.getTextWrittenToStandardError(); + + // Verify no errors + assertThat("Error output should be empty: " + errorOutput, + errorOutput, not(containsString("Error"))); + + // The submission needs to be graded (no grade in test output) + assertThat(output, containsString("1 submission needs to be graded")); + assertThat(output, containsString("charlie.out")); + + // The submission should NOT be listed as needing recording + // because it hasn't been graded yet + assertThat(output, containsString("0 submissions need to be recorded")); + + // Verify charlie.out only appears once (in "needs to be graded" section) + int occurrences = output.split("charlie\\.out", -1).length - 1; + assertThat("charlie.out should appear exactly once", occurrences, equalTo(1)); + } + + @Test + void multipleUngradedSubmissionsAreNotListedAsNeedingRecording(@TempDir Path tempDir) throws IOException { + // This test reproduces the real-world bug where: + // - student2.out, student3.out, student4.out all have NO grade (line " out of 6.0" AFTER line 7) + // - These .out files have notes from the grader (e.g., "fix and resubmit") + // - student1.out has a grade 5.0 that matches the gradebook + // Expected: The 3 submissions with notes but no grade should NOT appear in "need to be recorded" + // They are considered "graded" (because of notes) but have no actual grade (NaN) + // Actual bug: All 4 appear in "need to be recorded" because NaN != any grade value + + GradeBook book = new GradeBook("Test Course"); + + Assignment project2 = new Assignment("Project2", 6.0); + project2.setType(Assignment.AssignmentType.PROJECT); + project2.setDescription("Text File Project"); + project2.setDueDate(LocalDateTime.of(2026, 1, 26, 17, 30)); + book.addAssignment(project2); + + // Add students - student1 has matching grade, others have no grade in gradebook + Student student1 = new Student("student1"); + student1.setFirstName("Student"); + student1.setLastName("One"); + student1.setEmail("student1@test.edu"); + student1.setEnrolledSection(Student.Section.UNDERGRADUATE); + student1.setGrade("Project2", new Grade("Project2", 5.0)); + book.addStudent(student1); + + Student student2 = new Student("student2"); + student2.setFirstName("Student"); + student2.setLastName("Two"); + student2.setEmail("student2@test.edu"); + student2.setEnrolledSection(Student.Section.UNDERGRADUATE); + book.addStudent(student2); + + Student student3 = new Student("student3"); + student3.setFirstName("Student"); + student3.setLastName("Three"); + student3.setEmail("student3@test.edu"); + student3.setEnrolledSection(Student.Section.UNDERGRADUATE); + book.addStudent(student3); + + Student student4 = new Student("student4"); + student4.setFirstName("Student"); + student4.setLastName("Four"); + student4.setEmail("student4@test.edu"); + student4.setEnrolledSection(Student.Section.UNDERGRADUATE); + book.addStudent(student4); + + // Write gradebook + Path gradebookFile = tempDir.resolve("gradebook.xml"); + XmlDumper dumper = new XmlDumper(gradebookFile.toFile()); + dumper.dump(book); + + // Create test output for student1 WITH a grade (5.0) that matches gradebook + createTestOutputWithGrade(tempDir, "student1", "Project2", 5.0); + + // Create test outputs for the others with NO grades + createTestOutputWithNoGrade(tempDir, "student2", "Project2"); + createTestOutputWithNoGrade(tempDir, "student3", "Project2"); + createTestOutputWithNoGrade(tempDir, "student4", "Project2"); + + // Create submission zips + createSubmissionZip(tempDir, "student1", "Project2"); + createSubmissionZip(tempDir, "student2", "Project2"); + createSubmissionZip(tempDir, "student3", "Project2"); + createSubmissionZip(tempDir, "student4", "Project2"); + + // Invoke FindUngradedSubmissions + String[] args = { + gradebookFile.toString(), + tempDir.toString() + }; + + MainMethodResult result = invokeMain(FindUngradedSubmissions.class, args); + + String output = result.getTextWrittenToStandardOut(); + String errorOutput = result.getTextWrittenToStandardError(); + + System.out.println("=== BUG REPRODUCTION OUTPUT ==="); + System.out.println(output); + System.out.println("=== END OUTPUT ==="); + + // Verify no errors + assertThat("Error output should be empty: " + errorOutput, + errorOutput, not(containsString("Error"))); + + // The 3 submissions with notes are considered "graded" (hasGrade=true because lineCount > 7) + // So they should NOT appear in "needs to be graded" + assertThat(output, containsString("0 submissions need to be graded")); + + // NO submissions should be listed as needing recording + // (student1 has matching grade, others have notes but no actual grade which shouldn't be recorded) + assertThat(output, containsString("0 submissions need to be recorded")); + + // NONE of the .out files should appear in the output + assertThat(output, not(containsString("student1.out"))); + assertThat(output, not(containsString("student2.out"))); + assertThat(output, not(containsString("student3.out"))); + assertThat(output, not(containsString("student4.out"))); + } + + private void createTestOutputWithGrade(Path tempDir, String studentId, String projectName, double grade) throws IOException { + Path testOutputFile = tempDir.resolve(studentId + ".out"); + + LocalDateTime submissionTime = LocalDateTime.of(2026, 1, 20, 10, 30); + LocalDateTime gradedTime = LocalDateTime.of(2026, 1, 20, 11, 0); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("E MMM d hh:mm:ss a 'PST' yyyy"); + String submissionTimeStr = submissionTime.format(formatter); + String gradedTimeStr = gradedTime.format(formatter); + + String content = String.format(""" + The Joy of Coding Project 2: edu.pdx.cs410J.%s.%s + Submitted by %s + Submitted on %s + Graded on %s + + %.1f out of 6.0 + + Test results follow... + """, studentId, projectName, studentId, submissionTimeStr, gradedTimeStr, grade); + + Files.writeString(testOutputFile, content); + } + + private void createTestOutputWithNoGrade(Path tempDir, String studentId, String projectName) throws IOException { + Path testOutputFile = tempDir.resolve(studentId + ".out"); + + LocalDateTime submissionTime = LocalDateTime.of(2026, 1, 20, 10, 30); + LocalDateTime gradedTime = LocalDateTime.of(2026, 1, 20, 11, 0); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("E MMM d hh:mm:ss a 'PST' yyyy"); + String submissionTimeStr = submissionTime.format(formatter); + String gradedTimeStr = gradedTime.format(formatter); + + // This creates a test output with " out of 6.0" AFTER line 7 (line 9) + // The grader has left a note instructing the student to fix and resubmit + // According to spec: this should be considered graded (hasGrade=true) + // But should NOT be considered needing recording (because there's no actual grade) + String content = String.format(" The Joy of Coding Project 2: edu.pdx.cs410J.%s.%s\n" + + " Submitted by %s\n" + + " Submitted on %s\n" + + " Graded on %s\n" + + "\n" + + "NOTE TO STUDENT: Your submission has a fundamental flaw.\n" + + "Please fix the following issue and resubmit:\n" + + "- Your code does not compile\n" + + "\n" + + " out of 6.0\n" + + "\n" + + "Test results follow...\n", studentId, projectName, studentId, submissionTimeStr, gradedTimeStr); + + Files.writeString(testOutputFile, content); + } + + private Path createGradebook(Path tempDir) throws IOException { + GradeBook book = new GradeBook("Test Course"); + + // Add assignments + Assignment project1 = new Assignment("Project1", 15.0); + project1.setType(Assignment.AssignmentType.PROJECT); + project1.setDescription("First Project"); + project1.setDueDate(LocalDateTime.of(2025, 1, 15, 17, 30)); + book.addAssignment(project1); + + Assignment project2 = new Assignment("Project2", 20.0); + project2.setType(Assignment.AssignmentType.PROJECT); + project2.setDescription("Second Project"); + project2.setDueDate(LocalDateTime.of(2025, 1, 22, 17, 30)); + book.addAssignment(project2); + + Assignment project3 = new Assignment("Project3", 20.0); + project3.setType(Assignment.AssignmentType.PROJECT); + project3.setDescription("Third Project"); + project3.setDueDate(LocalDateTime.of(2025, 1, 29, 17, 30)); + book.addAssignment(project3); + + Assignment project4 = new Assignment("Project4", 20.0); + project4.setType(Assignment.AssignmentType.PROJECT); + project4.setDescription("Fourth Project"); + project4.setDueDate(LocalDateTime.of(2025, 2, 5, 17, 30)); + book.addAssignment(project4); + + // Add students with grades + addStudent(book, "fred", "Fred", "Flintstone", 12.5, "Project1"); + addStudent(book, "jane", "Jane", "Jetson", 10.0, "Project2"); + addStudent(book, "alex", "Alex", "Anderson", null, null); + addStudent(book, "bob", "Bob", "Builder", null, null); + + // Write gradebook and student files using XmlDumper + Path gradebookFile = tempDir.resolve("gradebook.xml"); + try { + XmlDumper dumper = new XmlDumper(gradebookFile.toFile()); + dumper.dump(book); + } catch (Exception e) { + throw new IOException("Error writing gradebook XML", e); + } + + return gradebookFile; + } + + private void addStudent(GradeBook book, String id, String firstName, String lastName, Double gradeScore, String assignmentName) { + Student student = new Student(id); + student.setFirstName(firstName); + student.setLastName(lastName); + student.setEmail(id + "@test.edu"); + student.setEnrolledSection(Student.Section.UNDERGRADUATE); + + if (gradeScore != null && assignmentName != null) { + Grade grade = new Grade(assignmentName, gradeScore); + student.setGrade(assignmentName, grade); + } + + book.addStudent(student); + } + + private void createTestOutputFile(Path tempDir, String studentId, String projectName, Double grade, boolean hasGrade) throws IOException { + Path testOutputFile = tempDir.resolve(studentId + ".out"); + + LocalDateTime submissionTime = LocalDateTime.of(2025, 1, 15, 10, 30); + LocalDateTime gradedTime = LocalDateTime.of(2025, 1, 15, 11, 0); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("E MMM d hh:mm:ss a 'PST' yyyy"); + String submissionTimeStr = submissionTime.format(formatter); + String gradedTimeStr = gradedTime.format(formatter); + + String gradeSection; + if (hasGrade && grade != null) { + gradeSection = String.format("%.1f out of 15.0", grade); + } else { + gradeSection = " out of 15.0"; + } + + String content = String.format(""" + The Joy of Coding Project 1: edu.pdx.cs410J.%s.%s + Submitted by %s + Submitted on %s + Graded on %s + + %s + + Test results follow... + """, studentId, projectName, studentId, submissionTimeStr, gradedTimeStr, gradeSection); + + Files.writeString(testOutputFile, content); + } + + private void createSubmissionZip(Path tempDir, String studentId, String projectName) throws IOException { + // Create a simple zip file with a manifest + Path zipFile = tempDir.resolve(studentId + "-" + projectName + ".zip"); + + LocalDateTime submissionTime = LocalDateTime.of(2025, 1, 15, 10, 30); + + // Use the correct manifest attribute names that ProjectSubmissionsProcessor expects + String manifestContent = String.format(""" + Manifest-Version: 1.0 + Submitter-User-Id: %s + Submission-Time: %s + Project-Name: %s + + """, studentId, submissionTime.format(java.time.format.DateTimeFormatter.ISO_DATE_TIME), projectName); + + // For simplicity, we'll create a minimal zip file structure + // In a real scenario, this would be more complex + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(baos)) { + java.util.zip.ZipEntry manifestEntry = new java.util.zip.ZipEntry("META-INF/MANIFEST.MF"); + zos.putNextEntry(manifestEntry); + zos.write(manifestContent.getBytes()); + zos.closeEntry(); + } + + Files.write(zipFile, baos.toByteArray()); + } + + /** + * Helper class to capture the result of invoking a main method. + */ + private static class MainMethodResult { + private final String out; + private final String err; + + public MainMethodResult(String out, String err) { + this.out = out; + this.err = err; + } + + public String getTextWrittenToStandardOut() { + return out; + } + + public String getTextWrittenToStandardError() { + return err; + } + } + + /** + * Invokes the main method of the specified class with the given arguments. + */ + private MainMethodResult invokeMain(Class mainClass, String... args) { + java.io.ByteArrayOutputStream outStream = new java.io.ByteArrayOutputStream(); + java.io.ByteArrayOutputStream errStream = new java.io.ByteArrayOutputStream(); + + java.io.PrintStream originalOut = System.out; + java.io.PrintStream originalErr = System.err; + + try { + System.setOut(new java.io.PrintStream(outStream)); + System.setErr(new java.io.PrintStream(errStream)); + + java.lang.reflect.Method mainMethod = mainClass.getMethod("main", String[].class); + mainMethod.invoke(null, (Object) args); + + } catch (Exception e) { + e.printStackTrace(System.err); + } finally { + System.setOut(originalOut); + System.setErr(originalErr); + } + + return new MainMethodResult(outStream.toString(), errStream.toString()); + } +} diff --git a/grader/src/main/java/edu/pdx/cs/joy/grader/FindUngradedSubmissions.java b/grader/src/main/java/edu/pdx/cs/joy/grader/FindUngradedSubmissions.java index 9c4648a8f..30a9e08ec 100644 --- a/grader/src/main/java/edu/pdx/cs/joy/grader/FindUngradedSubmissions.java +++ b/grader/src/main/java/edu/pdx/cs/joy/grader/FindUngradedSubmissions.java @@ -1,6 +1,10 @@ package edu.pdx.cs.joy.grader; import com.google.common.annotations.VisibleForTesting; +import edu.pdx.cs.joy.grader.gradebook.Grade; +import edu.pdx.cs.joy.grader.gradebook.GradeBook; +import edu.pdx.cs.joy.grader.gradebook.Student; +import edu.pdx.cs.joy.grader.gradebook.XmlGradeBookParser; import java.io.IOException; import java.io.InputStream; @@ -8,32 +12,39 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDateTime; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.List; -import java.util.function.Consumer; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; import java.util.jar.Attributes; import java.util.jar.Manifest; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Stream; +import static edu.pdx.cs.joy.grader.TestedProjectSubmissionOutputParser.*; + public class FindUngradedSubmissions { private final SubmissionDetailsProvider submissionDetailsProvider; private final TestOutputPathProvider testOutputProvider; private final TestOutputDetailsProvider testOutputDetailsProvider; + private final GradeBookProvider gradeBookProvider; @VisibleForTesting - FindUngradedSubmissions(SubmissionDetailsProvider submissionDetailsProvider, TestOutputPathProvider testOutputProvider, TestOutputDetailsProvider testOutputDetailsProvider) { + FindUngradedSubmissions(SubmissionDetailsProvider submissionDetailsProvider, TestOutputPathProvider testOutputProvider, TestOutputDetailsProvider testOutputDetailsProvider, GradeBookProvider gradeBookProvider) { + Objects.requireNonNull(gradeBookProvider, "GradeBook provider cannot be null"); this.submissionDetailsProvider = submissionDetailsProvider; this.testOutputProvider = testOutputProvider; this.testOutputDetailsProvider = testOutputDetailsProvider; + this.gradeBookProvider = gradeBookProvider; } - public FindUngradedSubmissions() { - this(new SubmissionDetailsProviderFromZipFile(), new TestOutputProviderInParentDirectory(), new TestOutputDetailsProviderFromTestOutputFile()); + public FindUngradedSubmissions(String gradeBookXmlFile) { + this(new SubmissionDetailsProviderFromZipFile(), + new TestOutputProviderInParentDirectory(), + new TestOutputDetailsProviderFromTestOutputFile(), + new GradeBookProviderFromXmlFile(gradeBookXmlFile)); } @VisibleForTesting @@ -43,34 +54,70 @@ SubmissionAnalysis analyzeSubmission(Path submissionPath) { Path testOutputPath = this.testOutputProvider.getTestOutput(submissionDirectory, submission.studentId()); boolean needsToBeTested; boolean needsToBeGraded; + boolean needsToBeRecorded; String reason; if (!Files.exists(testOutputPath)) { needsToBeTested = true; needsToBeGraded = true; + needsToBeRecorded = false; reason = "Test output file does not exist: " + testOutputPath; } else { - TestOutputDetails testOutput = this.testOutputDetailsProvider.getTestOutputDetails(testOutputPath); if (submittedAfterTesting(submission, testOutput)) { needsToBeTested = true; needsToBeGraded = true; + needsToBeRecorded = false; reason = "Submission on " + submission.submissionTime() + " is after testing on " + testOutput.testedSubmissionTime(); } else if (!testOutput.hasGrade()) { needsToBeTested = false; - needsToBeGraded = true; + needsToBeGraded = !testOutput.hasBeenReviewed(); + needsToBeRecorded = false; reason = "Test output file does not have a grade: " + testOutputPath; } else { needsToBeTested = false; needsToBeGraded = false; + needsToBeRecorded = doesCareNeedToBeRecorded(submission, testOutput); reason = "Test output file was graded after submission: " + testOutputPath; } } - return new SubmissionAnalysis(submissionPath, needsToBeTested, needsToBeGraded, reason); + return new SubmissionAnalysis(submissionPath, needsToBeTested, needsToBeGraded, reason, testOutputPath, needsToBeRecorded); + } + + private boolean doesCareNeedToBeRecorded(SubmissionDetails submission, TestOutputDetails testOutput) { + // If test output doesn't have project name or grade, can't check + if (testOutput.projectName() == null || testOutput.grade() == null) { + return false; + } + + // Get the gradebook + java.util.Optional gradeBookOpt = gradeBookProvider.getGradeBook(); + if (gradeBookOpt.isEmpty()) { + return false; + } + + GradeBook gradeBook = gradeBookOpt.get(); + + // Get the student from the gradebook + Optional studentOpt = gradeBook.getStudent(submission.studentId()); + if (studentOpt.isEmpty()) { + return true; // Student not in gradebook, grade needs to be recorded + } + + Student student = studentOpt.get(); + + // Get the grade for the assignment + Grade grade = student.getGrade(testOutput.projectName()); + if (grade == null) { + return true; // No grade recorded for this assignment + } + + // Compare grades - if different, needs to be recorded + return grade.getScore() != testOutput.grade(); } private static boolean submittedAfterTesting(SubmissionDetails submission, TestOutputDetails testOutput) { @@ -86,8 +133,8 @@ record SubmissionDetails(String studentId, LocalDateTime submissionTime) { public static void main(String[] args) { if (args.length == 0) { - System.err.println("Usage: java FindUngradedSubmissions -includeReason submissionZipOrDirectory+"); - System.exit(1); + System.err.println("Usage: java FindUngradedSubmissions -includeReason gradeBookXmlFile submissionZipOrDirectory+"); + return; } boolean includeReason = false; @@ -99,37 +146,63 @@ public static void main(String[] args) { } else if (arg.startsWith("-")) { System.err.println("Unknown option: " + arg); - System.exit(1); + return; } else { fileNames.add(arg); } } + // First non-option argument is the required gradebook XML file + if (fileNames.isEmpty()) { + System.err.println("Missing required gradebook XML file"); + System.err.println("Usage: java FindUngradedSubmissions -includeReason gradeBookXmlFile submissionZipOrDirectory+"); + return; + } + + String gradeBookXmlFile = fileNames.removeFirst(); + + // Validate that the gradebook XML file can be parsed + try { + XmlGradeBookParser parser = new XmlGradeBookParser(gradeBookXmlFile); + parser.parse(); // This will throw an exception if the file is invalid + } catch (Exception e) { + System.err.println("Error: The first argument must be a valid gradebook XML file"); + System.err.println("Cannot parse gradebook from: " + gradeBookXmlFile); + System.err.println("Error: " + e.getMessage()); + return; + } + Stream submissions = findSubmissionsIn(fileNames); - FindUngradedSubmissions finder = new FindUngradedSubmissions(); + FindUngradedSubmissions finder = new FindUngradedSubmissions(gradeBookXmlFile); Stream analyses = submissions.map(finder::analyzeSubmission); List needsToBeTested = new ArrayList<>(); List needsToBeGraded = new ArrayList<>(); + List gradeNeedsToBeRecorded = new ArrayList<>(); + analyses.forEach(analysis -> { if (analysis.needsToBeTested()) { needsToBeTested.add(analysis); } else if (analysis.needsToBeGraded()) { needsToBeGraded.add(analysis); + + } else if (analysis.gradeNeedsToBeRecorded()) { + gradeNeedsToBeRecorded.add(analysis); } }); - printOutAnalyses(needsToBeTested, "tested", includeReason); - printOutAnalyses(needsToBeGraded, "graded", includeReason); + printOutAnalyses(needsToBeTested, "tested", SubmissionAnalysis::submission, includeReason); + printOutAnalyses(needsToBeGraded, "graded", SubmissionAnalysis::testOutput, includeReason); + printOutAnalyses(gradeNeedsToBeRecorded, "recorded", SubmissionAnalysis::testOutput, includeReason); } - private static void printOutAnalyses(List analyses, String action, boolean includeReason) { + private static void printOutAnalyses(List analyses, String action, Function getPath, boolean includeReason) { int size = analyses.size(); String description = (size == 1 ? " submission needs" : " submissions need"); System.out.println(size + description + " to be " + action + ": "); analyses.forEach(analysis -> { - System.out.print(" " + analysis.submission); + System.out.print(" " + getPath.apply(analysis)); if (includeReason) { System.out.print(" " + analysis.reason); } @@ -178,12 +251,16 @@ interface TestOutputDetailsProvider { TestOutputDetails getTestOutputDetails(Path testOutput); } + interface GradeBookProvider { + java.util.Optional getGradeBook(); + } + @VisibleForTesting - record TestOutputDetails(LocalDateTime testedSubmissionTime, boolean hasGrade) { + record TestOutputDetails(Path testOutput, LocalDateTime testedSubmissionTime, boolean hasGrade, String projectName, Double grade, boolean hasBeenReviewed) { } @VisibleForTesting - record SubmissionAnalysis (Path submission, boolean needsToBeTested, boolean needsToBeGraded, String reason) { + record SubmissionAnalysis (Path submission, boolean needsToBeTested, boolean needsToBeGraded, String reason, Path testOutput, boolean gradeNeedsToBeRecorded) { } @@ -214,42 +291,32 @@ public Path getTestOutput(Path submissionDirectory, String studentId) { } } - @VisibleForTesting - static class TestOutputDetailsProviderFromTestOutputFile implements TestOutputDetailsProvider { - private static final Pattern SUBMISSION_TIME_PATTERN = Pattern.compile(".*Submitted on (.+)"); + private static class GradeBookProviderFromXmlFile implements GradeBookProvider { + private final String xmlFilePath; + private GradeBook gradeBook; - public static LocalDateTime parseSubmissionTime(String line) { - if (line.contains("Submitted on")) { - Matcher matcher = TestOutputDetailsProviderFromTestOutputFile.SUBMISSION_TIME_PATTERN.matcher(line); - if (matcher.matches()) { - String timeString = matcher.group(1).trim(); - return parseTime(timeString); - } else { - throw new IllegalArgumentException("Could not parse submission time from line: " + line); - } - } - - return null; + GradeBookProviderFromXmlFile(String xmlFilePath) { + this.xmlFilePath = xmlFilePath; } - private static LocalDateTime parseTime(String timeString) { - try { - ZonedDateTime zoned; + @Override + public java.util.Optional getGradeBook() { + if (gradeBook == null && xmlFilePath != null) { try { - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("E MMM d hh:mm:ss a z yyyy"); - zoned = ZonedDateTime.parse(timeString, formatter); - - } catch (DateTimeParseException ex) { - // Single-digit day format - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("E MMM d hh:mm:ss a z yyyy"); - zoned = ZonedDateTime.parse(timeString, formatter); + XmlGradeBookParser parser = new XmlGradeBookParser(xmlFilePath); + gradeBook = parser.parse(); + } catch (Exception e) { + String message = "Error loading gradebook from " + xmlFilePath + ": " + e.getMessage(); + System.err.println(message); + throw new RuntimeException(message, e); } - return zoned.toLocalDateTime(); - - } catch (DateTimeParseException ex) { - return LocalDateTime.parse(timeString); } + return java.util.Optional.ofNullable(gradeBook); } + } + + @VisibleForTesting + static class TestOutputDetailsProviderFromTestOutputFile implements TestOutputDetailsProvider { public static Double parseGrade(String line) { if (line.contains("out of")) { @@ -265,52 +332,31 @@ public static Double parseGrade(String line) { return null; } + private static final Pattern PROJECT_NAME_PATTERN = Pattern.compile(".*The Joy of Coding Project \\d+: edu\\.pdx\\.cs[\\w.]*\\.(\\w+)"); + + public static String parseProjectName(String line) { + if (line.contains("The Joy of Coding Project")) { + Matcher matcher = PROJECT_NAME_PATTERN.matcher(line); + if (matcher.matches()) { + return matcher.group(1); + } + } + return null; + } + @Override public TestOutputDetails getTestOutputDetails(Path testOutput) { try { - return parseTestOutputDetails(Files.lines(testOutput)); - } catch (IOException e) { - throw new RuntimeException(e); + return parseTestOutputDetails(testOutput, Files.lines(testOutput)); + } catch (IOException | TestedProjectSubmissionOutputParsingException e) { + throw new RuntimeException("While parsing " + testOutput, e); } } - static TestOutputDetails parseTestOutputDetails(Stream lines) { - TestOutputDetailsCreator creator = new TestOutputDetailsCreator(); - lines.forEach(creator); - return creator.createTestOutputDetails(); + static TestOutputDetails parseTestOutputDetails(Path testOutput, Stream lines) throws TestedProjectSubmissionOutputParsingException { + ProjectScore projectScore = parseTestedSubmissionOutput(lines); + return new TestOutputDetails(testOutput, projectScore.getSubmissionTime(), !Double.isNaN(projectScore.getScore()), projectScore.getProjectName(), projectScore.getScore(), projectScore.isReviewed()); } - private static class TestOutputDetailsCreator implements Consumer { - private LocalDateTime testedSubmissionTime; - private Boolean hasGrade; - private int lineCount; - - @Override - public void accept(String line) { - this.lineCount++; - - LocalDateTime submissionTime = parseSubmissionTime(line); - if (submissionTime != null) { - this.testedSubmissionTime = submissionTime; - } - - Double grade = parseGrade(line); - if (grade != null) { - boolean testOutputHasNotesForStudent = lineCount > 7; - this.hasGrade = testOutputHasNotesForStudent || !grade.isNaN(); - } - } - - public TestOutputDetails createTestOutputDetails() { - if (this.testedSubmissionTime == null) { - throw new IllegalStateException("Tested submission time was not set"); - - } else if( this.hasGrade == null) { - throw new IllegalStateException("Has grade was not set"); - } - - return new TestOutputDetails(this.testedSubmissionTime, hasGrade); - } - } } } diff --git a/grader/src/main/java/edu/pdx/cs/joy/grader/ProjectGradesImporter.java b/grader/src/main/java/edu/pdx/cs/joy/grader/ProjectGradesImporter.java index 993028d6c..d89789db7 100644 --- a/grader/src/main/java/edu/pdx/cs/joy/grader/ProjectGradesImporter.java +++ b/grader/src/main/java/edu/pdx/cs/joy/grader/ProjectGradesImporter.java @@ -1,6 +1,5 @@ package edu.pdx.cs.joy.grader; -import com.google.common.annotations.VisibleForTesting; import edu.pdx.cs.joy.ParserException; import edu.pdx.cs.joy.grader.gradebook.*; import org.slf4j.Logger; @@ -10,11 +9,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; public class ProjectGradesImporter { - static final Pattern scorePattern = Pattern.compile("(\\d+\\.?\\d*) out of (\\d+\\.?\\d*)", Pattern.CASE_INSENSITIVE); private final GradeBook gradeBook; private final Assignment assignment; @@ -26,27 +22,12 @@ public ProjectGradesImporter(GradeBook gradeBook, Assignment assignment, Logger this.logger = logger; } - public static ProjectScore getScoreFrom(Reader reader) throws ScoreNotFoundException { - BufferedReader br = new BufferedReader(reader); - Optional scoreLine = br.lines().filter(scorePattern.asPredicate()).findFirst(); - - if (scoreLine.isPresent()) { - Matcher matcher = scorePattern.matcher(scoreLine.get()); - if (matcher.find()) { - return new ProjectScore(matcher.group(1), matcher.group(2)); - - } else { - throw new IllegalStateException("Matcher didn't match \"" + scoreLine.get() + "\""); - } - - } else { - throw new ScoreNotFoundException(); - } - + public static TestedProjectSubmissionOutputParser.ProjectScore getScoreFrom(Reader reader) throws TestedProjectSubmissionOutputParser.TestedProjectSubmissionOutputParsingException, IOException { + return TestedProjectSubmissionOutputParser.parseTestedSubmissionOutput(reader); } - public void recordScoreFromProjectReport(String studentId, Reader report) throws ScoreNotFoundException { - ProjectScore score = getScoreFrom(report); + public void recordScoreFromProjectReport(String studentId, Reader report) throws TestedProjectSubmissionOutputParser.TestedProjectSubmissionOutputParsingException, IOException { + TestedProjectSubmissionOutputParser.ProjectScore score = getScoreFrom(report); if (score.getTotalPoints() != this.assignment.getPoints()) { String message = "Assignment " + this.assignment.getName() + " should be worth " + this.assignment.getPoints() + @@ -81,24 +62,6 @@ private void warn(String message) { logger.warn(message); } - static class ProjectScore { - private final double score; - private final double totalPoints; - - private ProjectScore(String score, String totalPoints) { - this.score = Double.parseDouble(score); - this.totalPoints = Double.parseDouble(totalPoints); - } - - public double getScore() { - return this.score; - } - - public double getTotalPoints() { - return this.totalPoints; - } - } - public static void main(String[] args) { String gradeBookFileName = null; String assignmentName = null; @@ -132,12 +95,13 @@ public static void main(String[] args) { try { importer.recordScoreFromProjectReport(studentId, new FileReader(projectFile)); - } catch (IllegalStateException ex) { - throw new IllegalStateException("While recording score from " + projectFileName, ex); - } catch (FileNotFoundException ex) { throw new IllegalStateException("Could not find file \"" + projectFile + "\""); - } catch (ScoreNotFoundException e) { + + } catch (IllegalStateException | IOException ex) { + throw new IllegalStateException("While recording score from " + projectFileName, ex); + + } catch (TestedProjectSubmissionOutputParser.TestedProjectSubmissionOutputParsingException e) { logger.warn("Could not find score in " + projectFileName); } } @@ -233,8 +197,4 @@ private static T usage(String message) { return null; } - @VisibleForTesting - static class ScoreNotFoundException extends Exception { - - } } diff --git a/grader/src/main/java/edu/pdx/cs/joy/grader/TestedProjectSubmissionOutputParser.java b/grader/src/main/java/edu/pdx/cs/joy/grader/TestedProjectSubmissionOutputParser.java new file mode 100644 index 000000000..d303dad7c --- /dev/null +++ b/grader/src/main/java/edu/pdx/cs/joy/grader/TestedProjectSubmissionOutputParser.java @@ -0,0 +1,166 @@ +package edu.pdx.cs.joy.grader; + +import com.google.common.annotations.VisibleForTesting; +import org.jspecify.annotations.NonNull; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.Reader; +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Iterator; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +public class TestedProjectSubmissionOutputParser { + static final Pattern scorePattern = Pattern.compile("(\\d+\\.?\\d*)? out of (\\d+\\.?\\d*)", Pattern.CASE_INSENSITIVE); + static final Pattern projectNamePattern = Pattern.compile(".*The Joy of Coding Project \\d+: edu\\.pdx\\.[\\w.]*\\.(Project\\d+)", Pattern.CASE_INSENSITIVE); + static final Pattern koansProjectNamePattern = Pattern.compile(".*The Joy of Coding Koans.*", Pattern.CASE_INSENSITIVE); + private static final Pattern submissionTimePattern = Pattern.compile(".*Submitted on (.+)", Pattern.CASE_INSENSITIVE); + + static @NonNull ProjectScore parseTestedSubmissionOutput(Reader reader) throws TestedProjectSubmissionOutputParsingException, IOException { + try (BufferedReader br = new BufferedReader(reader)) { + return parseTestedSubmissionOutput(br.lines()); + } + } + + static @NonNull ProjectScore parseTestedSubmissionOutput(Stream lines) throws TestedProjectSubmissionOutputParsingException { + String score = null; + int scoreLineNumber = 0; + String totalPoints = null; + String projectName = null; + LocalDateTime submissionTime = null; + + int lineNumber = 0; + + Iterator iterator = lines.iterator(); + while (iterator.hasNext()) { + String line = iterator.next(); + lineNumber++; + if (score == null && totalPoints == null) { + Matcher matcher = scorePattern.matcher(line); + if (matcher.find()) { + score = matcher.group(1); + totalPoints = matcher.group(2); + scoreLineNumber = lineNumber; + } + } + + if (projectName == null && line.contains("The Joy of Coding")) { + Matcher matcher = projectNamePattern.matcher(line); + if (matcher.find()) { + projectName = matcher.group(1); + + } else { + matcher = koansProjectNamePattern.matcher(line); + if (matcher.find()) { + projectName = "koans"; + } + } + } + + if (submissionTime == null && line.contains("Submitted on")) { + submissionTime = parseSubmissionTime(line); + } + } + + if (totalPoints == null) { + throw new TestedProjectSubmissionOutputParsingException("Could not find score line in project report"); + } + + if (projectName == null) { + throw new TestedProjectSubmissionOutputParsingException("Could not find project name in project report"); + } + + if (submissionTime == null) { + throw new TestedProjectSubmissionOutputParsingException("Could not find submission time in project report"); + } + + return new ProjectScore(score, scoreLineNumber, totalPoints, projectName, submissionTime); + } + + static LocalDateTime parseSubmissionTime(String line) { + Matcher matcher = submissionTimePattern.matcher(line); + String timeString; + if (matcher.matches()) { + timeString = matcher.group(1).trim(); + + } else { + throw new IllegalArgumentException("Could not parse submission time from line: " + line); + } + + try { + ZonedDateTime zoned; + try { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("E MMM d hh:mm:ss a z yyyy"); + zoned = ZonedDateTime.parse(timeString, formatter); + + } catch (DateTimeParseException ex) { + // Single-digit day format + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("E MMM d hh:mm:ss a z yyyy"); + zoned = ZonedDateTime.parse(timeString, formatter); + } + return zoned.toLocalDateTime(); + + } catch (DateTimeParseException ex) { + return LocalDateTime.parse(timeString); + } + } + + static class ProjectScore { + static final int UNREVIEWED_SCORE_LINE_NUMBER = 7; + private final double score; + private final double totalPoints; + private final String projectName; + private final boolean reviewed; + private final LocalDateTime submissionTime; + + ProjectScore(String score, int scoreLineNumber, String totalPoints, String projectName, LocalDateTime submissionTime) { + this.score = score == null ? Double.NaN : Double.parseDouble(score); + this.totalPoints = Double.parseDouble(totalPoints); + this.projectName = projectName; + this.reviewed = !Double.isNaN(this.score) || scoreLineNumber > UNREVIEWED_SCORE_LINE_NUMBER; + this.submissionTime = submissionTime; + } + + public double getScore() { + return this.score; + } + + public double getTotalPoints() { + return this.totalPoints; + } + + public String getProjectName() { + return projectName; + } + + /** + * Determines if a submission has been reviewed by a grader. + * A submission is considered reviewed if: + * 1. It has a numeric score, OR + * 2. The score line appears after line 7 (indicating grader comments are present) + *

+ * When a score line like " out of X.X" appears after line 7, it indicates + * the grader has left feedback for the student to fix issues and resubmit. + */ + public boolean isReviewed() { + return reviewed; + } + + public LocalDateTime getSubmissionTime() { + return submissionTime; + } + } + + @VisibleForTesting + static class TestedProjectSubmissionOutputParsingException extends Exception { + + public TestedProjectSubmissionOutputParsingException(String message) { + super(message); + } + } +} diff --git a/grader/src/test/java/edu/pdx/cs/joy/grader/FindUngradedSubmissionsTest.java b/grader/src/test/java/edu/pdx/cs/joy/grader/FindUngradedSubmissionsTest.java index 283dba292..c6302fa7a 100644 --- a/grader/src/test/java/edu/pdx/cs/joy/grader/FindUngradedSubmissionsTest.java +++ b/grader/src/test/java/edu/pdx/cs/joy/grader/FindUngradedSubmissionsTest.java @@ -1,6 +1,9 @@ package edu.pdx.cs.joy.grader; import edu.pdx.cs.joy.grader.FindUngradedSubmissions.TestOutputDetailsProviderFromTestOutputFile; +import edu.pdx.cs.joy.grader.gradebook.Grade; +import edu.pdx.cs.joy.grader.gradebook.GradeBook; +import edu.pdx.cs.joy.grader.gradebook.Student; import org.junit.jupiter.api.Test; import java.nio.file.FileSystem; @@ -8,6 +11,7 @@ import java.nio.file.Path; import java.nio.file.spi.FileSystemProvider; import java.time.LocalDateTime; +import java.util.Optional; import java.util.stream.Stream; import static org.hamcrest.MatcherAssert.assertThat; @@ -60,7 +64,8 @@ void submissionWithNoTestOutputNeedsToBeTested() { FindUngradedSubmissions.TestOutputPathProvider testOutputProvider = mock(FindUngradedSubmissions.TestOutputPathProvider.class); when(testOutputProvider.getTestOutput(any(Path.class), eq(studentId))).thenReturn(testOutput); - FindUngradedSubmissions finder = new FindUngradedSubmissions(submissionDetailsProvider, testOutputProvider, mock(FindUngradedSubmissions.TestOutputDetailsProvider.class)); + FindUngradedSubmissions.GradeBookProvider gradeBookProvider = mock(FindUngradedSubmissions.GradeBookProvider.class); + FindUngradedSubmissions finder = new FindUngradedSubmissions(submissionDetailsProvider, testOutputProvider, mock(FindUngradedSubmissions.TestOutputDetailsProvider.class), gradeBookProvider); FindUngradedSubmissions.SubmissionAnalysis analysis = finder.analyzeSubmission(submission); assertThat(analysis.needsToBeTested(), equalTo(true)); assertThat(analysis.needsToBeGraded(), equalTo(true)); @@ -83,10 +88,11 @@ void submissionWithTestOutputOlderThanSubmissionNeedsToBeTested() { when(testOutputProvider.getTestOutput(any(Path.class), eq(studentId))).thenReturn(testOutput); FindUngradedSubmissions.TestOutputDetailsProvider testOutputDetailsProvider = mock(FindUngradedSubmissions.TestOutputDetailsProvider.class); - LocalDateTime testedSubmissionTime = submissionTime.minusDays(1); // Simulate test output older than submission - when(testOutputDetailsProvider.getTestOutputDetails(testOutput)).thenReturn(new FindUngradedSubmissions.TestOutputDetails(testedSubmissionTime, true)); + LocalDateTime testedSubmissionTime = submissionTime.minusDays(1); // Old test output + when(testOutputDetailsProvider.getTestOutputDetails(testOutput)).thenReturn(new FindUngradedSubmissions.TestOutputDetails(testOutput, testedSubmissionTime, true, null, null, true)); - FindUngradedSubmissions finder = new FindUngradedSubmissions(submissionDetailsProvider, testOutputProvider, testOutputDetailsProvider); + FindUngradedSubmissions.GradeBookProvider gradeBookProvider = mock(FindUngradedSubmissions.GradeBookProvider.class); + FindUngradedSubmissions finder = new FindUngradedSubmissions(submissionDetailsProvider, testOutputProvider, testOutputDetailsProvider, gradeBookProvider); FindUngradedSubmissions.SubmissionAnalysis analysis = finder.analyzeSubmission(submission); assertThat(analysis.needsToBeTested(), equalTo(true)); assertThat(analysis.needsToBeGraded(), equalTo(true)); @@ -108,10 +114,11 @@ void submissionWithTestOutputLessThanAMinuteOlderThanSubmissionDoesNotNeedToBeTe when(testOutputProvider.getTestOutput(any(Path.class), eq(studentId))).thenReturn(testOutput); FindUngradedSubmissions.TestOutputDetailsProvider testOutputDetailsProvider = mock(FindUngradedSubmissions.TestOutputDetailsProvider.class); - LocalDateTime testedSubmissionTime = submissionTime.minusSeconds(10); // Simulate test output older than submission - when(testOutputDetailsProvider.getTestOutputDetails(testOutput)).thenReturn(new FindUngradedSubmissions.TestOutputDetails(testedSubmissionTime, true)); + LocalDateTime testedSubmissionTime = submissionTime.plusMinutes(30); // Still within 1 minute tolerance + when(testOutputDetailsProvider.getTestOutputDetails(testOutput)).thenReturn(new FindUngradedSubmissions.TestOutputDetails(testOutput, testedSubmissionTime, true, null, null, true)); - FindUngradedSubmissions finder = new FindUngradedSubmissions(submissionDetailsProvider, testOutputProvider, testOutputDetailsProvider); + FindUngradedSubmissions.GradeBookProvider gradeBookProvider = mock(FindUngradedSubmissions.GradeBookProvider.class); + FindUngradedSubmissions finder = new FindUngradedSubmissions(submissionDetailsProvider, testOutputProvider, testOutputDetailsProvider, gradeBookProvider); FindUngradedSubmissions.SubmissionAnalysis analysis = finder.analyzeSubmission(submission); assertThat(analysis.needsToBeTested(), equalTo(false)); } @@ -133,12 +140,15 @@ void submissionWithNoGradeNeedsToBeGraded() { FindUngradedSubmissions.TestOutputDetailsProvider testOutputDetailsProvider = mock(FindUngradedSubmissions.TestOutputDetailsProvider.class); LocalDateTime gradedTime = submissionTime.plusDays(1); // Simulate test output newer than submission - when(testOutputDetailsProvider.getTestOutputDetails(testOutput)).thenReturn(new FindUngradedSubmissions.TestOutputDetails(gradedTime, false)); + boolean hasBeenReviewed = false; + when(testOutputDetailsProvider.getTestOutputDetails(testOutput)).thenReturn(new FindUngradedSubmissions.TestOutputDetails(testOutput, gradedTime, false, null, null, hasBeenReviewed)); - FindUngradedSubmissions finder = new FindUngradedSubmissions(submissionDetailsProvider, testOutputProvider, testOutputDetailsProvider); + FindUngradedSubmissions.GradeBookProvider gradeBookProvider = mock(FindUngradedSubmissions.GradeBookProvider.class); + FindUngradedSubmissions finder = new FindUngradedSubmissions(submissionDetailsProvider, testOutputProvider, testOutputDetailsProvider, gradeBookProvider); FindUngradedSubmissions.SubmissionAnalysis analysis = finder.analyzeSubmission(submission); assertThat(analysis.needsToBeTested(), equalTo(false)); assertThat(analysis.needsToBeGraded(), equalTo(true)); + assertThat(analysis.testOutput(), equalTo(testOutput)); } @Test @@ -158,33 +168,34 @@ void submissionWithGradeIsGraded() { FindUngradedSubmissions.TestOutputDetailsProvider testOutputDetailsProvider = mock(FindUngradedSubmissions.TestOutputDetailsProvider.class); LocalDateTime gradedTime = submissionTime.plusDays(1); - when(testOutputDetailsProvider.getTestOutputDetails(testOutput)).thenReturn(new FindUngradedSubmissions.TestOutputDetails(gradedTime, true)); + when(testOutputDetailsProvider.getTestOutputDetails(testOutput)).thenReturn(new FindUngradedSubmissions.TestOutputDetails(testOutput, gradedTime, true, null, null, true)); - FindUngradedSubmissions finder = new FindUngradedSubmissions(submissionDetailsProvider, testOutputProvider, testOutputDetailsProvider); + FindUngradedSubmissions.GradeBookProvider gradeBookProvider = mock(FindUngradedSubmissions.GradeBookProvider.class); + FindUngradedSubmissions finder = new FindUngradedSubmissions(submissionDetailsProvider, testOutputProvider, testOutputDetailsProvider, gradeBookProvider); FindUngradedSubmissions.SubmissionAnalysis analysis = finder.analyzeSubmission(submission); assertThat(analysis.needsToBeTested(), equalTo(false)); assertThat(analysis.needsToBeGraded(), equalTo(false)); } @Test - void parseSubmissionTimeFromAndroidProjectTestOutputLine() { - LocalDateTime submissionTime = TestOutputDetailsProviderFromTestOutputFile.parseSubmissionTime(" Submitted on 2025-08-18T11:34:19.017953486"); - LocalDateTime expectedTime = LocalDateTime.of(2025, 8, 18, 11, 34, 19, 17953486); - assertThat(submissionTime, equalTo(expectedTime)); + void parseProjectNameFromTestOutputLine() { + String line = " The Joy of Coding Project 1: edu.pdx.cs410J.studentId.Project1"; + String projectName = TestOutputDetailsProviderFromTestOutputFile.parseProjectName(line); + assertThat(projectName, equalTo("Project1")); } @Test - void parseSubmissionTimeFromTestOutputLine() { - LocalDateTime submissionTime = TestOutputDetailsProviderFromTestOutputFile.parseSubmissionTime(" Submitted on Wed Aug 6 01:13:59 PM PDT 2025"); - LocalDateTime expectedTime = LocalDateTime.of(2025, 8, 6, 13, 13, 59); - assertThat(submissionTime, equalTo(expectedTime)); + void parseProjectNameFromTestOutputLineWithDifferentProject() { + String line = " The Joy of Coding Project 3: edu.pdx.cs.joy.student.Project3"; + String projectName = TestOutputDetailsProviderFromTestOutputFile.parseProjectName(line); + assertThat(projectName, equalTo("Project3")); } @Test - void parseSubmissionTimeFromTestOutputLineWithTwoDigitDay() { - LocalDateTime submissionTime = TestOutputDetailsProviderFromTestOutputFile.parseSubmissionTime(" Submitted on Wed Jul 23 12:59:13 PM PDT 2025"); - LocalDateTime expectedTime = LocalDateTime.of(2025, 7, 23, 12, 59, 13); - assertThat(submissionTime, equalTo(expectedTime)); + void lineWithoutProjectNameReturnsNull() { + String line = "Some random line without a project"; + String projectName = TestOutputDetailsProviderFromTestOutputFile.parseProjectName(line); + assertThat(projectName, equalTo(null)); } @Test @@ -209,20 +220,43 @@ void lineWithMissingGradeHasNaNGrade() { } @Test - void parseTestOutputDetails() { + void parseTestOutputDetails() throws TestedProjectSubmissionOutputParser.TestedProjectSubmissionOutputParsingException { Stream lines = Stream.of( + " The Joy of Coding Project 2: edu.pdx.cs410J.whitlock.Project2", " Submitted on Wed Aug 6 01:13:59 PM PDT 2025", "", "12.5 out of 13.0" ); - FindUngradedSubmissions.TestOutputDetails details = TestOutputDetailsProviderFromTestOutputFile.parseTestOutputDetails(lines); + Path testOutput = mock(Path.class); + FindUngradedSubmissions.TestOutputDetails details = TestOutputDetailsProviderFromTestOutputFile.parseTestOutputDetails(testOutput, lines); LocalDateTime submissionTime = LocalDateTime.of(2025, 8, 6, 13, 13, 59); + assertThat(details.testOutput(), equalTo(testOutput)); assertThat(details.testedSubmissionTime(), equalTo(submissionTime)); assertThat(details.hasGrade(), equalTo(true)); } @Test - void testOutputDetailsWithMessageToStudentDoesNotNeedToBeGraded() { + void testOutputDetailsIncludesProjectNameAndGrade() throws TestedProjectSubmissionOutputParser.TestedProjectSubmissionOutputParsingException { + Stream lines = Stream.of( + " The Joy of Coding Project 2: edu.pdx.cs410J.whitlock.Project2", + " Submitted by Dave Whitlock", + " Submitted on Wed Aug 6 01:13:59 PM PDT 2025", + " Graded on Wed Aug 6 02:30:00 PM PDT 2025", + "", + "12.5 out of 13.0" + ); + Path testOutput = mock(Path.class); + FindUngradedSubmissions.TestOutputDetails details = TestOutputDetailsProviderFromTestOutputFile.parseTestOutputDetails(testOutput, lines); + LocalDateTime submissionTime = LocalDateTime.of(2025, 8, 6, 13, 13, 59); + assertThat(details.testOutput(), equalTo(testOutput)); + assertThat(details.testedSubmissionTime(), equalTo(submissionTime)); + assertThat(details.hasGrade(), equalTo(true)); + assertThat(details.projectName(), equalTo("Project2")); + assertThat(details.grade(), equalTo(12.5)); + } + + @Test + void testOutputDetailsWithMessageToStudentDoesNotNeedToBeGraded() throws TestedProjectSubmissionOutputParser.TestedProjectSubmissionOutputParsingException { Stream lines = Stream.of( "Hi, Student. There were some problems with your submission", "", @@ -236,8 +270,229 @@ void testOutputDetailsWithMessageToStudentDoesNotNeedToBeGraded() { "", " out of 7.0" ); - FindUngradedSubmissions.TestOutputDetails details = TestOutputDetailsProviderFromTestOutputFile.parseTestOutputDetails(lines); - assertThat(details.hasGrade(), equalTo(true)); + Path testOutput = mock(Path.class); + FindUngradedSubmissions.TestOutputDetails details = TestOutputDetailsProviderFromTestOutputFile.parseTestOutputDetails(testOutput, lines); + assertThat(details.hasGrade(), equalTo(false)); + assertThat(details.hasBeenReviewed(), equalTo(true)); + } + + @Test + void submissionAnalysisIncludesGradeNeedsToBeRecorded() { + FindUngradedSubmissions.SubmissionDetailsProvider submissionDetailsProvider = mock(FindUngradedSubmissions.SubmissionDetailsProvider.class); + + String studentId = "student123"; + LocalDateTime submissionTime = LocalDateTime.now(); + FindUngradedSubmissions.SubmissionDetails submissionDetails = new FindUngradedSubmissions.SubmissionDetails(studentId, submissionTime); + Path submission = getPathToExistingFile(); + when(submissionDetailsProvider.getSubmissionDetails(submission)).thenReturn(submissionDetails); + + Path testOutput = getPathToExistingFile(); + + FindUngradedSubmissions.TestOutputPathProvider testOutputProvider = mock(FindUngradedSubmissions.TestOutputPathProvider.class); + when(testOutputProvider.getTestOutput(any(Path.class), eq(studentId))).thenReturn(testOutput); + + FindUngradedSubmissions.TestOutputDetailsProvider testOutputDetailsProvider = mock(FindUngradedSubmissions.TestOutputDetailsProvider.class); + LocalDateTime gradedTime = submissionTime.plusDays(1); + when(testOutputDetailsProvider.getTestOutputDetails(testOutput)).thenReturn(new FindUngradedSubmissions.TestOutputDetails(testOutput, gradedTime, true, "Project1", 12.5, true)); + + FindUngradedSubmissions.GradeBookProvider gradeBookProvider = mock(FindUngradedSubmissions.GradeBookProvider.class); + FindUngradedSubmissions finder = new FindUngradedSubmissions(submissionDetailsProvider, testOutputProvider, testOutputDetailsProvider, gradeBookProvider); + FindUngradedSubmissions.SubmissionAnalysis analysis = finder.analyzeSubmission(submission); + + assertThat(analysis.needsToBeTested(), equalTo(false)); + assertThat(analysis.needsToBeGraded(), equalTo(false)); + assertThat(analysis.gradeNeedsToBeRecorded(), equalTo(false)); + } + + @Test + void submissionWithGradeMissingFromGradeBookNeedsToBeRecorded() { + FindUngradedSubmissions.SubmissionDetailsProvider submissionDetailsProvider = mock(FindUngradedSubmissions.SubmissionDetailsProvider.class); + + String studentId = "student123"; + LocalDateTime submissionTime = LocalDateTime.now(); + FindUngradedSubmissions.SubmissionDetails submissionDetails = new FindUngradedSubmissions.SubmissionDetails(studentId, submissionTime); + Path submission = getPathToExistingFile(); + when(submissionDetailsProvider.getSubmissionDetails(submission)).thenReturn(submissionDetails); + + Path testOutput = getPathToExistingFile(); + + FindUngradedSubmissions.TestOutputPathProvider testOutputProvider = mock(FindUngradedSubmissions.TestOutputPathProvider.class); + when(testOutputProvider.getTestOutput(any(Path.class), eq(studentId))).thenReturn(testOutput); + + FindUngradedSubmissions.TestOutputDetailsProvider testOutputDetailsProvider = mock(FindUngradedSubmissions.TestOutputDetailsProvider.class); + LocalDateTime gradedTime = submissionTime.plusDays(1); + when(testOutputDetailsProvider.getTestOutputDetails(testOutput)).thenReturn(new FindUngradedSubmissions.TestOutputDetails(testOutput, gradedTime, true, "Project1", 12.5, true)); + + // Create a mock GradeBook with a student that doesn't have a grade for Project1 + GradeBook gradeBook = mock(GradeBook.class); + Student student = mock(Student.class); + when(gradeBook.getStudent(studentId)).thenReturn(Optional.of(student)); + when(student.getGrade("Project1")).thenReturn(null); // No grade recorded + + FindUngradedSubmissions.GradeBookProvider gradeBookProvider = mock(FindUngradedSubmissions.GradeBookProvider.class); + when(gradeBookProvider.getGradeBook()).thenReturn(Optional.of(gradeBook)); + + FindUngradedSubmissions finder = new FindUngradedSubmissions(submissionDetailsProvider, testOutputProvider, testOutputDetailsProvider, gradeBookProvider); + FindUngradedSubmissions.SubmissionAnalysis analysis = finder.analyzeSubmission(submission); + + assertThat(analysis.needsToBeTested(), equalTo(false)); + assertThat(analysis.needsToBeGraded(), equalTo(false)); + assertThat(analysis.gradeNeedsToBeRecorded(), equalTo(true)); // This should be true! + } + + @Test + void submissionWithDifferentGradeInGradeBookNeedsToBeRecorded() { + FindUngradedSubmissions.SubmissionDetailsProvider submissionDetailsProvider = mock(FindUngradedSubmissions.SubmissionDetailsProvider.class); + + String studentId = "student456"; + LocalDateTime submissionTime = LocalDateTime.now(); + FindUngradedSubmissions.SubmissionDetails submissionDetails = new FindUngradedSubmissions.SubmissionDetails(studentId, submissionTime); + Path submission = getPathToExistingFile(); + when(submissionDetailsProvider.getSubmissionDetails(submission)).thenReturn(submissionDetails); + + Path testOutput = getPathToExistingFile(); + + FindUngradedSubmissions.TestOutputPathProvider testOutputProvider = mock(FindUngradedSubmissions.TestOutputPathProvider.class); + when(testOutputProvider.getTestOutput(any(Path.class), eq(studentId))).thenReturn(testOutput); + + FindUngradedSubmissions.TestOutputDetailsProvider testOutputDetailsProvider = mock(FindUngradedSubmissions.TestOutputDetailsProvider.class); + LocalDateTime gradedTime = submissionTime.plusDays(1); + when(testOutputDetailsProvider.getTestOutputDetails(testOutput)).thenReturn(new FindUngradedSubmissions.TestOutputDetails(testOutput, gradedTime, true, "Project2", 12.5, true)); + + // Create a mock GradeBook with a student that has a DIFFERENT grade for Project2 + GradeBook gradeBook = mock(GradeBook.class); + Student student = mock(Student.class); + Grade grade = mock(Grade.class); + when(gradeBook.getStudent(studentId)).thenReturn(Optional.of(student)); + when(student.getGrade("Project2")).thenReturn(grade); + when(grade.getScore()).thenReturn(10.0); // GradeBook has 10.0, test output has 12.5 + + FindUngradedSubmissions.GradeBookProvider gradeBookProvider = mock(FindUngradedSubmissions.GradeBookProvider.class); + when(gradeBookProvider.getGradeBook()).thenReturn(Optional.of(gradeBook)); + + FindUngradedSubmissions finder = new FindUngradedSubmissions(submissionDetailsProvider, testOutputProvider, testOutputDetailsProvider, gradeBookProvider); + FindUngradedSubmissions.SubmissionAnalysis analysis = finder.analyzeSubmission(submission); + + assertThat(analysis.needsToBeTested(), equalTo(false)); + assertThat(analysis.needsToBeGraded(), equalTo(false)); + assertThat(analysis.gradeNeedsToBeRecorded(), equalTo(true)); // Grades differ! + } + + @Test + void submissionWithMatchingGradeInGradeBookDoesNotNeedToBeRecorded() { + FindUngradedSubmissions.SubmissionDetailsProvider submissionDetailsProvider = mock(FindUngradedSubmissions.SubmissionDetailsProvider.class); + + String studentId = "student789"; + LocalDateTime submissionTime = LocalDateTime.now(); + FindUngradedSubmissions.SubmissionDetails submissionDetails = new FindUngradedSubmissions.SubmissionDetails(studentId, submissionTime); + Path submission = getPathToExistingFile(); + when(submissionDetailsProvider.getSubmissionDetails(submission)).thenReturn(submissionDetails); + + Path testOutput = getPathToExistingFile(); + + FindUngradedSubmissions.TestOutputPathProvider testOutputProvider = mock(FindUngradedSubmissions.TestOutputPathProvider.class); + when(testOutputProvider.getTestOutput(any(Path.class), eq(studentId))).thenReturn(testOutput); + + FindUngradedSubmissions.TestOutputDetailsProvider testOutputDetailsProvider = mock(FindUngradedSubmissions.TestOutputDetailsProvider.class); + LocalDateTime gradedTime = submissionTime.plusDays(1); + when(testOutputDetailsProvider.getTestOutputDetails(testOutput)).thenReturn(new FindUngradedSubmissions.TestOutputDetails(testOutput, gradedTime, true, "Project3", 15.0, true)); + + // Create a mock GradeBook with a student that has a MATCHING grade for Project3 + GradeBook gradeBook = mock(GradeBook.class); + Student student = mock(Student.class); + Grade grade = mock(Grade.class); + + when(gradeBook.getStudent(studentId)).thenReturn(Optional.of(student)); + when(student.getGrade("Project3")).thenReturn(grade); + when(grade.getScore()).thenReturn(15.0); // GradeBook and test output both have 15.0 + + FindUngradedSubmissions.GradeBookProvider gradeBookProvider = mock(FindUngradedSubmissions.GradeBookProvider.class); + when(gradeBookProvider.getGradeBook()).thenReturn(Optional.of(gradeBook)); + + FindUngradedSubmissions finder = new FindUngradedSubmissions(submissionDetailsProvider, testOutputProvider, testOutputDetailsProvider, gradeBookProvider); + FindUngradedSubmissions.SubmissionAnalysis analysis = finder.analyzeSubmission(submission); + + assertThat(analysis.needsToBeTested(), equalTo(false)); + assertThat(analysis.needsToBeGraded(), equalTo(false)); + assertThat(analysis.gradeNeedsToBeRecorded(), equalTo(false)); // Grades match! + } + + @Test + void ungradedSubmissionIsNotConsideredUnrecordedEvenIfGradebookHasGrade() { + FindUngradedSubmissions.SubmissionDetailsProvider submissionDetailsProvider = mock(FindUngradedSubmissions.SubmissionDetailsProvider.class); + + String studentId = "student999"; + LocalDateTime submissionTime = LocalDateTime.now(); + FindUngradedSubmissions.SubmissionDetails submissionDetails = new FindUngradedSubmissions.SubmissionDetails(studentId, submissionTime); + Path submission = getPathToExistingFile(); + when(submissionDetailsProvider.getSubmissionDetails(submission)).thenReturn(submissionDetails); + + Path testOutput = getPathToExistingFile(); + + FindUngradedSubmissions.TestOutputPathProvider testOutputProvider = mock(FindUngradedSubmissions.TestOutputPathProvider.class); + when(testOutputProvider.getTestOutput(any(Path.class), eq(studentId))).thenReturn(testOutput); + + FindUngradedSubmissions.TestOutputDetailsProvider testOutputDetailsProvider = mock(FindUngradedSubmissions.TestOutputDetailsProvider.class); + LocalDateTime gradedTime = submissionTime.plusDays(1); + // Test output has NO grade yet (hasGrade = false, grade = null) + boolean hasBeenReviewed = false; + when(testOutputDetailsProvider.getTestOutputDetails(testOutput)).thenReturn(new FindUngradedSubmissions.TestOutputDetails(testOutput, gradedTime, false, "Project4", null, hasBeenReviewed)); + + // Create a mock GradeBook with a student that HAS a grade for Project4 in the gradebook + GradeBook gradeBook = mock(GradeBook.class); + Student student = mock(Student.class); + Grade grade = mock(Grade.class); + + when(gradeBook.getStudent(studentId)).thenReturn(Optional.of(student)); + when(student.getGrade("Project4")).thenReturn(grade); + when(grade.getScore()).thenReturn(20.0); // GradeBook has a grade, but test output doesn't + + FindUngradedSubmissions.GradeBookProvider gradeBookProvider = mock(FindUngradedSubmissions.GradeBookProvider.class); + when(gradeBookProvider.getGradeBook()).thenReturn(Optional.of(gradeBook)); + + FindUngradedSubmissions finder = new FindUngradedSubmissions(submissionDetailsProvider, testOutputProvider, testOutputDetailsProvider, gradeBookProvider); + FindUngradedSubmissions.SubmissionAnalysis analysis = finder.analyzeSubmission(submission); + + // The submission needs to be graded (no grade in test output yet) + assertThat(analysis.needsToBeTested(), equalTo(false)); + assertThat(analysis.needsToBeGraded(), equalTo(true)); + // Even though gradebook has a grade, since submission isn't graded yet, it should NOT be considered unrecorded + assertThat(analysis.gradeNeedsToBeRecorded(), equalTo(false)); + } + + + @Test + void reviewedSubmissionDoesNotNeedToBeTestedGradedOrRecorded() { + FindUngradedSubmissions.SubmissionDetailsProvider submissionDetailsProvider = mock(FindUngradedSubmissions.SubmissionDetailsProvider.class); + + String studentId = "student999"; + LocalDateTime submissionTime = LocalDateTime.now(); + FindUngradedSubmissions.SubmissionDetails submissionDetails = new FindUngradedSubmissions.SubmissionDetails(studentId, submissionTime); + Path submission = getPathToExistingFile(); + when(submissionDetailsProvider.getSubmissionDetails(submission)).thenReturn(submissionDetails); + + Path testOutput = getPathToExistingFile(); + + FindUngradedSubmissions.TestOutputPathProvider testOutputProvider = mock(FindUngradedSubmissions.TestOutputPathProvider.class); + when(testOutputProvider.getTestOutput(any(Path.class), eq(studentId))).thenReturn(testOutput); + + FindUngradedSubmissions.TestOutputDetailsProvider testOutputDetailsProvider = mock(FindUngradedSubmissions.TestOutputDetailsProvider.class); + LocalDateTime gradedTime = submissionTime.plusDays(1); + // Test output has NO grade yet (hasGrade = false, grade = null) + boolean hasGrade = false; + boolean hasBeenReviewed = true; + when(testOutputDetailsProvider.getTestOutputDetails(testOutput)).thenReturn(new FindUngradedSubmissions.TestOutputDetails(testOutput, gradedTime, hasGrade, "Project4", null, hasBeenReviewed)); + + FindUngradedSubmissions.GradeBookProvider gradeBookProvider = mock(FindUngradedSubmissions.GradeBookProvider.class); + + FindUngradedSubmissions finder = new FindUngradedSubmissions(submissionDetailsProvider, testOutputProvider, testOutputDetailsProvider, gradeBookProvider); + FindUngradedSubmissions.SubmissionAnalysis analysis = finder.analyzeSubmission(submission); + + // The submission needs to be graded (no grade in test output yet) + assertThat(analysis.needsToBeTested(), equalTo(false)); + assertThat(analysis.needsToBeGraded(), equalTo(false)); + assertThat(analysis.gradeNeedsToBeRecorded(), equalTo(false)); } } + diff --git a/grader/src/test/java/edu/pdx/cs/joy/grader/ProjectGradesImporterTest.java b/grader/src/test/java/edu/pdx/cs/joy/grader/ProjectGradesImporterTest.java index 26b8096e7..6df9b148f 100644 --- a/grader/src/test/java/edu/pdx/cs/joy/grader/ProjectGradesImporterTest.java +++ b/grader/src/test/java/edu/pdx/cs/joy/grader/ProjectGradesImporterTest.java @@ -1,5 +1,7 @@ package edu.pdx.cs.joy.grader; +import edu.pdx.cs.joy.grader.TestedProjectSubmissionOutputParser.TestedProjectSubmissionOutputParsingException; +import edu.pdx.cs.joy.grader.TestedProjectSubmissionOutputParserTest.TestedProjectSubmissionOutput; import edu.pdx.cs.joy.grader.gradebook.Assignment; import edu.pdx.cs.joy.grader.gradebook.GradeBook; import edu.pdx.cs.joy.grader.gradebook.Student; @@ -8,9 +10,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.Reader; -import java.io.StringReader; -import java.util.regex.Matcher; +import java.io.IOException; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; @@ -20,104 +20,10 @@ public class ProjectGradesImporterTest { - private Logger logger = LoggerFactory.getLogger(this.getClass().getPackage().getName()); + private final Logger logger = LoggerFactory.getLogger(this.getClass().getPackage().getName()); @Test - public void gradedProjectWithNoGradeThrowsScoreNotFoundException() throws ProjectGradesImporter.ScoreNotFoundException { - GradedProject project = new GradedProject(); - project.addLine("asdfhjkl"); - project.addLine("iadguow"); - - assertThrows(ProjectGradesImporter.ScoreNotFoundException.class, () -> - ProjectGradesImporter.getScoreFrom(project.getReader()) - ); - } - - @Test - public void scoreRegularExpressionWorkWithSimpleCase() { - Matcher matcher = ProjectGradesImporter.scorePattern.matcher("3.0 out of 4.5"); - assertThat(matcher.find(), equalTo(true)); - assertThat(matcher.groupCount(), equalTo(2)); - assertThat(matcher.group(1), equalTo("3.0")); - assertThat(matcher.group(2), equalTo("4.5")); - } - - @Test - public void gradedProjectWithOutOfHasValidScore() throws ProjectGradesImporter.ScoreNotFoundException { - GradedProject project = new GradedProject(); - project.addLine("3.4 out of 3.5"); - project.addLine("iadguow"); - - ProjectGradesImporter.ProjectScore score = ProjectGradesImporter.getScoreFrom(project.getReader()); - assertThat(score.getScore(), equalTo(3.4)); - assertThat(score.getTotalPoints(), equalTo(3.5)); - } - - @Test - public void scoreMatchesRegardlessOfCase() { - Matcher matcher = ProjectGradesImporter.scorePattern.matcher("4.0 OUT OF 5.0"); - assertThat(matcher.find(), equalTo(true)); - assertThat(matcher.groupCount(), equalTo(2)); - assertThat(matcher.group(1), equalTo("4.0")); - assertThat(matcher.group(2), equalTo("5.0")); - } - - @Test - public void scoreMatchesIntegerPoints() { - Matcher matcher = ProjectGradesImporter.scorePattern.matcher("4 out of 5"); - assertThat(matcher.find(), equalTo(true)); - assertThat(matcher.groupCount(), equalTo(2)); - assertThat(matcher.group(1), equalTo("4")); - assertThat(matcher.group(2), equalTo("5")); - } - - @Test - public void scoreMatchesInTheMiddleOfOtherText() { - Matcher matcher = ProjectGradesImporter.scorePattern.matcher("You got 4 out of 5 points"); - assertThat(matcher.find(), equalTo(true)); - assertThat(matcher.groupCount(), equalTo(2)); - assertThat(matcher.group(1), equalTo("4")); - assertThat(matcher.group(2), equalTo("5")); - } - - @Test - public void gradedProjectWithIntegerPoints() throws ProjectGradesImporter.ScoreNotFoundException { - GradedProject project = new GradedProject(); - project.addLine("3 out of 5"); - project.addLine("iadguow"); - - ProjectGradesImporter.ProjectScore score = ProjectGradesImporter.getScoreFrom(project.getReader()); - assertThat(score.getScore(), equalTo(3.0)); - assertThat(score.getTotalPoints(), equalTo(5.0)); - } - - private class GradedProject { - private StringBuilder sb = new StringBuilder(); - - public void addLine(String line) { - sb.append(line); - sb.append("\n"); - } - - public Reader getReader() { - return new StringReader(sb.toString()); - } - } - - @Test - public void onlyFirstScoreIsReturned() throws ProjectGradesImporter.ScoreNotFoundException { - GradedProject project = new GradedProject(); - project.addLine("3.4 out of 3.5"); - project.addLine("iadguow"); - project.addLine("3.3 out of 3.4"); - - ProjectGradesImporter.ProjectScore score = ProjectGradesImporter.getScoreFrom(project.getReader()); - assertThat(score.getScore(), equalTo(3.4)); - assertThat(score.getTotalPoints(), equalTo(3.5)); - } - - @Test - public void scoreIsRecordedInGradeBook() throws ProjectGradesImporter.ScoreNotFoundException { + public void scoreIsRecordedInGradeBook() throws TestedProjectSubmissionOutputParsingException, IOException { String studentId = "student"; Assignment assignment = new Assignment("project", 6.0); @@ -126,7 +32,9 @@ public void scoreIsRecordedInGradeBook() throws ProjectGradesImporter.ScoreNotFo gradeBook.addAssignment(assignment); String score = "5.8"; - GradedProject project = new GradedProject(); + TestedProjectSubmissionOutput project = new TestedProjectSubmissionOutput(); + project.addLine(" The Joy of Coding Project 2: edu.pdx.cs410J.student.Project2"); + project.addLine(" Submitted on Wed Feb 4 05:07:17 PM PST 2026"); project.addLine(score + " out of 6.0"); project.addLine(""); project.addLine("asdfasd"); @@ -144,7 +52,7 @@ public void scoreIsRecordedInGradeBook() throws ProjectGradesImporter.ScoreNotFo } @Test - public void throwIllegalStateExceptionWhenTotalPointsInReportDoesNotMatchGradeBook() throws ProjectGradesImporter.ScoreNotFoundException { + public void throwIllegalStateExceptionWhenTotalPointsInReportDoesNotMatchGradeBook() { String studentId = "student"; Assignment assignment = new Assignment("project", 8.0); @@ -152,7 +60,9 @@ public void throwIllegalStateExceptionWhenTotalPointsInReportDoesNotMatchGradeBo gradeBook.addStudent(new Student(studentId)); gradeBook.addAssignment(assignment); - GradedProject project = new GradedProject(); + TestedProjectSubmissionOutput project = new TestedProjectSubmissionOutput(); + project.addLine(" The Joy of Coding Project 2: edu.pdx.cs410J.student.Project2"); + project.addLine(" Submitted on Wed Feb 4 05:07:17 PM PST 2026"); project.addLine("5.8 out of 6.0"); project.addLine(""); project.addLine("asdfasd"); @@ -164,14 +74,16 @@ public void throwIllegalStateExceptionWhenTotalPointsInReportDoesNotMatchGradeBo } @Test - public void logWarningWhenStudentDoesNotExistInGradeBook() throws ProjectGradesImporter.ScoreNotFoundException { + public void logWarningWhenStudentDoesNotExistInGradeBook() throws TestedProjectSubmissionOutputParsingException, IOException { String studentId = "student"; Assignment assignment = new Assignment("project", 6.0); GradeBook gradeBook = new GradeBook("test"); gradeBook.addAssignment(assignment); - GradedProject project = new GradedProject(); + TestedProjectSubmissionOutput project = new TestedProjectSubmissionOutput(); + project.addLine(" The Joy of Coding Project 2: edu.pdx.cs410J.student.Project2"); + project.addLine(" Submitted on Wed Feb 4 05:07:17 PM PST 2026"); project.addLine("5.8 out of 6.0"); project.addLine(""); project.addLine("asdfasd"); diff --git a/grader/src/test/java/edu/pdx/cs/joy/grader/TestedProjectSubmissionOutputParserTest.java b/grader/src/test/java/edu/pdx/cs/joy/grader/TestedProjectSubmissionOutputParserTest.java new file mode 100644 index 000000000..0951dd15e --- /dev/null +++ b/grader/src/test/java/edu/pdx/cs/joy/grader/TestedProjectSubmissionOutputParserTest.java @@ -0,0 +1,256 @@ +package edu.pdx.cs.joy.grader; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.time.LocalDateTime; +import java.util.regex.Matcher; + +import static edu.pdx.cs.joy.grader.TestedProjectSubmissionOutputParser.*; +import static edu.pdx.cs.joy.grader.TestedProjectSubmissionOutputParser.parseTestedSubmissionOutput; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class TestedProjectSubmissionOutputParserTest { + + @Test + public void gradedProjectWithNoGradeThrowsScoreNotFoundException() { + TestedProjectSubmissionOutput project = new TestedProjectSubmissionOutput(); + project.addLine("asdfhjkl"); + project.addLine("iadguow"); + + assertThrows(TestedProjectSubmissionOutputParsingException.class, () -> + ProjectGradesImporter.getScoreFrom(project.getReader()) + ); + } + + @Test + public void scoreRegularExpressionWorkWithSimpleCase() { + Matcher matcher = scorePattern.matcher("3.0 out of 4.5"); + assertThat(matcher.find(), equalTo(true)); + assertThat(matcher.groupCount(), equalTo(2)); + assertThat(matcher.group(1), equalTo("3.0")); + assertThat(matcher.group(2), equalTo("4.5")); + } + + @Test + public void gradedProjectWithOutOfHasValidScore() throws TestedProjectSubmissionOutputParsingException, IOException { + TestedProjectSubmissionOutput project = new TestedProjectSubmissionOutput(); + project.addLine(" The Joy of Coding Project 2: edu.pdx.cs410J.student.Project2"); + project.addLine(" Submitted on Wed Feb 4 05:07:17 PM PST 2026"); + project.addLine("3.4 out of 3.5"); + project.addLine("iadguow"); + + ProjectScore score = parseTestedSubmissionOutput(project.getReader()); + assertThat(score.getScore(), equalTo(3.4)); + assertThat(score.getTotalPoints(), equalTo(3.5)); + } + + @Test + public void scoreMatchesRegardlessOfCase() { + Matcher matcher = scorePattern.matcher("4.0 OUT OF 5.0"); + assertThat(matcher.find(), equalTo(true)); + assertThat(matcher.groupCount(), equalTo(2)); + assertThat(matcher.group(1), equalTo("4.0")); + assertThat(matcher.group(2), equalTo("5.0")); + } + + @Test + public void scoreMatchesIntegerPoints() { + Matcher matcher = scorePattern.matcher("4 out of 5"); + assertThat(matcher.find(), equalTo(true)); + assertThat(matcher.groupCount(), equalTo(2)); + assertThat(matcher.group(1), equalTo("4")); + assertThat(matcher.group(2), equalTo("5")); + } + + @Test + public void scoreMatchesInTheMiddleOfOtherText() { + Matcher matcher = scorePattern.matcher("You got 4 out of 5 points"); + assertThat(matcher.find(), equalTo(true)); + assertThat(matcher.groupCount(), equalTo(2)); + assertThat(matcher.group(1), equalTo("4")); + assertThat(matcher.group(2), equalTo("5")); + } + + @Test + public void gradedProjectWithIntegerPoints() throws TestedProjectSubmissionOutputParsingException, IOException { + TestedProjectSubmissionOutput project = new TestedProjectSubmissionOutput(); + project.addLine(" The Joy of Coding Project 2: edu.pdx.cs410J.student.Project2"); + project.addLine(" Submitted on Wed Feb 4 05:07:17 PM PST 2026"); + project.addLine("3 out of 5"); + project.addLine("iadguow"); + + ProjectScore score = parseTestedSubmissionOutput(project.getReader()); + assertThat(score.getScore(), equalTo(3.0)); + assertThat(score.getTotalPoints(), equalTo(5.0)); + } + + static class TestedProjectSubmissionOutput { + private final StringBuilder sb = new StringBuilder(); + + public void addLine(String line) { + sb.append(line); + sb.append("\n"); + } + + public Reader getReader() { + return new StringReader(sb.toString()); + } + } + + @Test + public void onlyFirstScoreIsReturned() throws TestedProjectSubmissionOutputParsingException, IOException { + TestedProjectSubmissionOutput project = new TestedProjectSubmissionOutput(); + project.addLine(" The Joy of Coding Project 2: edu.pdx.cs410J.student.Project2"); + project.addLine(" Submitted on Wed Feb 4 05:07:17 PM PST 2026"); + project.addLine("3.4 out of 3.5"); + project.addLine("iadguow"); + project.addLine("3.3 out of 3.4"); + + ProjectScore score = parseTestedSubmissionOutput(project.getReader()); + assertThat(score.getScore(), equalTo(3.4)); + assertThat(score.getTotalPoints(), equalTo(3.5)); + } + + @Test + public void projectNameIsIdentified() throws TestedProjectSubmissionOutputParsingException, IOException { + String projectName = "Project2"; + + TestedProjectSubmissionOutput project = new TestedProjectSubmissionOutput(); + project.addLine(""); + project.addLine(" The Joy of Coding Project 2: edu.pdx.cs410J.student." + projectName); + project.addLine(" Submitted by Student Name"); + project.addLine(" Submitted on Wed Feb 4 05:07:17 PM PST 2026"); + project.addLine(" Graded on Wed Feb 4 06:08:07 PM PST 2026"); + project.addLine(""); + project.addLine("5.5 out of 6.0"); + project.addLine(""); + + ProjectScore score = parseTestedSubmissionOutput(project.getReader()); + assertThat(score.getScore(), equalTo(5.5)); + assertThat(score.getTotalPoints(), equalTo(6.0)); + assertThat(score.getProjectName(), equalTo(projectName)); + assertThat(score.isReviewed(), equalTo(true)); + } + + @Test + public void koansProjectNameIsIdentified() throws TestedProjectSubmissionOutputParsingException, IOException { + TestedProjectSubmissionOutput project = new TestedProjectSubmissionOutput(); + project.addLine(""); + project.addLine(" The Joy of Coding Koans"); + project.addLine(" Submitted by Student Name"); + project.addLine(" Submitted on Wed Feb 4 05:07:17 PM PST 2026"); + project.addLine(" Graded on Wed Feb 4 06:08:07 PM PST 2026"); + project.addLine(""); + project.addLine("5.5 out of 6.0"); + project.addLine(""); + + ProjectScore score = parseTestedSubmissionOutput(project.getReader()); + assertThat(score.getScore(), equalTo(5.5)); + assertThat(score.getTotalPoints(), equalTo(6.0)); + assertThat(score.getProjectName(), equalTo("koans")); + assertThat(score.isReviewed(), equalTo(true)); + } + + @Test + public void scoreRegularExpressionWorksWithMissingScore() { + Matcher matcher = scorePattern.matcher(" out of 4.5"); + assertThat(matcher.find(), equalTo(true)); + assertThat(matcher.groupCount(), equalTo(2)); + assertThat(matcher.group(1), equalTo(null)); + assertThat(matcher.group(2), equalTo("4.5")); + } + + @Test + public void ungradedProjectWithoutCommentIsNotReviewed() throws TestedProjectSubmissionOutputParsingException, IOException { + String projectName = "Project2"; + + TestedProjectSubmissionOutput project = new TestedProjectSubmissionOutput(); + project.addLine(""); + project.addLine(" The Joy of Coding Project 2: edu.pdx.cs410J.student." + projectName); + project.addLine(" Submitted by Student Name"); + project.addLine(" Submitted on Wed Feb 4 05:07:17 PM PST 2026"); + project.addLine(" Graded on Wed Feb 4 06:08:07 PM PST 2026"); + project.addLine(""); + project.addLine(" out of 6.0"); + project.addLine(""); + + ProjectScore score = parseTestedSubmissionOutput(project.getReader()); + assertThat(score.getScore(), equalTo(Double.NaN)); + assertThat(score.getTotalPoints(), equalTo(6.0)); + assertThat(score.getProjectName(), equalTo(projectName)); + assertThat(score.isReviewed(), equalTo(false)); + } + + @Test + public void testOutputWithCommentsForStudentIsMarkedAsReviewed() throws TestedProjectSubmissionOutputParsingException, IOException { + String projectName = "Project2"; + + TestedProjectSubmissionOutput project = new TestedProjectSubmissionOutput(); + project.addLine(""); + project.addLine("Hey Student Name. I found some issues with your code. Please fix them and resubmit."); + project.addLine(""); + project.addLine(" The Joy of Coding Project 2: edu.pdx.cs410J.student." + projectName); + project.addLine(" Submitted by Student Name"); + project.addLine(" Submitted on Wed Feb 4 05:07:17 PM PST 2026"); + project.addLine(" Graded on Wed Feb 4 06:08:07 PM PST 2026"); + project.addLine(""); + project.addLine(" out of 6.0"); + project.addLine(""); + + ProjectScore score = parseTestedSubmissionOutput(project.getReader()); + assertThat(score.getScore(), equalTo(Double.NaN)); + assertThat(score.getTotalPoints(), equalTo(6.0)); + assertThat(score.getProjectName(), equalTo(projectName)); + assertThat(score.isReviewed(), equalTo(true)); + } + + + @Test + void parseSubmissionTimeFromAndroidProjectTestOutputLine() { + LocalDateTime submissionTime = parseSubmissionTime(" Submitted on 2025-08-18T11:34:19.017953486"); + LocalDateTime expectedTime = LocalDateTime.of(2025, 8, 18, 11, 34, 19, 17953486); + assertThat(submissionTime, equalTo(expectedTime)); + } + + @Test + void parseSubmissionTimeFromTestOutputLine() { + LocalDateTime submissionTime = parseSubmissionTime(" Submitted on Wed Aug 6 01:13:59 PM PDT 2025"); + LocalDateTime expectedTime = LocalDateTime.of(2025, 8, 6, 13, 13, 59); + assertThat(submissionTime, equalTo(expectedTime)); + } + + @Test + void parseSubmissionTimeFromTestOutputLineWithTwoDigitDay() { + LocalDateTime submissionTime = parseSubmissionTime(" Submitted on Wed Jul 23 12:59:13 PM PDT 2025"); + LocalDateTime expectedTime = LocalDateTime.of(2025, 7, 23, 12, 59, 13); + assertThat(submissionTime, equalTo(expectedTime)); + } + + @Test + public void submissionTimeIsIdentified() throws TestedProjectSubmissionOutputParsingException, IOException { + String projectName = "Project2"; + LocalDateTime submissionTime = LocalDateTime.of(2026, 2, 4, 17, 7, 17); + + TestedProjectSubmissionOutput project = new TestedProjectSubmissionOutput(); + project.addLine(""); + project.addLine(" The Joy of Coding Project 2: edu.pdx.cs410J.student." + projectName); + project.addLine(" Submitted by Student Name"); + project.addLine(" Submitted on Wed Feb 4 05:07:17 PM PST 2026"); + project.addLine(" Graded on Wed Feb 4 06:08:07 PM PST 2026"); + project.addLine(""); + project.addLine("5.5 out of 6.0"); + project.addLine(""); + + ProjectScore score = parseTestedSubmissionOutput(project.getReader()); + assertThat(score.getScore(), equalTo(5.5)); + assertThat(score.getTotalPoints(), equalTo(6.0)); + assertThat(score.getProjectName(), equalTo(projectName)); + assertThat(score.isReviewed(), equalTo(true)); + assertThat(score.getSubmissionTime(), equalTo(submissionTime)); + } +} diff --git a/pom.xml b/pom.xml index 381fd2ac5..e2f3277b9 100644 --- a/pom.xml +++ b/pom.xml @@ -94,7 +94,7 @@ 0.75 0 - 1.5.0 + 1.5.2-SNAPSHOT