diff --git a/src/main/java/de/tum/in/www1/artemis/config/Constants.java b/src/main/java/de/tum/in/www1/artemis/config/Constants.java index f85699d9ae4f..5f9d4de1de12 100644 --- a/src/main/java/de/tum/in/www1/artemis/config/Constants.java +++ b/src/main/java/de/tum/in/www1/artemis/config/Constants.java @@ -337,6 +337,16 @@ public final class Constants { */ public static final String CHECKED_OUT_REPOS_TEMP_DIR = "checked-out-repos"; + /** + * Minimum score for a result to be considered successful and shown in green + */ + public static final int MIN_SCORE_GREEN = 80; + + /** + * Minimum score for a result to be considered partially successful and shown in orange + */ + public static final int MIN_SCORE_ORANGE = 40; + private Constants() { } } diff --git a/src/main/java/de/tum/in/www1/artemis/config/migration/MigrationRegistry.java b/src/main/java/de/tum/in/www1/artemis/config/migration/MigrationRegistry.java index e8e05bbf7889..b39b1be1d0d8 100644 --- a/src/main/java/de/tum/in/www1/artemis/config/migration/MigrationRegistry.java +++ b/src/main/java/de/tum/in/www1/artemis/config/migration/MigrationRegistry.java @@ -12,6 +12,8 @@ import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; +import de.tum.in.www1.artemis.config.migration.entries.MigrationEntry20240614_140000; + /** * This component allows registering certain entries containing functionality that gets executed on application startup. The entries must extend {@link MigrationEntry}. */ @@ -26,6 +28,7 @@ public class MigrationRegistry { public MigrationRegistry(MigrationService migrationService) { this.migrationService = migrationService; + this.migrationEntryMap.put(1, MigrationEntry20240614_140000.class); // Here we define the order of the ChangeEntries } diff --git a/src/main/java/de/tum/in/www1/artemis/config/migration/entries/MigrationEntry20240614_140000.java b/src/main/java/de/tum/in/www1/artemis/config/migration/entries/MigrationEntry20240614_140000.java new file mode 100644 index 000000000000..dc433c1a6585 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/config/migration/entries/MigrationEntry20240614_140000.java @@ -0,0 +1,54 @@ +package de.tum.in.www1.artemis.config.migration.entries; + +import java.time.ZonedDateTime; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.tum.in.www1.artemis.config.migration.MigrationEntry; +import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.competency.Competency; +import de.tum.in.www1.artemis.repository.CompetencyRepository; +import de.tum.in.www1.artemis.repository.CourseRepository; +import de.tum.in.www1.artemis.service.competency.CompetencyProgressService; + +public class MigrationEntry20240614_140000 extends MigrationEntry { + + private static final Logger log = LoggerFactory.getLogger(MigrationEntry20240614_140000.class); + + private final CourseRepository courseRepository; + + private final CompetencyRepository competencyRepository; + + private final CompetencyProgressService competencyProgressService; + + public MigrationEntry20240614_140000(CourseRepository courseRepository, CompetencyRepository competencyRepository, CompetencyProgressService competencyProgressService) { + this.courseRepository = courseRepository; + this.competencyRepository = competencyRepository; + this.competencyProgressService = competencyProgressService; + } + + @Override + public void execute() { + List activeCourses = courseRepository.findAllActiveWithoutTestCourses(ZonedDateTime.now()); + + log.info("Updating competency progress for {} active courses", activeCourses.size()); + + activeCourses.forEach(course -> { + List competencies = competencyRepository.findByCourseId(course.getId()); + // Asynchronously update the progress for each competency + competencies.forEach(competencyProgressService::updateProgressByCompetencyAsync); + }); + } + + @Override + public String author() { + return "stoehrj"; + } + + @Override + public String date() { + return "20240614_140000"; + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java b/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java index b596e904897e..93a59504f213 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java @@ -1,5 +1,7 @@ package de.tum.in.www1.artemis.domain; +import static de.tum.in.www1.artemis.config.Constants.MIN_SCORE_GREEN; + import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Collection; @@ -239,12 +241,9 @@ public abstract class Exercise extends BaseExercise implements LearningObject { @Override public boolean isCompletedFor(User user) { - return this.getStudentParticipations().stream().anyMatch((participation) -> participation.getStudents().contains(user)); - } - - @Override - public Optional getCompletionDate(User user) { - return this.getStudentParticipations().stream().filter((participation) -> participation.getStudents().contains(user)).map(Participation::getInitializationDate).findFirst(); + var latestResult = this.getStudentParticipations().stream().filter(participation -> participation.getStudents().contains(user)) + .flatMap(participation -> participation.getResults().stream()).max(Comparator.comparing(Result::getCompletionDate)); + return latestResult.map(result -> result.getScore() >= MIN_SCORE_GREEN).orElse(false); } public boolean getAllowFeedbackRequests() { diff --git a/src/main/java/de/tum/in/www1/artemis/domain/LearningObject.java b/src/main/java/de/tum/in/www1/artemis/domain/LearningObject.java index bfc783a3c02a..cfe388000a01 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/LearningObject.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/LearningObject.java @@ -1,7 +1,5 @@ package de.tum.in.www1.artemis.domain; -import java.time.ZonedDateTime; -import java.util.Optional; import java.util.Set; import de.tum.in.www1.artemis.domain.competency.Competency; @@ -16,14 +14,6 @@ public interface LearningObject { */ boolean isCompletedFor(User user); - /** - * Get the date when the object has been completed by the participant - * - * @param user the user to retrieve the date for - * @return The datetime when the object was first completed or null - */ - Optional getCompletionDate(User user); - Long getId(); Set getCompetencies(); diff --git a/src/main/java/de/tum/in/www1/artemis/domain/competency/CompetencyProgress.java b/src/main/java/de/tum/in/www1/artemis/domain/competency/CompetencyProgress.java index 0ff87488b1a4..f6803cdeb37f 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/competency/CompetencyProgress.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/competency/CompetencyProgress.java @@ -10,6 +10,8 @@ import jakarta.persistence.EmbeddedId; import jakarta.persistence.Entity; import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.ManyToOne; import jakarta.persistence.MapsId; import jakarta.persistence.Table; @@ -23,6 +25,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.domain.enumeration.CompetencyProgressConfidenceReason; /** * This class models the 'progress' association between a user and a competency. @@ -57,6 +60,10 @@ public class CompetencyProgress implements Serializable { @Column(name = "confidence") private Double confidence; + @Enumerated(EnumType.STRING) + @Column(name = "confidence_reason", columnDefinition = "varchar(30) default 'NO_REASON'") + private CompetencyProgressConfidenceReason confidenceReason = CompetencyProgressConfidenceReason.NO_REASON; + @LastModifiedDate @Column(name = "last_modified_date") @JsonIgnore @@ -98,6 +105,14 @@ public void setConfidence(Double confidence) { this.confidence = confidence; } + public CompetencyProgressConfidenceReason getConfidenceReason() { + return confidenceReason; + } + + public void setConfidenceReason(CompetencyProgressConfidenceReason confidenceReason) { + this.confidenceReason = confidenceReason; + } + public Instant getLastModifiedDate() { return lastModifiedDate; } diff --git a/src/main/java/de/tum/in/www1/artemis/domain/enumeration/CompetencyProgressConfidenceReason.java b/src/main/java/de/tum/in/www1/artemis/domain/enumeration/CompetencyProgressConfidenceReason.java new file mode 100644 index 000000000000..f57ddd8efecb --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/domain/enumeration/CompetencyProgressConfidenceReason.java @@ -0,0 +1,12 @@ +package de.tum.in.www1.artemis.domain.enumeration; + +import de.tum.in.www1.artemis.domain.competency.CompetencyProgress; + +/** + * Enum to define the different reasons why the confidence is above/below 1 in the {@link CompetencyProgress}. + * A confidence != 1 leads to a higher/lower mastery, which is displayed to the student together with the reason. + * Also see {@link CompetencyProgress#setConfidenceReason}. + */ +public enum CompetencyProgressConfidenceReason { + NO_REASON, RECENT_SCORES_LOWER, RECENT_SCORES_HIGHER, MORE_EASY_POINTS, MORE_HARD_POINTS, QUICKLY_SOLVED_EXERCISES +} diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lecture/LectureUnit.java b/src/main/java/de/tum/in/www1/artemis/domain/lecture/LectureUnit.java index 0b47e1a41f29..0b2412782e74 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/lecture/LectureUnit.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/lecture/LectureUnit.java @@ -2,7 +2,6 @@ import java.time.ZonedDateTime; import java.util.HashSet; -import java.util.Optional; import java.util.Set; import jakarta.persistence.CascadeType; @@ -148,11 +147,6 @@ public boolean isCompletedFor(User user) { return getCompletedUsers().stream().map(LectureUnitCompletion::getUser).anyMatch(user1 -> user1.getId().equals(user.getId())); } - @Override - public Optional getCompletionDate(User user) { - return getCompletedUsers().stream().filter(completion -> completion.getUser().getId().equals(user.getId())).map(LectureUnitCompletion::getCompletedAt).findFirst(); - } - // Used to distinguish the type when used in a DTO, e.g., LectureUnitForLearningPathNodeDetailsDTO. public abstract String getType(); } diff --git a/src/main/java/de/tum/in/www1/artemis/repository/CompetencyProgressRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/CompetencyProgressRepository.java index f86876bf8b97..4297c1da01ad 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/CompetencyProgressRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/CompetencyProgressRepository.java @@ -72,29 +72,20 @@ default CompetencyProgress findByCompetencyIdAndUserIdOrElseThrow(long competenc """) Set findAllByCompetencyIdsAndUserId(@Param("competencyIds") Set competencyIds, @Param("userId") long userId); - @Query(""" - SELECT AVG(cp.confidence) - FROM CompetencyProgress cp - WHERE cp.competency.id = :competencyId - """) - Optional findAverageConfidenceByCompetencyId(@Param("competencyId") long competencyId); - @Query(""" SELECT COUNT(cp) FROM CompetencyProgress cp WHERE cp.competency.id = :competencyId """) - Long countByCompetency(@Param("competencyId") long competencyId); + long countByCompetency(@Param("competencyId") long competencyId); @Query(""" SELECT COUNT(cp) FROM CompetencyProgress cp WHERE cp.competency.id = :competencyId - AND cp.progress >= :progress - AND cp.confidence >= :confidence + AND cp.progress * cp.confidence >= :masteryThreshold """) - Long countByCompetencyAndProgressAndConfidenceGreaterThanEqual(@Param("competencyId") long competencyId, @Param("progress") double progress, - @Param("confidence") double confidence); + long countByCompetencyAndMastered(@Param("competencyId") long competencyId, @Param("masteryThreshold") int masteryThreshold); @Query(""" SELECT cp diff --git a/src/main/java/de/tum/in/www1/artemis/repository/CompetencyRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/CompetencyRepository.java index 20f6ba12c968..0b33d7ed99e1 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/CompetencyRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/CompetencyRepository.java @@ -18,6 +18,7 @@ import de.tum.in.www1.artemis.domain.Course; import de.tum.in.www1.artemis.domain.competency.Competency; import de.tum.in.www1.artemis.repository.base.ArtemisJpaRepository; +import de.tum.in.www1.artemis.web.rest.dto.metrics.CompetencyExerciseMasteryCalculationDTO; import de.tum.in.www1.artemis.web.rest.errors.EntityNotFoundException; /** @@ -60,15 +61,42 @@ public interface CompetencyRepository extends ArtemisJpaRepository findByIdWithLectureUnitsAndCompletions(@Param("competencyId") long competencyId); + /** + * Fetches all information related to the calculation of the mastery for exercises in a competency. + * The complex grouping by is necessary for postgres + * + * @param competencyId the id of the competency for which to fetch the exercise information + * @return the exercise information for the calculation of the mastery in the competency + */ + @Query(""" + SELECT new de.tum.in.www1.artemis.web.rest.dto.metrics.CompetencyExerciseMasteryCalculationDTO( + ex.maxPoints, + ex.difficulty, + CASE WHEN TYPE(ex) = ProgrammingExercise THEN TRUE ELSE FALSE END, + COALESCE(sS.lastScore, tS.lastScore), + COALESCE(sS.lastPoints, tS.lastPoints), + COALESCE(sS.lastModifiedDate, tS.lastModifiedDate), + COUNT(s) + ) + FROM Competency c + LEFT JOIN c.exercises ex + LEFT JOIN ex.studentParticipations sp + LEFT JOIN sp.submissions s + LEFT JOIN StudentScore sS ON sS.exercise = ex + LEFT JOIN TeamScore tS ON tS.exercise = ex + WHERE c.id = :competencyId + AND ex IS NOT NULL + GROUP BY ex.maxPoints, ex.difficulty, TYPE(ex), sS.lastScore, tS.lastScore, sS.lastPoints, tS.lastPoints, sS.lastModifiedDate, tS.lastModifiedDate + """) + Set findAllExerciseInfoByCompetencyId(@Param("competencyId") long competencyId); + @Query(""" SELECT c FROM Competency c - LEFT JOIN FETCH c.exercises - LEFT JOIN FETCH c.lectureUnits lu - LEFT JOIN FETCH lu.completedUsers + LEFT JOIN FETCH c.exercises ex WHERE c.id = :competencyId """) - Optional findByIdWithExercisesAndLectureUnitsAndCompletions(@Param("competencyId") long competencyId); + Optional findByIdWithExercises(@Param("competencyId") long competencyId); @Query(""" SELECT c @@ -191,6 +219,10 @@ default Competency findByIdWithLectureUnitsAndCompletionsElseThrow(long competen return findByIdWithLectureUnitsAndCompletions(competencyId).orElseThrow(() -> new EntityNotFoundException("Competency", competencyId)); } + default Competency findByIdWithExercisesElseThrow(long competencyId) { + return findByIdWithExercises(competencyId).orElseThrow(() -> new EntityNotFoundException("Competency", competencyId)); + } + default Competency findByIdWithExercisesAndLectureUnitsBidirectionalElseThrow(long competencyId) { return findByIdWithExercisesAndLectureUnitsBidirectional(competencyId).orElseThrow(() -> new EntityNotFoundException("Competency", competencyId)); } diff --git a/src/main/java/de/tum/in/www1/artemis/repository/LectureUnitCompletionRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/LectureUnitCompletionRepository.java index ec2650fe051a..d1bda968a511 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/LectureUnitCompletionRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/LectureUnitCompletionRepository.java @@ -37,4 +37,10 @@ public interface LectureUnitCompletionRepository extends ArtemisJpaRepository findByLectureUnitsAndUserId(@Param("lectureUnits") Collection lectureUnits, @Param("userId") Long userId); + @Query(""" + SELECT COUNT(lectureUnitCompletion) + FROM LectureUnitCompletion lectureUnitCompletion + WHERE lectureUnitCompletion.lectureUnit.id IN :lectureUnitIds + """) + int countByLectureUnitIds(@Param("lectureUnitIds") Collection lectureUnitIds); } diff --git a/src/main/java/de/tum/in/www1/artemis/repository/metrics/ExerciseMetricsRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/metrics/ExerciseMetricsRepository.java index 4aad3df2422f..a58dcae08cdf 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/metrics/ExerciseMetricsRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/metrics/ExerciseMetricsRepository.java @@ -10,6 +10,7 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import de.tum.in.www1.artemis.config.Constants; import de.tum.in.www1.artemis.domain.Exercise; import de.tum.in.www1.artemis.repository.base.ArtemisJpaRepository; import de.tum.in.www1.artemis.web.rest.dto.metrics.ExerciseInformationDTO; @@ -138,18 +139,20 @@ CASE WHEN TYPE(e) = ProgrammingExercise THEN TREAT(e AS ProgrammingExercise).all * * @param userId the id of the user * @param exerciseIds the ids of the exercises + * @param minScore the minimum score required to consider an exercise as completed, normally {@link Constants#MIN_SCORE_GREEN } * @return the ids of the completed exercises for the user in the exercises */ @Query(""" SELECT e.id - FROM Exercise e - LEFT JOIN e.studentParticipations p - LEFT JOIN e.teams t - LEFT JOIN t.students u - WHERE e.id IN :exerciseIds - AND (p.student.id = :userId OR u.id = :userId) + FROM ParticipantScore p + LEFT JOIN p.exercise e + LEFT JOIN TREAT (p AS StudentScore).user u + LEFT JOIN TREAT (p AS TeamScore).team.students s + WHERE (u.id = :userId OR s.id = :userId) + AND p.exercise.id IN :exerciseIds + AND COALESCE(p.lastRatedScore, p.lastScore, 0) >= :minScore """) - Set findAllCompletedExerciseIdsForUserByExerciseIds(@Param("userId") long userId, @Param("exerciseIds") Set exerciseIds); + Set findAllCompletedExerciseIdsForUserByExerciseIds(@Param("userId") long userId, @Param("exerciseIds") Set exerciseIds, @Param("minScore") double minScore); /** * Get the ids of the teams the user is in for a set of exercises. diff --git a/src/main/java/de/tum/in/www1/artemis/service/LearningObjectService.java b/src/main/java/de/tum/in/www1/artemis/service/LearningObjectService.java index d21e78d7a877..789c83ce453d 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/LearningObjectService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/LearningObjectService.java @@ -1,5 +1,6 @@ package de.tum.in.www1.artemis.service; +import static de.tum.in.www1.artemis.config.Constants.MIN_SCORE_GREEN; import static de.tum.in.www1.artemis.config.Constants.PROFILE_CORE; import java.util.Set; @@ -13,7 +14,6 @@ import de.tum.in.www1.artemis.domain.LearningObject; import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.domain.lecture.LectureUnit; -import de.tum.in.www1.artemis.domain.lecture.LectureUnitCompletion; /** * Service implementation for interactions with learning objects. @@ -42,12 +42,10 @@ public LearningObjectService(ParticipantScoreService participantScoreService) { * @return true if the user completed the lecture unit or has at least one result for the exercise, false otherwise */ public boolean isCompletedByUser(@NotNull LearningObject learningObject, @NotNull User user) { - if (learningObject instanceof LectureUnit lectureUnit) { - return lectureUnit.getCompletedUsers().stream().map(LectureUnitCompletion::getUser).anyMatch(user1 -> user1.getId().equals(user.getId())); - } - else if (learningObject instanceof Exercise exercise) { - return participantScoreService.getStudentAndTeamParticipations(user, Set.of(exercise)).findAny().isPresent(); - } - throw new IllegalArgumentException("Learning object must be either LectureUnit or Exercise"); + return switch (learningObject) { + case LectureUnit lectureUnit -> lectureUnit.isCompletedFor(user); + case Exercise exercise -> participantScoreService.getStudentAndTeamParticipations(user, Set.of(exercise)).anyMatch(score -> score.getLastScore() >= MIN_SCORE_GREEN); + default -> throw new IllegalArgumentException("Learning object must be either LectureUnit or Exercise"); + }; } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/ParticipantScoreService.java b/src/main/java/de/tum/in/www1/artemis/service/ParticipantScoreService.java index 6517dbaea122..e02dff650692 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/ParticipantScoreService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/ParticipantScoreService.java @@ -27,6 +27,7 @@ import de.tum.in.www1.artemis.domain.exam.Exam; import de.tum.in.www1.artemis.domain.exam.ExerciseGroup; import de.tum.in.www1.artemis.domain.scores.ParticipantScore; +import de.tum.in.www1.artemis.repository.ParticipantScoreRepository; import de.tum.in.www1.artemis.repository.StudentScoreRepository; import de.tum.in.www1.artemis.repository.TeamRepository; import de.tum.in.www1.artemis.repository.TeamScoreRepository; @@ -52,14 +53,18 @@ public class ParticipantScoreService { private final TeamRepository teamRepository; + private final ParticipantScoreRepository participantScoreRepository; + public ParticipantScoreService(UserRepository userRepository, StudentScoreRepository studentScoreRepository, TeamScoreRepository teamScoreRepository, - GradingScaleService gradingScaleService, PresentationPointsCalculationService presentationPointsCalculationService, TeamRepository teamRepository) { + GradingScaleService gradingScaleService, PresentationPointsCalculationService presentationPointsCalculationService, TeamRepository teamRepository, + ParticipantScoreRepository participantScoreRepository) { this.userRepository = userRepository; this.studentScoreRepository = studentScoreRepository; this.teamScoreRepository = teamScoreRepository; this.gradingScaleService = gradingScaleService; this.presentationPointsCalculationService = presentationPointsCalculationService; this.teamRepository = teamRepository; + this.participantScoreRepository = participantScoreRepository; } /** @@ -186,24 +191,29 @@ public Stream getStudentAndTeamParticipations(User user, Set getStudentAndTeamParticipationScores(User user, Set exercises) { - return getStudentAndTeamParticipations(user, exercises).map(ParticipantScore::getLastScore); + public Stream getStudentAndTeamParticipationPoints(User user, Set exercises) { + return getStudentAndTeamParticipations(user, exercises).map(ParticipantScore::getLastPoints); } /** - * Gets all participation scores of the exercises for a user. + * Gets all achieved points of the exercises for a user. * * @param user the user whose scores should be fetched * @param exercises the exercises the scores should be fetched from - * @return stream of participant latest scores + * @return stream of achieved latest points */ - public DoubleStream getStudentAndTeamParticipationScoresAsDoubleStream(User user, Set exercises) { - return getStudentAndTeamParticipationScores(user, exercises).mapToDouble(Double::doubleValue); + public DoubleStream getStudentAndTeamParticipationPointsAsDoubleStream(User user, Set exercises) { + return getStudentAndTeamParticipationPoints(user, exercises).mapToDouble(Double::doubleValue); + } + + public double getAverageOfAverageScores(Set exercises) { + return participantScoreRepository.findAverageScoreForExercises(exercises).stream().mapToDouble(exerciseInfo -> (double) exerciseInfo.get("averageScore")).average() + .orElse(0.0); } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/competency/CompetencyProgressService.java b/src/main/java/de/tum/in/www1/artemis/service/competency/CompetencyProgressService.java index 5ff05c5a77af..809e5e3d9569 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/competency/CompetencyProgressService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/competency/CompetencyProgressService.java @@ -1,9 +1,11 @@ package de.tum.in.www1.artemis.service.competency; +import static de.tum.in.www1.artemis.config.Constants.MIN_SCORE_GREEN; import static de.tum.in.www1.artemis.config.Constants.PROFILE_CORE; +import static de.tum.in.www1.artemis.service.util.TimeUtil.toRelativeTime; import java.time.Instant; -import java.util.HashSet; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -22,20 +24,23 @@ import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.domain.competency.Competency; import de.tum.in.www1.artemis.domain.competency.CompetencyProgress; +import de.tum.in.www1.artemis.domain.enumeration.CompetencyProgressConfidenceReason; +import de.tum.in.www1.artemis.domain.enumeration.DifficultyLevel; import de.tum.in.www1.artemis.domain.lecture.ExerciseUnit; import de.tum.in.www1.artemis.domain.lecture.LectureUnit; import de.tum.in.www1.artemis.domain.participation.Participant; import de.tum.in.www1.artemis.repository.CompetencyProgressRepository; import de.tum.in.www1.artemis.repository.CompetencyRepository; import de.tum.in.www1.artemis.repository.ExerciseRepository; +import de.tum.in.www1.artemis.repository.LectureUnitCompletionRepository; import de.tum.in.www1.artemis.repository.LectureUnitRepository; import de.tum.in.www1.artemis.repository.UserRepository; import de.tum.in.www1.artemis.security.SecurityUtils; -import de.tum.in.www1.artemis.service.LearningObjectService; import de.tum.in.www1.artemis.service.ParticipantScoreService; import de.tum.in.www1.artemis.service.learningpath.LearningPathService; import de.tum.in.www1.artemis.service.util.RoundingUtil; import de.tum.in.www1.artemis.web.rest.dto.CourseCompetencyProgressDTO; +import de.tum.in.www1.artemis.web.rest.dto.metrics.CompetencyExerciseMasteryCalculationDTO; /** * Service for calculating the progress of a student in a competency. @@ -60,11 +65,21 @@ public class CompetencyProgressService { private final ParticipantScoreService participantScoreService; - private final LearningObjectService learningObjectService; + private final LectureUnitCompletionRepository lectureUnitCompletionRepository; + + private static final int MIN_EXERCISES_RECENCY_CONFIDENCE = 3; + + private static final int MAX_SUBMISSIONS_FOR_QUICK_SOLVE_HEURISTIC = 3; + + private static final double DEFAULT_CONFIDENCE = 1.0; + + private static final double MAX_CONFIDENCE_HEURISTIC = 0.25; + + private static final double CONFIDENCE_REASON_DEADZONE = 0.05; public CompetencyProgressService(CompetencyRepository competencyRepository, CompetencyProgressRepository competencyProgressRepository, ExerciseRepository exerciseRepository, LectureUnitRepository lectureUnitRepository, UserRepository userRepository, LearningPathService learningPathService, ParticipantScoreService participantScoreService, - LearningObjectService learningObjectService) { + LectureUnitCompletionRepository lectureUnitCompletionRepository) { this.competencyRepository = competencyRepository; this.competencyProgressRepository = competencyProgressRepository; this.exerciseRepository = exerciseRepository; @@ -72,7 +87,7 @@ public CompetencyProgressService(CompetencyRepository competencyRepository, Comp this.userRepository = userRepository; this.learningPathService = learningPathService; this.participantScoreService = participantScoreService; - this.learningObjectService = learningObjectService; + this.lectureUnitCompletionRepository = lectureUnitCompletionRepository; } /** @@ -96,14 +111,10 @@ public void updateProgressByLearningObjectAsync(LearningObject learningObject, @ public void updateProgressByLearningObjectAsync(LearningObject learningObject) { SecurityUtils.setAuthorizationObject(); // required for async Course course; - if (learningObject instanceof Exercise exercise) { - course = exercise.getCourseViaExerciseGroupOrCourseMember(); - } - else if (learningObject instanceof LectureUnit lectureUnit) { - course = lectureUnit.getLecture().getCourse(); - } - else { - throw new IllegalArgumentException("Learning object must be either LectureUnit or Exercise"); + switch (learningObject) { + case Exercise exercise -> course = exercise.getCourseViaExerciseGroupOrCourseMember(); + case LectureUnit lectureUnit -> course = lectureUnit.getLecture().getCourse(); + default -> throw new IllegalArgumentException("Learning object must be either LectureUnit or Exercise"); } updateProgressByLearningObject(learningObject, userRepository.getStudents(course)); } @@ -130,14 +141,10 @@ public void updateProgressByLearningObject(LearningObject learningObject, @NotNu log.debug("Updating competency progress for {} users.", users.size()); try { Set competencies; - if (learningObject instanceof Exercise exercise) { - competencies = exerciseRepository.findWithCompetenciesById(exercise.getId()).map(Exercise::getCompetencies).orElse(null); - } - else if (learningObject instanceof LectureUnit lectureUnit) { - competencies = lectureUnitRepository.findWithCompetenciesById(lectureUnit.getId()).map(LectureUnit::getCompetencies).orElse(null); - } - else { - throw new IllegalArgumentException("Learning object must be either LectureUnit or Exercise"); + switch (learningObject) { + case Exercise exercise -> competencies = exerciseRepository.findWithCompetenciesById(exercise.getId()).map(Exercise::getCompetencies).orElse(null); + case LectureUnit lectureUnit -> competencies = lectureUnitRepository.findWithCompetenciesById(lectureUnit.getId()).map(LectureUnit::getCompetencies).orElse(null); + default -> throw new IllegalArgumentException("Learning object must be either LectureUnit or Exercise"); } if (competencies == null) { @@ -154,20 +161,26 @@ else if (learningObject instanceof LectureUnit lectureUnit) { } /** - * Updates the progress value (and confidence score) of the given competency and user, then returns it + * Updates the progress value and confidence score of the given competency and user, then returns it * * @param competencyId The id of the competency to update the progress for * @param user The user for which the progress should be updated * @return The updated competency progress, which is also persisted to the database */ public CompetencyProgress updateCompetencyProgress(Long competencyId, User user) { - var competency = competencyRepository.findByIdWithExercisesAndLectureUnitsAndCompletions(competencyId).orElse(null); + Optional optionalCompetency = competencyRepository.findByIdWithLectureUnits(competencyId); - if (user == null || competency == null) { + if (user == null || optionalCompetency.isEmpty()) { log.debug("User or competency no longer exist, skipping."); return null; } + Competency competency = optionalCompetency.get(); + Set lectureUnits = competency.getLectureUnits().stream().filter(lectureUnit -> !(lectureUnit instanceof ExerciseUnit)).collect(Collectors.toSet()); + Set exerciseInfos = competencyRepository.findAllExerciseInfoByCompetencyId(competencyId); + int numberOfCompletedLectureUnits = lectureUnitCompletionRepository + .countByLectureUnitIds(competency.getLectureUnits().stream().map(LectureUnit::getId).collect(Collectors.toSet())); + var competencyProgress = competencyProgressRepository.findEagerByCompetencyIdAndUserId(competencyId, user.getId()); if (competencyProgress.isPresent()) { @@ -179,28 +192,12 @@ public CompetencyProgress updateCompetencyProgress(Long competencyId, User user) } var studentProgress = competencyProgress.orElse(new CompetencyProgress()); - Set learningObjects = new HashSet<>(); - - Set allLectureUnits = competency.getLectureUnits().stream().filter(LectureUnit::isVisibleToStudents).collect(Collectors.toSet()); - - Set lectureUnits = allLectureUnits.stream().filter(lectureUnit -> !(lectureUnit instanceof ExerciseUnit)).collect(Collectors.toSet()); - Set exercises = competency.getExercises().stream().filter(Exercise::isVisibleToStudents).collect(Collectors.toSet()); - - learningObjects.addAll(lectureUnits); - learningObjects.addAll(exercises); - - var progress = RoundingUtil.roundScoreSpecifiedByCourseSettings(calculateProgress(learningObjects, user), competency.getCourse()); - var confidence = RoundingUtil.roundScoreSpecifiedByCourseSettings(calculateConfidence(exercises, user), competency.getCourse()); - if (exercises.isEmpty()) { - // If the competency has no exercises, the confidence score equals the progress - confidence = progress; - } + calculateProgress(lectureUnits, exerciseInfos, numberOfCompletedLectureUnits, studentProgress); + calculateConfidence(exerciseInfos, studentProgress); studentProgress.setCompetency(competency); studentProgress.setUser(user); - studentProgress.setProgress(progress); - studentProgress.setConfidence(confidence); try { competencyProgressRepository.save(studentProgress); @@ -217,26 +214,200 @@ public CompetencyProgress updateCompetencyProgress(Long competencyId, User user) } /** - * Calculate the progress value for the given user in a competency. + * Calculate the progress for the given user in a competency. + * The progress for exercises is the percentage of points achieved in all exercises linked to the competency. + * The progress for lecture units is the percentage of lecture units completed by the user. + * The final progress is a weighted average of the progress in exercises and lecture units. + * + * @param lectureUnits The lecture units linked to the competency + * @param exerciseInfos The information about the exercises linked to the competency + * @param numberOfCompletedLectureUnits The number of lecture units completed by the user + * @param competencyProgress The progress entity to update + */ + private void calculateProgress(Set lectureUnits, Set exerciseInfos, int numberOfCompletedLectureUnits, + CompetencyProgress competencyProgress) { + double numberOfLearningObjects = lectureUnits.size() + exerciseInfos.size(); + if (numberOfLearningObjects == 0) { + // If nothing is linked to the competency, the competency is considered completed + competencyProgress.setProgress(100.0); + } + + double achievedPoints = exerciseInfos.stream().mapToDouble(info -> info.lastPoints() != null ? info.lastPoints() : 0).sum(); + double maxPoints = exerciseInfos.stream().mapToDouble(CompetencyExerciseMasteryCalculationDTO::maxPoints).sum(); + double exerciseProgress = maxPoints > 0 ? achievedPoints / maxPoints * 100 : 0; + + double lectureProgress = 100.0 * numberOfCompletedLectureUnits / lectureUnits.size(); + + double weightedExerciseProgress = exerciseInfos.size() / numberOfLearningObjects * exerciseProgress; + double weightedLectureProgress = lectureUnits.size() / numberOfLearningObjects * lectureProgress; + + double progress = weightedExerciseProgress + weightedLectureProgress; + // Bonus points can lead to a progress > 100% + progress = Math.clamp(Math.round(progress), 0, 100); + competencyProgress.setProgress(progress); + } + + /** + * Calculate the confidence score for the given user in a competency based on the exercises linked to the competency. + * + * @param exerciseInfos The information about the exercises linked to the competency + * @param competencyProgress The progress entity to update + */ + private void calculateConfidence(Set exerciseInfos, CompetencyProgress competencyProgress) { + Set participantScoreInfos = exerciseInfos.stream() + .filter(info -> info.lastScore() != null && info.lastPoints() != null && info.lastModifiedDate() != null).collect(Collectors.toSet()); + + double recencyConfidenceHeuristic = calculateRecencyConfidenceHeuristic(participantScoreInfos); + double difficultyConfidenceHeuristic = calculateDifficultyConfidenceHeuristic(participantScoreInfos, exerciseInfos); + double quickSolveConfidenceHeuristic = calculateQuickSolveConfidenceHeuristic(participantScoreInfos); + + // Standard factor of 1 (no change to mastery compared to progress) plus the confidence heuristics + double confidence = DEFAULT_CONFIDENCE + recencyConfidenceHeuristic + difficultyConfidenceHeuristic + quickSolveConfidenceHeuristic; + + competencyProgress.setConfidence(confidence); + setConfidenceReason(competencyProgress, recencyConfidenceHeuristic, difficultyConfidenceHeuristic, quickSolveConfidenceHeuristic); + } + + /** + * Calculate the recency confidence heuristic for the given user in a competency based on the exercises linked to the competency. + * If the recent scores are higher than the average scores, the confidence should also be higher and vice versa. + * + * @param participantScores the participant scores for the exercises linked to the competency + * @return The recency confidence heuristic + */ + private double calculateRecencyConfidenceHeuristic(@NotNull Set participantScores) { + if (participantScores.size() < MIN_EXERCISES_RECENCY_CONFIDENCE) { + return 0; + } + + Instant earliestScoreDate = participantScores.stream().map(CompetencyExerciseMasteryCalculationDTO::lastModifiedDate).min(Instant::compareTo).get(); + Instant latestScoreDate = participantScores.stream().map(CompetencyExerciseMasteryCalculationDTO::lastModifiedDate).max(Instant::compareTo).get(); + + double doubleWeightedScoreSum = participantScores.stream() + .mapToDouble(info -> info.lastScore() * toRelativeTime(earliestScoreDate, latestScoreDate, info.lastModifiedDate())).sum(); + double weightSum = participantScores.stream().mapToDouble(info -> toRelativeTime(earliestScoreDate, latestScoreDate, info.lastModifiedDate())).sum(); + double weightedAverageScore = doubleWeightedScoreSum / weightSum; + + double averageScore = participantScores.stream().mapToDouble(CompetencyExerciseMasteryCalculationDTO::lastScore).average().orElse(0.0); + + double recencyConfidence = weightedAverageScore - averageScore; + + return Math.clamp(recencyConfidence, -MAX_CONFIDENCE_HEURISTIC, MAX_CONFIDENCE_HEURISTIC); + } + + /** + * Calculate the difficulty confidence heuristic for the given user in a competency based on the exercises linked to the competency. + * If the proportion of achieved points in hard exercises is higher than the proportion of hard points in the competency, the confidence should be higher and vice versa. + * If the proportion of achieved points in easy exercises is higher than the proportion of easy points in the competency, the confidence should be lower and vice versa. + * + * @param participantScores the participant scores for the exercises linked to the competency + * @param exerciseInfos The information about the exercises linked to the competency + * @return The difficulty confidence heuristic + */ + private double calculateDifficultyConfidenceHeuristic(@NotNull Set participantScores, + @NotNull Set exerciseInfos) { + if (participantScores.isEmpty()) { + return 0; + } + + double achievedPoints = participantScores.stream().mapToDouble(CompetencyExerciseMasteryCalculationDTO::lastPoints).sum(); + double pointsInCompetency = exerciseInfos.stream().mapToDouble(CompetencyExerciseMasteryCalculationDTO::maxPoints).sum(); + + if (achievedPoints == 0 || pointsInCompetency == 0) { + return 0; + } + + double easyConfidence = calculateDifficultyConfidenceHeuristicForDifficulty(participantScores, exerciseInfos, achievedPoints, pointsInCompetency, DifficultyLevel.EASY); + double hardConfidence = calculateDifficultyConfidenceHeuristicForDifficulty(participantScores, exerciseInfos, achievedPoints, pointsInCompetency, DifficultyLevel.HARD); + + double difficultyConfidence = hardConfidence - easyConfidence; + + return Math.clamp(difficultyConfidence, -MAX_CONFIDENCE_HEURISTIC, MAX_CONFIDENCE_HEURISTIC); + } + + /** + * Calculate the difficulty confidence heuristic for the given user and difficulty in a competency based on the exercises linked to the competency. + * If the proportion of achieved points in the given difficulty is higher than the proportion of points in the competency, the confidence should be higher. + * + * @param participantScores the participant scores for the exercises linked to the competency + * @param exerciseInfos the information about the exercises linked to the competency + * @param achievedPoints the total points achieved by the user in the competency + * @param pointsInCompetency the total points in the competency + * @param difficultyLevel the difficulty level to calculate the confidence for + * @return the difficulty confidence heuristic for the given difficulty + */ + private double calculateDifficultyConfidenceHeuristicForDifficulty(@NotNull Set participantScores, + @NotNull Set exerciseInfos, double achievedPoints, double pointsInCompetency, DifficultyLevel difficultyLevel) { + + double achievedPointsInDifficulty = participantScores.stream().filter(info -> info.difficulty() == difficultyLevel) + .mapToDouble(CompetencyExerciseMasteryCalculationDTO::lastPoints).sum(); + double pointsInCompetencyInDifficulty = exerciseInfos.stream().filter(info -> info.difficulty() == difficultyLevel) + .mapToDouble(CompetencyExerciseMasteryCalculationDTO::maxPoints).sum(); + + double proportionOfAchievedPointsInDifficulty = achievedPointsInDifficulty / achievedPoints; + double proportionOfPointsInCompetencyInDifficulty = pointsInCompetencyInDifficulty / pointsInCompetency; + + return proportionOfAchievedPointsInDifficulty - proportionOfPointsInCompetencyInDifficulty; + } + + /** + * Calculate the quick solve confidence heuristic for the given user in a competency based on the exercises linked to the competency. + * If the user has solved a high proportion of programming exercises with a high score in a short amount of time, the confidence should be higher. * - * @param learningObjects A list of all learning objects linked to a specific competency - * @param user The user for which the progress should be calculated - * @return The percentage of completed learning objects by the user + * @param participantScores the participant scores for the exercises linked to the competency + * @return The quick solve confidence heuristic */ - private double calculateProgress(@NotNull Set learningObjects, @NotNull User user) { - return learningObjects.stream().map(learningObject -> learningObjectService.isCompletedByUser(learningObject, user)).mapToInt(completed -> completed ? 100 : 0).average() - .orElse(0.); + private double calculateQuickSolveConfidenceHeuristic(@NotNull Set participantScores) { + Set programmingParticipationScores = participantScores.stream() + .filter(CompetencyExerciseMasteryCalculationDTO::isProgrammingExercise).collect(Collectors.toSet()); + + if (programmingParticipationScores.isEmpty()) { + return 0; + } + + double numberOfQuicklySolvedProgrammingExercises = programmingParticipationScores.stream() + .filter(info -> info.lastScore() >= MIN_SCORE_GREEN && info.submissionCount() <= MAX_SUBMISSIONS_FOR_QUICK_SOLVE_HEURISTIC).count(); + + double quickSolveConfidence = numberOfQuicklySolvedProgrammingExercises / programmingParticipationScores.size(); + + return Math.clamp(quickSolveConfidence, -MAX_CONFIDENCE_HEURISTIC, MAX_CONFIDENCE_HEURISTIC); } /** - * Calculate the confidence score for the given user in a competency. + * Find most important heuristic that influences the confidence score and set the confidence reason accordingly. + * If the confidence does not deviate significantly from 1, the reason is set to NO_REASON. * - * @param exercises A list of all exercises linked to a specific competency - * @param user The user for which the confidence score should be calculated - * @return The average score of the user in all exercises linked to the competency + * @param competencyProgress the progress entity add the confidence reason to + * @param recencyConfidence the recency confidence heuristic + * @param difficultyConfidence the difficulty confidence heuristic + * @param quickSolveConfidence the quick solve confidence heuristic */ - private double calculateConfidence(@NotNull Set exercises, @NotNull User user) { - return participantScoreService.getStudentAndTeamParticipationScoresAsDoubleStream(user, exercises).summaryStatistics().getAverage(); + private void setConfidenceReason(CompetencyProgress competencyProgress, double recencyConfidence, double difficultyConfidence, double quickSolveConfidence) { + if (competencyProgress.getConfidence() < DEFAULT_CONFIDENCE - CONFIDENCE_REASON_DEADZONE) { + double minConfidenceHeuristic = Math.min(recencyConfidence, difficultyConfidence); + if (recencyConfidence == minConfidenceHeuristic) { + competencyProgress.setConfidenceReason(CompetencyProgressConfidenceReason.RECENT_SCORES_LOWER); + } + else { + // quickSolveConfidence cannot be negative therefore we don't check it here + competencyProgress.setConfidenceReason(CompetencyProgressConfidenceReason.MORE_EASY_POINTS); + } + } + else if (competencyProgress.getConfidence() > DEFAULT_CONFIDENCE + CONFIDENCE_REASON_DEADZONE) { + double maxConfidenceHeuristic = Math.max(recencyConfidence, Math.max(difficultyConfidence, quickSolveConfidence)); + if (recencyConfidence == maxConfidenceHeuristic) { + competencyProgress.setConfidenceReason(CompetencyProgressConfidenceReason.RECENT_SCORES_HIGHER); + } + else if (difficultyConfidence == maxConfidenceHeuristic) { + competencyProgress.setConfidenceReason(CompetencyProgressConfidenceReason.MORE_HARD_POINTS); + } + else { + competencyProgress.setConfidenceReason(CompetencyProgressConfidenceReason.QUICKLY_SOLVED_EXERCISES); + } + } + else { + competencyProgress.setConfidenceReason(CompetencyProgressConfidenceReason.NO_REASON); + } } /** @@ -246,9 +417,7 @@ private double calculateConfidence(@NotNull Set exercises, @NotNull Us * @return The mastery level */ public static double getMastery(@NotNull CompetencyProgress competencyProgress) { - // mastery as a weighted function of progress and confidence (consistent with client) - final double weight = 2.0 / 3.0; - return (1 - weight) * competencyProgress.getProgress() + weight * competencyProgress.getConfidence(); + return Math.clamp(competencyProgress.getProgress() * competencyProgress.getConfidence(), 0, 100); } /** @@ -280,10 +449,15 @@ public static boolean isMastered(@NotNull CompetencyProgress competencyProgress) * @return true if the competency can be mastered without completing any exercises, false otherwise */ public static boolean canBeMasteredWithoutExercises(@NotNull Competency competency) { - final var lectureUnits = competency.getLectureUnits().size(); - final var numberOfLearningObjects = lectureUnits + competency.getExercises().size(); - final var achievableMasteryScore = ((double) lectureUnits) / (3 * numberOfLearningObjects) * 100; - return achievableMasteryScore >= competency.getMasteryThreshold(); + double numberOfLectureUnits = competency.getLectureUnits().size(); + double numberOfLearningObjects = numberOfLectureUnits + competency.getExercises().size(); + if (numberOfLearningObjects == 0) { + return true; + } + + double achievableProgressScore = numberOfLectureUnits / numberOfLearningObjects * 100; + // Without exercises, the confidence score is 1 and the mastery is equal to the progress + return achievableProgressScore >= competency.getMasteryThreshold(); } /** @@ -304,10 +478,8 @@ public void deleteProgressForCompetency(long competencyId) { */ public CourseCompetencyProgressDTO getCompetencyCourseProgress(@NotNull Competency competency, @NotNull Course course) { var numberOfStudents = competencyProgressRepository.countByCompetency(competency.getId()); - var numberOfMasteredStudents = competencyProgressRepository.countByCompetencyAndProgressAndConfidenceGreaterThanEqual(competency.getId(), 100.0, - competency.getMasteryThreshold()); - var averageStudentScore = RoundingUtil.roundScoreSpecifiedByCourseSettings(competencyProgressRepository.findAverageConfidenceByCompetencyId(competency.getId()).orElse(0.0), - course); + var numberOfMasteredStudents = competencyProgressRepository.countByCompetencyAndMastered(competency.getId(), competency.getMasteryThreshold()); + var averageStudentScore = RoundingUtil.roundScoreSpecifiedByCourseSettings(participantScoreService.getAverageOfAverageScores(competency.getExercises()), course); return new CourseCompetencyProgressDTO(competency.getId(), numberOfStudents, numberOfMasteredStudents, averageStudentScore); } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/PyrisDTOService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/PyrisDTOService.java index d35784c4f54e..9b3dfc9caefc 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/PyrisDTOService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/PyrisDTOService.java @@ -1,6 +1,6 @@ package de.tum.in.www1.artemis.service.connectors.pyris; -import static de.tum.in.www1.artemis.service.util.ZonedDateTimeUtil.toInstant; +import static de.tum.in.www1.artemis.service.util.TimeUtil.toInstant; import java.util.List; import java.util.Map; diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisCompetencyDTO.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisCompetencyDTO.java index a462b9323c38..a64aae5d3075 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisCompetencyDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisCompetencyDTO.java @@ -1,6 +1,6 @@ package de.tum.in.www1.artemis.service.connectors.pyris.dto.data; -import static de.tum.in.www1.artemis.service.util.ZonedDateTimeUtil.toInstant; +import static de.tum.in.www1.artemis.service.util.TimeUtil.toInstant; import java.time.Instant; diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisExamDTO.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisExamDTO.java index b7d2046c7ecf..62b4442061e4 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisExamDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisExamDTO.java @@ -1,6 +1,6 @@ package de.tum.in.www1.artemis.service.connectors.pyris.dto.data; -import static de.tum.in.www1.artemis.service.util.ZonedDateTimeUtil.toInstant; +import static de.tum.in.www1.artemis.service.util.TimeUtil.toInstant; import java.time.Instant; diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisExerciseWithStudentSubmissionsDTO.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisExerciseWithStudentSubmissionsDTO.java index 31224c8c92fc..93da6e73a7fa 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisExerciseWithStudentSubmissionsDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisExerciseWithStudentSubmissionsDTO.java @@ -1,6 +1,6 @@ package de.tum.in.www1.artemis.service.connectors.pyris.dto.data; -import static de.tum.in.www1.artemis.service.util.ZonedDateTimeUtil.toInstant; +import static de.tum.in.www1.artemis.service.util.TimeUtil.toInstant; import java.time.Instant; import java.util.Optional; diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisExtendedCourseDTO.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisExtendedCourseDTO.java index 257ff62b5922..d982942d6d97 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisExtendedCourseDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisExtendedCourseDTO.java @@ -1,6 +1,6 @@ package de.tum.in.www1.artemis.service.connectors.pyris.dto.data; -import static de.tum.in.www1.artemis.service.util.ZonedDateTimeUtil.toInstant; +import static de.tum.in.www1.artemis.service.util.TimeUtil.toInstant; import java.time.Instant; import java.util.List; diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisMessageDTO.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisMessageDTO.java index bc8665eacafa..7e2811b7c042 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisMessageDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/pyris/dto/data/PyrisMessageDTO.java @@ -1,6 +1,6 @@ package de.tum.in.www1.artemis.service.connectors.pyris.dto.data; -import static de.tum.in.www1.artemis.service.util.ZonedDateTimeUtil.toInstant; +import static de.tum.in.www1.artemis.service.util.TimeUtil.toInstant; import java.time.Instant; import java.util.List; diff --git a/src/main/java/de/tum/in/www1/artemis/service/learningpath/LearningPathNgxService.java b/src/main/java/de/tum/in/www1/artemis/service/learningpath/LearningPathNgxService.java index 2d39d54305a4..4f7bc39c1d5f 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/learningpath/LearningPathNgxService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/learningpath/LearningPathNgxService.java @@ -95,8 +95,8 @@ private void generateNgxGraphRepresentationForCompetency(LearningPath learningPa }); // generate nodes and edges for exercises competency.getExercises().forEach(exercise -> { - currentCluster.add(NgxLearningPathDTO.Node.of(getExerciseNodeId(competency.getId(), exercise.getId()), NgxLearningPathDTO.NodeType.EXERCISE, exercise.getId(), - exercise.isCompletedFor(learningPath.getUser()), exercise.getTitle())); + currentCluster.add(NgxLearningPathDTO.Node.of(getExerciseNodeId(competency.getId(), exercise.getId()), NgxLearningPathDTO.NodeType.EXERCISE, exercise.getId(), false, + exercise.getTitle())); edges.add(new NgxLearningPathDTO.Edge(getExerciseInEdgeId(competency.getId(), exercise.getId()), startNodeId, getExerciseNodeId(competency.getId(), exercise.getId()))); edges.add(new NgxLearningPathDTO.Edge(getExerciseOutEdgeId(competency.getId(), exercise.getId()), getExerciseNodeId(competency.getId(), exercise.getId()), endNodeId)); }); diff --git a/src/main/java/de/tum/in/www1/artemis/service/learningpath/LearningPathRecommendationService.java b/src/main/java/de/tum/in/www1/artemis/service/learningpath/LearningPathRecommendationService.java index d79190ad067a..12594f063cba 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/learningpath/LearningPathRecommendationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/learningpath/LearningPathRecommendationService.java @@ -5,7 +5,6 @@ import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; -import java.util.Arrays; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; @@ -18,16 +17,16 @@ import java.util.stream.Collectors; import java.util.stream.Stream; -import jakarta.validation.constraints.NotNull; - import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; +import com.google.common.util.concurrent.AtomicDouble; + import de.tum.in.www1.artemis.domain.Exercise; import de.tum.in.www1.artemis.domain.LearningObject; import de.tum.in.www1.artemis.domain.Lecture; -import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.domain.competency.Competency; +import de.tum.in.www1.artemis.domain.competency.CompetencyProgress; import de.tum.in.www1.artemis.domain.competency.LearningPath; import de.tum.in.www1.artemis.domain.competency.RelationType; import de.tum.in.www1.artemis.domain.enumeration.DifficultyLevel; @@ -77,8 +76,6 @@ public class LearningPathRecommendationService { */ private static final double MASTERY_PROGRESS_UTILITY = 1; - private static final double SCORE_THRESHOLD = 80; - /** * Lookup table containing the distribution of exercises by difficulty level that should be recommended. *

@@ -402,23 +399,30 @@ public List getRecommendedOrderOfLearningObjects(LearningPath le } final var combinedPriorConfidence = computeCombinedPriorConfidence(competency, state); + final var optionalCompetencyProgress = competency.getUserProgress().stream().findAny(); + final double weightedConfidence; + if (optionalCompetencyProgress.isPresent()) { + final var competencyProgress = optionalCompetencyProgress.get(); + weightedConfidence = (competencyProgress.getProgress() * competencyProgress.getConfidence()) + (1 - competencyProgress.getProgress()) * combinedPriorConfidence; + } + else { + weightedConfidence = combinedPriorConfidence; + } + + final var numberOfRequiredExercisePointsToMaster = calculateNumberOfExercisePointsRequiredToMaster(learningPath, competency, weightedConfidence); + final var pendingExercises = competency.getExercises().stream().filter(exercise -> !learningObjectService.isCompletedByUser(exercise, learningPath.getUser())) .collect(Collectors.toSet()); - final var numberOfExercisesRequiredToMaster = predictNumberOfExercisesRequiredToMaster(learningPath, competency, combinedPriorConfidence, pendingExercises.size()); - Map> difficultyLevelMap = generateDifficultyLevelMap(competency.getExercises()); - if (numberOfExercisesRequiredToMaster >= competency.getExercises().size()) { - scheduleAllExercises(recommendedOrder, difficultyLevelMap); - return recommendedOrder; - } - final var recommendedExerciseDistribution = getRecommendedExerciseDistribution(numberOfExercisesRequiredToMaster, combinedPriorConfidence); - if (Arrays.stream(recommendedExerciseDistribution).sum() >= competency.getExercises().size()) { - // The calculation of the distribution uses the ceiling of the recommendation to schedule sufficiently many exercises required for mastery. - // For competencies with only few exercises, this might cause the number of recommended exercises to surpass the number of linked exercises. + final var pendingExercisePoints = pendingExercises.stream().mapToDouble(Exercise::getMaxPoints).sum(); + + Map> difficultyLevelMap = generateDifficultyLevelMap(pendingExercises); + if (numberOfRequiredExercisePointsToMaster >= pendingExercisePoints) { scheduleAllExercises(recommendedOrder, difficultyLevelMap); return recommendedOrder; } + final var recommendedExerciseDistribution = getRecommendedExercisePointDistribution(numberOfRequiredExercisePointsToMaster, weightedConfidence); - scheduleExercisesByDistribution(recommendedOrder, recommendedExerciseDistribution, learningPath, competency); + scheduleExercisesByDistribution(recommendedOrder, recommendedExerciseDistribution, difficultyLevelMap); return recommendedOrder; } @@ -437,41 +441,35 @@ private void scheduleAllExercises(List recommendedOrder, Map recommendedOrder, int[] recommendedExercisesDistribution, LearningPath learningPath, Competency competency) { - var exerciseCandidates = competency.getExercises().stream().filter(exercise -> !hasScoredAtLeast(exercise, learningPath.getUser(), SCORE_THRESHOLD)) - .collect(Collectors.toSet()); - final var difficultyMap = generateDifficultyLevelMap(exerciseCandidates); + private void scheduleExercisesByDistribution(List recommendedOrder, double[] recommendedExercisePointDistribution, + Map> difficultyMap) { final var easyExercises = new HashSet(); final var mediumExercises = new HashSet(); final var hardExercises = new HashSet(); // choose as many exercises from the correct difficulty level as possible - final var missingEasy = selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.EASY, recommendedExercisesDistribution[0], easyExercises); - final var missingMedium = selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.MEDIUM, recommendedExercisesDistribution[1], mediumExercises); - final var missingHard = selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.HARD, recommendedExercisesDistribution[2], hardExercises); - int numberOfMissingExercises = missingEasy + missingMedium + missingHard; + final var missingEasy = selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.EASY, recommendedExercisePointDistribution[0], easyExercises); + final var missingHard = selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.HARD, recommendedExercisePointDistribution[2], hardExercises); // if there are not sufficiently many exercises per difficulty level, prefer medium difficulty // case 1: no medium exercises available/medium exercises missing: continue to fill with easy/hard exercises // case 2: medium exercises available: no medium exercises missing -> missing exercises must be easy/hard -> in both scenarios medium is the closest difficulty level - if (numberOfMissingExercises > 0 && !difficultyMap.get(DifficultyLevel.MEDIUM).isEmpty()) { - numberOfMissingExercises = selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.MEDIUM, numberOfMissingExercises, mediumExercises); - } + double mediumExercisePoints = recommendedExercisePointDistribution[1] + missingEasy + missingHard; + double numberOfMissingExercisePoints = selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.MEDIUM, mediumExercisePoints, mediumExercises); // if there are still not sufficiently many medium exercises, choose easy difficulty // prefer easy to hard exercises to avoid student overload - if (numberOfMissingExercises > 0 && !difficultyMap.get(DifficultyLevel.EASY).isEmpty()) { - numberOfMissingExercises = selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.EASY, numberOfMissingExercises, easyExercises); + if (numberOfMissingExercisePoints > 0 && !difficultyMap.get(DifficultyLevel.EASY).isEmpty()) { + numberOfMissingExercisePoints = selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.EASY, numberOfMissingExercisePoints, easyExercises); } // fill remaining slots with hard difficulty - if (numberOfMissingExercises > 0) { - selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.HARD, numberOfMissingExercises, hardExercises); + if (numberOfMissingExercisePoints > 0 && !difficultyMap.get(DifficultyLevel.HARD).isEmpty()) { + selectExercisesWithDifficulty(difficultyMap, DifficultyLevel.HARD, numberOfMissingExercisePoints, hardExercises); } recommendedOrder.addAll(easyExercises); @@ -484,18 +482,20 @@ private void scheduleExercisesByDistribution(List recommendedOrd *

* If there are not sufficiently exercises available, the method returns the number of exercises that could not be selected with the particular difficulty. * - * @param difficultyMap a map from difficulty level to a set of corresponding exercises - * @param difficulty the difficulty level that should be chosen - * @param numberOfExercises the number of exercises that should be selected - * @param exercises the set to store the selected exercises - * @return number of exercises that could not be selected + * @param difficultyMap a map from difficulty level to a set of corresponding exercises + * @param difficulty the difficulty level that should be chosen + * @param exercisePoints the amount of exercise points that should be selected + * @param exercises the set to store the selected exercises + * @return amount of points that are missing, if negative the amount of points that are selected too much */ - private static int selectExercisesWithDifficulty(Map> difficultyMap, DifficultyLevel difficulty, int numberOfExercises, + private static double selectExercisesWithDifficulty(Map> difficultyMap, DifficultyLevel difficulty, double exercisePoints, Set exercises) { - var selectedExercises = difficultyMap.get(difficulty).stream().limit(numberOfExercises).collect(Collectors.toSet()); + var remainingExercisePoints = new AtomicDouble(exercisePoints); + var selectedExercises = difficultyMap.get(difficulty).stream().takeWhile(exercise -> remainingExercisePoints.getAndAdd(-exercise.getMaxPoints()) >= 0) + .collect(Collectors.toSet()); exercises.addAll(selectedExercises); difficultyMap.get(difficulty).removeAll(selectedExercises); - return numberOfExercises - selectedExercises.size(); + return remainingExercisePoints.get(); } /** @@ -506,51 +506,46 @@ private static int selectExercisesWithDifficulty(Map c.getUserProgress().stream().findFirst()) - .mapToDouble(competencyProgress -> { - if (competencyProgress.isEmpty()) { - return 0; - } - return competencyProgress.get().getConfidence(); - }).sorted().average().orElse(100); + return state.priorCompetencies.get(competency.getId()).stream().map(state.competencyIdMap::get).flatMap(c -> c.getUserProgress().stream()) + .mapToDouble(CompetencyProgress::getConfidence).sorted().average().orElse(1); } /** - * Predicts the number of exercises required to master the given competency based on prior performance and current progress. + * Predicts the additionally needed exercise points required to master the given competency based on prior performance and current progress. *

* The following formulas are used predict the number of exercises required to master the competency: *

    *
  • Mastery >= MasteryThreshold
  • - *
  • Mastery = 1/3 * Progress + 2/3 * Confidence
  • - *
  • Progress = (#CompletedLearningObjects + #ExercisesRequiredToMaster) / #LearningObjects
  • - *
  • Confidence = (Sum of LatestScores + #ExercisesRequiredToMaster * avg. prior Confidence) / (#LatestScores + #ExercisesRequiredToMaster)
  • + *
  • Mastery = Progress * Confidence {@link CompetencyProgressService#getMastery}
  • + *
  • Progress = (#Exercises / # LearningObjects) * (AchievedPoints / TotalPoints) + #LectureUnits / #LearningObjects {@link CompetencyProgressService#calculateProgress}
  • + *
  • Confidence ≈ 0.9 * weightedConfidence
  • + *
  • RequiredPoints = AchievedPoints - CurrentScore
  • *
- * The formulas are substituted and solved for #ExercisesRequiredToMaster. + * The formulas are substituted and solved for RequiredScore. * - * @param learningPath the learning path for which the prediction should be computed - * @param competency the competency for which the prediction should be computed - * @param priorConfidence the average confidence of all prior competencies - * @param numberOfPendingExercises the number of exercises that have not been completed by the user - * @return the predicted number of exercises required to master the given competency + * @param learningPath the learning path for which the prediction should be computed + * @param competency the competency for which the prediction should be computed + * @param weightedConfidence the weighted confidence of the current and prior competencies + * @return the predicted number of exercise points required to master the given competency */ - private int predictNumberOfExercisesRequiredToMaster(LearningPath learningPath, Competency competency, double priorConfidence, int numberOfPendingExercises) { - // we assume that the student may perform slightly worse that previously and dampen the prior confidence for the prediction process - priorConfidence *= 0.75; - final var scores = participantScoreService.getStudentAndTeamParticipationScoresAsDoubleStream(learningPath.getUser(), competency.getExercises()).summaryStatistics(); + private double calculateNumberOfExercisePointsRequiredToMaster(LearningPath learningPath, Competency competency, double weightedConfidence) { + // we assume that the student may perform slightly worse than previously and dampen the confidence for the prediction process + weightedConfidence *= 0.9; + double currentPoints = participantScoreService.getStudentAndTeamParticipationPointsAsDoubleStream(learningPath.getUser(), competency.getExercises()).sum(); + double maxPoints = competency.getExercises().stream().mapToDouble(Exercise::getMaxPoints).sum(); double lectureUnits = competency.getLectureUnits().size(); double exercises = competency.getExercises().size(); double learningObjects = lectureUnits + exercises; double masteryThreshold = competency.getMasteryThreshold(); - double completedExercises = exercises - numberOfPendingExercises; - double a = 100d / (3d * learningObjects); - double b = 100d * (lectureUnits + completedExercises + scores.getCount()) / (3d * learningObjects) + 2d * priorConfidence / 3d - masteryThreshold; - double c = 100d * (lectureUnits + completedExercises) * scores.getCount() / (3d * learningObjects) + 2d * scores.getSum() / 3d - masteryThreshold * scores.getCount(); - double D = Math.sqrt(Math.pow(b, 2) - 4 * a * c); - double prediction1 = Math.ceil((-b + D) / (2d * a)); - double prediction2 = Math.ceil((-b - D) / (2d * a)); - int prediction = (int) Math.max(prediction1, prediction2); + + double neededProgress = masteryThreshold / weightedConfidence; + double maxLectureUnitProgress = lectureUnits / learningObjects * 100; + double exerciseWeight = exercises / learningObjects; + double neededTotalExercisePoints = (neededProgress - maxLectureUnitProgress) / exerciseWeight * (maxPoints / 100); + + double neededExercisePoints = neededTotalExercisePoints - currentPoints; // numerical edge case, can't happen for valid competencies - return Math.max(prediction, 0); + return Math.max(neededExercisePoints, 0); } /** @@ -580,48 +575,32 @@ private static Map> generateDifficultyLevelMap(Se /** * Computes the recommended amount of exercises per difficulty level. * - * @param numberOfExercisesRequiredToMaster the minimum number of exercises that should be recommended - * @param priorConfidence the average confidence of all prior competencies + * @param numberOfExercisePointsRequiredToMaster the minimum amount of exercise points that should be recommended + * @param weightedConfidence the weighted confidence of the current and prior competencies * @return array containing the recommended number of exercises per difficulty level (easy to hard) */ - private static int[] getRecommendedExerciseDistribution(int numberOfExercisesRequiredToMaster, double priorConfidence) { - final var distribution = getExerciseDifficultyDistribution(priorConfidence); - final var numberOfExercises = new int[DifficultyLevel.values().length]; - for (int i = 0; i < numberOfExercises.length; i++) { - numberOfExercises[i] = (int) Math.round(Math.ceil(distribution[i] * numberOfExercisesRequiredToMaster)); + private static double[] getRecommendedExercisePointDistribution(double numberOfExercisePointsRequiredToMaster, double weightedConfidence) { + final var distribution = getExerciseDifficultyDistribution(weightedConfidence); + final var numberOfExercisePoints = new double[DifficultyLevel.values().length]; + for (int i = 0; i < numberOfExercisePoints.length; i++) { + numberOfExercisePoints[i] = distribution[i] * numberOfExercisePointsRequiredToMaster; } - return numberOfExercises; + return numberOfExercisePoints; } /** * Retrieves the corresponding distribution from the lookup table. * - * @param priorConfidence the median of the normal distribution + * @param weightedConfidence the weighted confidence of the current and prior competencies * @return array containing the distribution in percent per difficulty level (easy to hard) */ - private static double[] getExerciseDifficultyDistribution(double priorConfidence) { - int distributionIndex = (int) Math.round(priorConfidence * (EXERCISE_DIFFICULTY_DISTRIBUTION_LUT.length - 1) / 100); - return EXERCISE_DIFFICULTY_DISTRIBUTION_LUT[distributionIndex]; + private static double[] getExerciseDifficultyDistribution(double weightedConfidence) { + int distributionIndex = (int) Math.round(weightedConfidence * (EXERCISE_DIFFICULTY_DISTRIBUTION_LUT.length - 1)); + return EXERCISE_DIFFICULTY_DISTRIBUTION_LUT[Math.clamp(distributionIndex, 0, EXERCISE_DIFFICULTY_DISTRIBUTION_LUT.length - 1)]; } public record RecommendationState(Map competencyIdMap, List recommendedOrderOfCompetencies, Set masteredCompetencies, Map competencyMastery, Map> matchingClusters, Map> priorCompetencies, Map extendsCompetencies, Map assumesCompetencies) { } - - /** - * Checks if the user has achieved the minimum score. - * - * @param exercise the exercise that should be checked - * @param user the user for which to check the score - * @param minScore the minimum score that should be achieved - * @return true if the user achieved the minimum score, false otherwise - */ - private boolean hasScoredAtLeast(@NotNull Exercise exercise, @NotNull User user, double minScore) { - final var score = participantScoreService.getStudentAndTeamParticipationScoresAsDoubleStream(user, Set.of(exercise)).max(); - if (score.isEmpty()) { - return false; - } - return score.getAsDouble() >= minScore; - } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/learningpath/LearningPathService.java b/src/main/java/de/tum/in/www1/artemis/service/learningpath/LearningPathService.java index 6918b86f2232..13945926a0df 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/learningpath/LearningPathService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/learningpath/LearningPathService.java @@ -24,6 +24,7 @@ import de.tum.in.www1.artemis.domain.competency.CompetencyProgress; import de.tum.in.www1.artemis.domain.competency.CompetencyRelation; import de.tum.in.www1.artemis.domain.competency.LearningPath; +import de.tum.in.www1.artemis.domain.lecture.ExerciseUnit; import de.tum.in.www1.artemis.domain.lecture.LectureUnit; import de.tum.in.www1.artemis.domain.lecture.LectureUnitCompletion; import de.tum.in.www1.artemis.domain.participation.StudentParticipation; @@ -334,6 +335,9 @@ public NgxLearningPathDTO generateNgxPathRepresentation(@NotNull LearningPath le */ public LearningPath findWithCompetenciesAndLearningObjectsAndCompletedUsersById(long learningPathId) { LearningPath learningPath = learningPathRepository.findWithCompetenciesAndLectureUnitsAndExercisesByIdElseThrow(learningPathId); + // Remove exercise units, since they are already retrieved as exercises + learningPath.getCompetencies().stream().forEach(competency -> competency + .setLectureUnits(competency.getLectureUnits().stream().filter(lectureUnit -> !(lectureUnit instanceof ExerciseUnit)).collect(Collectors.toSet()))); if (learningPath.getUser() == null) { learningPath.getCompetencies().forEach(competency -> { competency.setUserProgress(Collections.emptySet()); diff --git a/src/main/java/de/tum/in/www1/artemis/service/metrics/LearningMetricsService.java b/src/main/java/de/tum/in/www1/artemis/service/metrics/LearningMetricsService.java index 59382d081736..4dad8f652b0a 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/metrics/LearningMetricsService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/metrics/LearningMetricsService.java @@ -1,7 +1,8 @@ package de.tum.in.www1.artemis.service.metrics; +import static de.tum.in.www1.artemis.config.Constants.MIN_SCORE_GREEN; import static de.tum.in.www1.artemis.config.Constants.PROFILE_CORE; -import static de.tum.in.www1.artemis.service.util.ZonedDateTimeUtil.toRelativeTime; +import static de.tum.in.www1.artemis.service.util.TimeUtil.toRelativeTime; import static java.util.function.Function.identity; import static java.util.stream.Collectors.averagingDouble; import static java.util.stream.Collectors.groupingBy; @@ -100,7 +101,7 @@ public ExerciseStudentMetricsDTO getStudentExerciseMetrics(long userId, long cou final var latestSubmissionOfUser = exerciseMetricsRepository.findLatestSubmissionDatesForUser(exerciseIdsWithDueDate, userId); final var latestSubmissionMap = latestSubmissionOfUser.stream().collect(toMap(ResourceTimestampDTO::id, relativeTime::applyAsDouble)); - final var completedExerciseIds = exerciseMetricsRepository.findAllCompletedExerciseIdsForUserByExerciseIds(userId, exerciseIds); + final var completedExerciseIds = exerciseMetricsRepository.findAllCompletedExerciseIdsForUserByExerciseIds(userId, exerciseIds, MIN_SCORE_GREEN); final var teamIds = exerciseMetricsRepository.findTeamIdsForUserByExerciseIds(userId, exerciseIds); final var teamIdMap = teamIds.stream().collect(toMap(MapEntryLongLong::key, MapEntryLongLong::value)); diff --git a/src/main/java/de/tum/in/www1/artemis/service/scheduled/ParticipantScoreScheduleService.java b/src/main/java/de/tum/in/www1/artemis/service/scheduled/ParticipantScoreScheduleService.java index 1cc8066c2ce0..a87b1924beac 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/scheduled/ParticipantScoreScheduleService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/scheduled/ParticipantScoreScheduleService.java @@ -304,18 +304,20 @@ private void executeTask(Long exerciseId, Long participantId, Instant resultLast // Either use the existing participant score or create a new one var score = participantScore.orElseGet(() -> { - if (participant instanceof Team team) { - var teamScore = new TeamScore(); - teamScore.setTeam(team); - teamScore.setExercise(exercise); - return teamScore; - } - else { - User user = (User) participant; - var studentScore = new StudentScore(); - studentScore.setUser(user); - studentScore.setExercise(exercise); - return studentScore; + switch (participant) { + case Team team -> { + var teamScore = new TeamScore(); + teamScore.setTeam(team); + teamScore.setExercise(exercise); + return teamScore; + } + case User user -> { + var studentScore = new StudentScore(); + studentScore.setUser(user); + studentScore.setExercise(exercise); + return studentScore; + } + default -> throw new IllegalArgumentException("Unknown participant type: " + participant); } }); diff --git a/src/main/java/de/tum/in/www1/artemis/service/util/ZonedDateTimeUtil.java b/src/main/java/de/tum/in/www1/artemis/service/util/TimeUtil.java similarity index 52% rename from src/main/java/de/tum/in/www1/artemis/service/util/ZonedDateTimeUtil.java rename to src/main/java/de/tum/in/www1/artemis/service/util/TimeUtil.java index 251e9047c6c9..5be76e7034cd 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/util/ZonedDateTimeUtil.java +++ b/src/main/java/de/tum/in/www1/artemis/service/util/TimeUtil.java @@ -6,12 +6,12 @@ import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotNull; -public class ZonedDateTimeUtil { +public class TimeUtil { /** * Private constructor to prevent instantiation. */ - private ZonedDateTimeUtil() { + private TimeUtil() { throw new IllegalStateException("Utility class"); } @@ -26,7 +26,28 @@ private ZonedDateTimeUtil() { * @return the relative time of the target ZonedDateTime object compared to the origin and unit ZonedDateTime objects */ public static double toRelativeTime(@NotNull ZonedDateTime origin, @NotNull ZonedDateTime unit, @NotNull ZonedDateTime target) { - return 100.0 * (target.toEpochSecond() - origin.toEpochSecond()) / (unit.toEpochSecond() - origin.toEpochSecond()); + return toRelativeTime(origin.toEpochSecond(), unit.toEpochSecond(), target.toEpochSecond()); + } + + /** + * Get the relative time of a ZonedDateTime object compared to an origin and a unit ZonedDateTime object in percent. + *

+ * Example: origin = 0:00, unit = 10:00, target = 2:30 => 25% + * + * @param origin the origin ZonedDateTime object + * @param unit the unit ZonedDateTime object + * @param target the target ZonedDateTime object + * @return the relative time of the target ZonedDateTime object compared to the origin and unit ZonedDateTime objects + */ + public static double toRelativeTime(@NotNull Instant origin, @NotNull Instant unit, @NotNull Instant target) { + return toRelativeTime(origin.getEpochSecond(), unit.getEpochSecond(), target.getEpochSecond()); + } + + private static double toRelativeTime(@NotNull long originEpochSecond, @NotNull long unitEpochSecond, @NotNull long targetEpochSecond) { + if (originEpochSecond == unitEpochSecond) { + return 1; + } + return 100.0 * (targetEpochSecond - originEpochSecond) / (unitEpochSecond - originEpochSecond); } /** diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/CompetencyResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/CompetencyResource.java index fdae40ac08c3..947d432631a8 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/CompetencyResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/CompetencyResource.java @@ -48,6 +48,7 @@ import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastInstructor; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastStudent; import de.tum.in.www1.artemis.security.annotations.enforceRoleInCourse.EnforceAtLeastEditorInCourse; +import de.tum.in.www1.artemis.security.annotations.enforceRoleInCourse.EnforceAtLeastInstructorInCourse; import de.tum.in.www1.artemis.security.annotations.enforceRoleInCourse.EnforceAtLeastStudentInCourse; import de.tum.in.www1.artemis.service.AuthorizationCheckService; import de.tum.in.www1.artemis.service.ExerciseService; @@ -435,7 +436,7 @@ public ResponseEntity deleteCompetency(@PathVariable long competencyId, @P * @return the ResponseEntity with status 200 (OK) and with the competency course performance in the body */ @GetMapping("courses/{courseId}/competencies/{competencyId}/student-progress") - @EnforceAtLeastStudent + @EnforceAtLeastStudentInCourse public ResponseEntity getCompetencyStudentProgress(@PathVariable long courseId, @PathVariable long competencyId, @RequestParam(defaultValue = "false") Boolean refresh) { log.debug("REST request to get student progress for competency: {}", competencyId); @@ -463,12 +464,11 @@ public ResponseEntity getCompetencyStudentProgress(@PathVari * @return the ResponseEntity with status 200 (OK) and with the competency course performance in the body */ @GetMapping("courses/{courseId}/competencies/{competencyId}/course-progress") - @EnforceAtLeastInstructor + @EnforceAtLeastInstructorInCourse public ResponseEntity getCompetencyCourseProgress(@PathVariable long courseId, @PathVariable long competencyId) { log.debug("REST request to get course progress for competency: {}", competencyId); var course = courseRepository.findByIdElseThrow(courseId); - var competency = competencyRepository.findByIdWithLectureUnitsAndCompletionsElseThrow(competencyId); - checkAuthorizationForCompetency(Role.INSTRUCTOR, course, competency); + var competency = competencyRepository.findByIdWithExercisesElseThrow(competencyId); var progress = competencyProgressService.getCompetencyCourseProgress(competency, course); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/metrics/CompetencyExerciseMasteryCalculationDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/metrics/CompetencyExerciseMasteryCalculationDTO.java new file mode 100644 index 000000000000..0c8778d480b0 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/metrics/CompetencyExerciseMasteryCalculationDTO.java @@ -0,0 +1,12 @@ +package de.tum.in.www1.artemis.web.rest.dto.metrics; + +import java.time.Instant; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.in.www1.artemis.domain.enumeration.DifficultyLevel; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record CompetencyExerciseMasteryCalculationDTO(double maxPoints, DifficultyLevel difficulty, boolean isProgrammingExercise, Double lastScore, Double lastPoints, + Instant lastModifiedDate, long submissionCount) { +} diff --git a/src/main/resources/config/liquibase/changelog/20240614140000_changelog.xml b/src/main/resources/config/liquibase/changelog/20240614140000_changelog.xml new file mode 100644 index 000000000000..9771d924024c --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20240614140000_changelog.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index e5207e0084dc..35f4223b1c39 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -14,6 +14,7 @@ + diff --git a/src/main/webapp/app/course/competencies/competency-accordion/competency-accordion.component.html b/src/main/webapp/app/course/competencies/competency-accordion/competency-accordion.component.html index 7cf8f7c2feb6..1266e68c00ef 100644 --- a/src/main/webapp/app/course/competencies/competency-accordion/competency-accordion.component.html +++ b/src/main/webapp/app/course/competencies/competency-accordion/competency-accordion.component.html @@ -48,7 +48,6 @@

this.metrics.exerciseMetrics?.completed?.includes(exerciseId)).length ?? 0; + if (competencyExercises === undefined || competencyExercises.length === 0) { return undefined; } - const progress = (completedExercises / competencyExercises.length) * 100; + + const competencyPoints = competencyExercises + ?.map((exercise) => ((this.metrics.exerciseMetrics?.score?.[exercise] ?? 0) * (this.metrics.exerciseMetrics?.exerciseInformation?.[exercise]?.maxPoints ?? 0)) / 100) + .reduce((a, b) => a + b, 0); + const competencyMaxPoints = competencyExercises + ?.map((exercise) => this.metrics.exerciseMetrics?.exerciseInformation?.[exercise]?.maxPoints ?? 0) + .reduce((a, b) => a + b, 0); + + const progress = (competencyPoints / competencyMaxPoints) * 100; return round(progress, 1); } @@ -163,17 +171,18 @@ export class CompetencyAccordionComponent implements OnChanges { const releasedLectureUnits = competencyLectureUnits?.filter((lectureUnitId) => this.metrics.lectureUnitStudentMetricsDTO?.lectureUnitInformation?.[lectureUnitId]?.releaseDate?.isBefore(dayjs()), ); - const completedLectureUnits = releasedLectureUnits?.filter((lectureUnitId) => this.metrics.lectureUnitStudentMetricsDTO?.completed?.includes(lectureUnitId)).length ?? 0; if (releasedLectureUnits === undefined || releasedLectureUnits.length === 0) { return undefined; } + + const completedLectureUnits = releasedLectureUnits?.filter((lectureUnitId) => this.metrics.lectureUnitStudentMetricsDTO?.completed?.includes(lectureUnitId)).length ?? 0; const progress = (completedLectureUnits / releasedLectureUnits.length) * 100; return round(progress, 1); } getUserProgress(): CompetencyProgress { const progress = this.metrics.competencyMetrics?.progress?.[this.competency.id] ?? 0; - const confidence = this.metrics.competencyMetrics?.confidence?.[this.competency.id] ?? 0; + const confidence = this.metrics.competencyMetrics?.confidence?.[this.competency.id] ?? 1; return { progress, confidence }; } diff --git a/src/main/webapp/app/course/competencies/competency-card/competency-card.component.html b/src/main/webapp/app/course/competencies/competency-card/competency-card.component.html index 049e8d21fd3e..c48969f072ef 100644 --- a/src/main/webapp/app/course/competencies/competency-card/competency-card.component.html +++ b/src/main/webapp/app/course/competencies/competency-card/competency-card.component.html @@ -56,7 +56,7 @@

} @if (!isPrerequisite) {
- +
}

diff --git a/src/main/webapp/app/course/competencies/competency-card/competency-card.component.ts b/src/main/webapp/app/course/competencies/competency-card/competency-card.component.ts index 2ec7db641f53..e7e8829c0680 100644 --- a/src/main/webapp/app/course/competencies/competency-card/competency-card.component.ts +++ b/src/main/webapp/app/course/competencies/competency-card/competency-card.component.ts @@ -1,7 +1,7 @@ import dayjs from 'dayjs/esm'; import { Component, Input } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { Competency, CompetencyProgress, getIcon } from 'app/entities/competency.model'; +import { Competency, CompetencyProgress, getIcon, getMastery, getProgress } from 'app/entities/competency.model'; @Component({ selector: 'jhi-competency-card', @@ -26,24 +26,15 @@ export class CompetencyCardComponent { if (this.competency.userProgress?.length) { return this.competency.userProgress.first()!; } - return { progress: 0, confidence: 0 } as CompetencyProgress; + return { progress: 0, confidence: 1 } as CompetencyProgress; } get progress(): number { - // The percentage of completed lecture units and participated exercises - return this.getUserProgress().progress ?? 0; - } - - get confidence(): number { - // Confidence level (average score in exercises) in proportion to the threshold value (max. 100 %) - // Example: If the student’s latest confidence level equals 60 % and the mastery threshold is set to 80 %, the ring would be 75 % full. - return Math.min(Math.round(((this.getUserProgress().confidence ?? 0) / (this.competency.masteryThreshold ?? 100)) * 100), 100); + return getProgress(this.getUserProgress()); } get mastery(): number { - // Advancement towards mastery as a weighted function of progress and confidence - const weight = 2 / 3; - return Math.round((1 - weight) * this.progress + weight * this.confidence); + return getMastery(this.getUserProgress()); } get isMastered(): boolean { diff --git a/src/main/webapp/app/course/competencies/competency-rings/competency-rings.component.html b/src/main/webapp/app/course/competencies/competency-rings/competency-rings.component.html index 0c51254ba002..e74dac483af5 100644 --- a/src/main/webapp/app/course/competencies/competency-rings/competency-rings.component.html +++ b/src/main/webapp/app/course/competencies/competency-rings/competency-rings.component.html @@ -1,21 +1,7 @@
- - - - - - + - + @if (node.type === NodeType.COMPETENCY_START) { - + } @if (node.type === NodeType.COMPETENCY_END) { diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-node.component.ts b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-node.component.ts index 1139ab70f379..99f64933cb4b 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-node.component.ts +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/learning-path-node.component.ts @@ -1,6 +1,6 @@ import { Component, Input, OnInit } from '@angular/core'; import { CompetencyProgressForLearningPathDTO, NgxLearningPathNode, NodeType, getIcon } from 'app/entities/competency/learning-path.model'; -import { Competency, CompetencyProgress, getConfidence, getMastery, getProgress } from 'app/entities/competency.model'; +import { Competency, CompetencyProgress, getMastery, getProgress } from 'app/entities/competency.model'; import { Exercise } from 'app/entities/exercise.model'; import { Lecture } from 'app/entities/lecture.model'; import { LectureUnitForLearningPathNodeDetailsDTO } from 'app/entities/lecture-unit/lectureUnit.model'; @@ -40,11 +40,7 @@ export class LearningPathNodeComponent implements OnInit { return getProgress(this.nodeDetailsData.competencyProgress!); } - get confidence() { - return getConfidence(this.nodeDetailsData.competencyProgress!, this.competencyProgressDTO!.masteryThreshold!); - } - get mastery() { - return getMastery(this.nodeDetailsData.competencyProgress!, this.competencyProgressDTO!.masteryThreshold!); + return getMastery(this.nodeDetailsData.competencyProgress); } } diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.html b/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.html index 384ca0a1a990..5c818233d85f 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.html +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.html @@ -20,6 +20,6 @@

}

-
+
} diff --git a/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.ts b/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.ts index 1468d49693c5..48d796ac7451 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.ts +++ b/src/main/webapp/app/course/learning-paths/learning-path-graph/node-details/competency-node-details.component.ts @@ -2,7 +2,7 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { HttpErrorResponse } from '@angular/common/http'; import { onError } from 'app/shared/util/global.utils'; import { CompetencyService } from 'app/course/competencies/competency.service'; -import { Competency, CompetencyProgress, getConfidence, getIcon, getMastery, getProgress } from 'app/entities/competency.model'; +import { Competency, CompetencyProgress, getIcon, getMastery, getProgress } from 'app/entities/competency.model'; import { AlertService } from 'app/core/util/alert.service'; @Component({ @@ -46,11 +46,7 @@ export class CompetencyNodeDetailsComponent implements OnInit { return getProgress(this.competencyProgress!); } - get confidence(): number { - return getConfidence(this.competencyProgress!, this.competency!.masteryThreshold!); - } - get mastery(): number { - return getMastery(this.competencyProgress!, this.competency!.masteryThreshold!); + return getMastery(this.competencyProgress); } } diff --git a/src/main/webapp/app/entities/competency.model.ts b/src/main/webapp/app/entities/competency.model.ts index a8b724595013..b7a21dfb61d7 100644 --- a/src/main/webapp/app/entities/competency.model.ts +++ b/src/main/webapp/app/entities/competency.model.ts @@ -37,7 +37,7 @@ export enum CompetencyValidators { DESCRIPTION_MAX = 10000, } -export const DEFAULT_MASTERY_THRESHOLD = 50; +export const DEFAULT_MASTERY_THRESHOLD = 80; export class Competency implements BaseEntity { public id?: number; @@ -112,9 +112,19 @@ export interface CompetencyImportResponseDTO extends BaseEntity { linkedStandardizedCompetencyId?: number; } +export enum ConfidenceReason { + NO_REASON = 'NO_REASON', + RECENT_SCORES_LOWER = 'RECENT_SCORES_LOWER', + RECENT_SCORES_HIGHER = 'RECENT_SCORES_HIGHER', + MORE_EASY_POINTS = 'MORE_EASY_POINTS', + MORE_HARD_POINTS = 'MORE_HARD_POINTS', + QUICKLY_SOLVED_EXERCISES = 'QUICKLY_SOLVED_EXERCISES', +} + export class CompetencyProgress { public progress?: number; public confidence?: number; + public confidenceReason?: ConfidenceReason; constructor() {} } @@ -133,8 +143,6 @@ export class CompetencyRelation implements BaseEntity { public tailCompetency?: Competency; public headCompetency?: Competency; public type?: CompetencyRelationType; - - constructor() {} } export class CompetencyRelationDTO implements BaseEntity { @@ -142,8 +150,6 @@ export class CompetencyRelationDTO implements BaseEntity { tailCompetencyId?: number; headCompetencyId?: number; relationType?: CompetencyRelationType; - - constructor() {} } /** @@ -184,15 +190,27 @@ export function getIcon(competencyTaxonomy?: CompetencyTaxonomy): IconProp { return icons[competencyTaxonomy] as IconProp; } -export function getProgress(competencyProgress: CompetencyProgress) { +/** + * The progress depends on the amount of completed lecture units and the achieved scores in the exercises. + * @param competencyProgress The progress in the competency + */ +export function getProgress(competencyProgress: CompetencyProgress | undefined): number { return Math.round(competencyProgress?.progress ?? 0); } -export function getConfidence(competencyProgress: CompetencyProgress, masteryThreshold: number): number { - return Math.min(Math.round(((competencyProgress?.confidence ?? 0) / (masteryThreshold ?? 100)) * 100), 100); +/** + * The confidence is a factor for the progress, normally near 1. It depends on different heuristics and determines if the mastery is lower/higher than the progress. + * @param competencyProgress The progress in the competency + */ +export function getConfidence(competencyProgress: CompetencyProgress | undefined): number { + return competencyProgress?.confidence ?? 1; } -export function getMastery(competencyProgress: CompetencyProgress, masteryThreshold: number): number { - const weight = 2 / 3; - return Math.round((1 - weight) * getProgress(competencyProgress) + weight * getConfidence(competencyProgress, masteryThreshold)); +/** + * The mastery is the final value that is shown to the user. It is the product of the progress and the confidence. + * @param competencyProgress The progress in the competency + */ +export function getMastery(competencyProgress: CompetencyProgress | undefined): number { + // clamp the value between 0 and 100 + return Math.min(100, Math.max(0, Math.round(getProgress(competencyProgress) * getConfidence(competencyProgress)))); } diff --git a/src/main/webapp/app/overview/course-competencies/course-competencies-details.component.html b/src/main/webapp/app/overview/course-competencies/course-competencies-details.component.html index 9e3a9f9343ff..6f057094b0f3 100644 --- a/src/main/webapp/app/overview/course-competencies/course-competencies-details.component.html +++ b/src/main/webapp/app/overview/course-competencies/course-competencies-details.component.html @@ -78,7 +78,6 @@

@@ -86,18 +85,17 @@

@if (!promptForJolRating || !judgementOfLearningEnabled || judgementOfLearning !== undefined) {
-
{{ 'artemisApp.competency.progress' | artemisTranslate }}
-
{{ progress }} %
-
-
-
- {{ 'artemisApp.competency.confidence' | artemisTranslate }} +
+
+ {{ mastery }} % + @if (competencyProgress?.confidenceReason && competencyProgress.confidenceReason !== ConfidenceReason.NO_REASON) { + + }
-
{{ confidence }} %
-
{{ 'artemisApp.competency.mastery' | artemisTranslate }}
-
{{ mastery }} %
+
+
{{ progress }} %
} diff --git a/src/main/webapp/app/overview/course-competencies/course-competencies-details.component.ts b/src/main/webapp/app/overview/course-competencies/course-competencies-details.component.ts index 66399b8eb8e5..9ab71403de83 100644 --- a/src/main/webapp/app/overview/course-competencies/course-competencies-details.component.ts +++ b/src/main/webapp/app/overview/course-competencies/course-competencies-details.component.ts @@ -1,7 +1,7 @@ import dayjs from 'dayjs/esm'; import { Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { Competency, CompetencyJol, CompetencyProgress, getIcon } from 'app/entities/competency.model'; +import { Competency, CompetencyJol, CompetencyProgress, ConfidenceReason, getConfidence, getIcon, getMastery, getProgress } from 'app/entities/competency.model'; import { CompetencyService } from 'app/course/competencies/competency.service'; import { AlertService } from 'app/core/util/alert.service'; import { onError } from 'app/shared/util/global.utils'; @@ -27,6 +27,7 @@ export class CourseCompetenciesDetailsComponent implements OnInit, OnDestroy { courseId?: number; isLoading = false; competency: Competency; + competencyProgress: CompetencyProgress; judgementOfLearning: CompetencyJol | undefined; promptForJolRating = false; showFireworks = false; @@ -34,6 +35,7 @@ export class CourseCompetenciesDetailsComponent implements OnInit, OnDestroy { paramsSubscription: Subscription; readonly LectureUnitType = LectureUnitType; + protected readonly ConfidenceReason = ConfidenceReason; faPencilAlt = faPencilAlt; getIcon = getIcon; @@ -87,6 +89,7 @@ export class CourseCompetenciesDetailsComponent implements OnInit, OnDestroy { forkJoin(observables).subscribe({ next: ([competencyResp, courseCompetenciesResp, judgementOfLearningResp]) => { this.competency = competencyResp.body! as Competency; + this.competencyProgress = this.getUserProgress(); if (this.judgementOfLearningEnabled) { const competencies = courseCompetenciesResp.body! as Competency[]; @@ -94,10 +97,9 @@ export class CourseCompetenciesDetailsComponent implements OnInit, OnDestroy { this.promptForJolRating = CompetencyJol.shouldPromptForJol(this.competency, progress, competencies); const judgementOfLearning = (judgementOfLearningResp?.body ?? undefined) as { current: CompetencyJol; prior?: CompetencyJol } | undefined; if ( - judgementOfLearning && - progress && - (judgementOfLearning.current.competencyProgress !== (progress?.progress ?? 0) || - judgementOfLearning.current.competencyConfidence !== (progress?.confidence ?? 0)) + !judgementOfLearning?.current || + judgementOfLearning.current.competencyProgress !== (progress?.progress ?? 0) || + judgementOfLearning.current.competencyConfidence !== (progress?.confidence ?? 1) ) { this.judgementOfLearning = undefined; } else { @@ -134,24 +136,19 @@ export class CourseCompetenciesDetailsComponent implements OnInit, OnDestroy { if (this.competency.userProgress?.length) { return this.competency.userProgress.first()!; } - return { progress: 0, confidence: 0 } as CompetencyProgress; + return { progress: 0, confidence: 1 } as CompetencyProgress; } get progress(): number { - // The percentage of completed lecture units and participated exercises - return Math.round(this.getUserProgress().progress ?? 0); + return getProgress(this.competencyProgress); } get confidence(): number { - // Confidence level (average score in exercises) in proportion to the threshold value (max. 100 %) - // Example: If the student’s latest confidence level equals 60 % and the mastery threshold is set to 80 %, the ring would be 75 % full. - return Math.min(Math.round(((this.getUserProgress().confidence ?? 0) / (this.competency.masteryThreshold ?? 100)) * 100), 100); + return getConfidence(this.competencyProgress); } get mastery(): number { - // Advancement towards mastery as a weighted function of progress and confidence - const weight = 2 / 3; - return Math.round((1 - weight) * this.progress + weight * this.confidence); + return getMastery(this.competencyProgress); } get isMastered(): boolean { diff --git a/src/main/webapp/app/overview/course-competencies/course-competencies.component.html b/src/main/webapp/app/overview/course-competencies/course-competencies.component.html index 4654b76216f5..62873f345677 100644 --- a/src/main/webapp/app/overview/course-competencies/course-competencies.component.html +++ b/src/main/webapp/app/overview/course-competencies/course-competencies.component.html @@ -43,8 +43,6 @@

{{ 'artemisApp.competency.table.panelHeader' | artemisTranslate
{{ 'artemisApp.competency.progress' | artemisTranslate }}
{{ 'artemisApp.competency.progressDescription' | artemisTranslate }}
-
{{ 'artemisApp.competency.confidence' | artemisTranslate }}
-
{{ 'artemisApp.competency.confidenceDescription' | artemisTranslate }}
{{ 'artemisApp.competency.mastery' | artemisTranslate }}
{{ 'artemisApp.competency.masteryDescription' | artemisTranslate }}
diff --git a/src/main/webapp/app/overview/course-competencies/course-competencies.component.ts b/src/main/webapp/app/overview/course-competencies/course-competencies.component.ts index c373f9624692..cde80ab44ee8 100644 --- a/src/main/webapp/app/overview/course-competencies/course-competencies.component.ts +++ b/src/main/webapp/app/overview/course-competencies/course-competencies.component.ts @@ -4,7 +4,7 @@ import { ActivatedRoute } from '@angular/router'; import { AlertService } from 'app/core/util/alert.service'; import { onError } from 'app/shared/util/global.utils'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; -import { Competency, CompetencyJol } from 'app/entities/competency.model'; +import { Competency, CompetencyJol, getMastery } from 'app/entities/competency.model'; import { Observable, Subscription, forkJoin } from 'rxjs'; import { Course } from 'app/entities/course.model'; import { faAngleDown, faAngleUp } from '@fortawesome/free-solid-svg-icons'; @@ -69,12 +69,7 @@ export class CourseCompetenciesComponent implements OnInit, OnDestroy { } get countMasteredCompetencies() { - return this.competencies.filter((competency) => { - if (competency.userProgress?.length && competency.masteryThreshold) { - return competency.userProgress.first()!.progress == 100 && competency.userProgress.first()!.confidence! >= competency.masteryThreshold!; - } - return false; - }).length; + return this.competencies.filter((competency) => getMastery(competency.userProgress?.first()) >= (competency.masteryThreshold ?? 100)).length; } get countPrerequisites() { @@ -109,7 +104,7 @@ export class CourseCompetenciesComponent implements OnInit, OnDestroy { this.judgementOfLearningMap = Object.fromEntries( Object.entries((judgementOfLearningMap?.body ?? {}) as { [key: number]: { current: CompetencyJol; prior?: CompetencyJol } }).filter(([key, value]) => { const progress = competenciesMap[Number(key)]?.userProgress?.first(); - return value.current.competencyProgress === (progress?.progress ?? 0) && value.current.competencyConfidence === (progress?.confidence ?? 0); + return value.current.competencyProgress === (progress?.progress ?? 0) && value.current.competencyConfidence === (progress?.confidence ?? 1); }), ); this.promptForJolRatingMap = Object.fromEntries( diff --git a/src/main/webapp/app/overview/course-dashboard/course-dashboard.service.ts b/src/main/webapp/app/overview/course-dashboard/course-dashboard.service.ts index c8d5cd674959..1568d22ed3e1 100644 --- a/src/main/webapp/app/overview/course-dashboard/course-dashboard.service.ts +++ b/src/main/webapp/app/overview/course-dashboard/course-dashboard.service.ts @@ -82,7 +82,7 @@ export class CourseDashboardService { return Object.fromEntries( Object.entries(competencyMetrics.currentJolValues ?? {}).filter(([key, value]) => { const progress = competencyMetrics?.progress?.[key] ?? 0; - const confidence = competencyMetrics?.confidence?.[key] ?? 0; + const confidence = competencyMetrics?.confidence?.[key] ?? 1; return value.competencyProgress === progress && value.competencyConfidence === confidence; }), ); diff --git a/src/main/webapp/i18n/de/competency.json b/src/main/webapp/i18n/de/competency.json index c5a8944d7a69..458006bb53f8 100644 --- a/src/main/webapp/i18n/de/competency.json +++ b/src/main/webapp/i18n/de/competency.json @@ -14,14 +14,20 @@ "optionalDescription": "Optionale Kompetenzen müssen nicht absolviert werden um den Lernpfad zu vervollständigen. Sie dienen entweder als zusätzliche Übung oder gehen über die erwarteten Inhalte des Kurses hinaus.", "course": "Kurs", "progress": "Fortschritt", - "progressDescription": "Der Prozentsatz an abgeschlossenen Vorlesungseinheiten und Übungen, die mit dieser Kompetenz verknüpft sind.", - "confidence": "Konfidenz", - "confidenceDescription": "Die durchschnittliche Bewertung in allen verknüpften Übungen im Verhältnis zu dem vom Lehrenden festgelegten Schwellenwert für die Beherrschung der Kompetenz.", + "progressDescription": "Der Fortschritt basiert auf dem Prozentsatz an abgeschlossenen Vorlesungseinheiten und den erreichten Punkten in den Übungen.", "mastery": "Kompetenzbeherrschung", - "masteryDescription": "Ein gewichteter Leistungsindex aus Fortschritt und Konfidenz, der Deine Entwicklung auf dem Weg zur Beherrschung der Kompetenz veranschaulicht.", + "masteryDescription": "Ein gewichteter Leistungsindex aus deinem Fortschritt, der Deine Entwicklung auf dem Weg zur Beherrschung der Kompetenz veranschaulicht.", "masteredStudents": "Studierende mit Kompetenz", "mastered": "Abgeschlossen", "competencies": "Kompetenzen", + "confidenceReasons": { + "NO_REASON": "Die Kompetenzbeherrschung entspricht in etwa dem Fortschritt", + "RECENT_SCORES_LOWER": "Da die letzten Bewertungen niedriger waren als dein Durchschnitt in dieser Kompetenz, ist die Kompetenzbeherrschung niedriger als der Fortschritt", + "RECENT_SCORES_HIGHER": "Da die letzten Bewertungen höher waren als dein Durchschnitt in dieser Kompetenz, ist die Kompetenzbeherrschung höher als der Fortschritt", + "MORE_EASY_POINTS": "Da ein größerer Anteil deiner Punkte aus einfachen Aufgaben kommt, ist die Kompetenzbeherrschung niedriger als der Fortschritt", + "MORE_HARD_POINTS": "Da ein größerer Anteil deiner Punkte aus schwierigen Aufgaben kommt, ist die Kompetenzbeherrschung höher als der Fortschritt", + "QUICKLY_SOLVED_EXERCISES": "Da du Aufgaben schnell gelöst hast, ist die Kompetenzbeherrschung höher als der Fortschritt" + }, "taxonomies": { "REMEMBER": "Erinnern", "UNDERSTAND": "Verstehen", @@ -124,7 +130,7 @@ "softDueDate": "Empfohlenes Fertigstellungsdatum", "softDueDateHint": "Empfehle einen Zeitpunkt zu dem die Studierenden diese Kompetenz gemeistert haben sollten. Dies ist keine harte Frist.", "suggestedTaxonomy": "Vorschlag", - "averageStudentScore": "Aktuelle durchschnittliche Konfidenz", + "averageStudentScore": "Durchschnittliche Bewertung der Studierenden", "connectWithLectureUnits": "Verbinde die Kompetenz mit Vorlesungseinheiten", "selectLecture": "Wähle eine Vorlesung aus", "noLectures": "Dieser Kurs hat keine Vorlesungen", @@ -152,7 +158,7 @@ "competencyCard": { "connectedLectureUnits": "Verbundene Vorlesungseinheiten", "connectedLectureUnitsExplanation": "Die folgenden Vorlesungseinheiten sind mit der Kompetenz verbunden:", - "ringsTooltip": "Dein Weg zum Erwerb der Kompetenz. (Rot = Kompetenzbeherrschung, Grün = Selbstsicherheit, Blau = Fortschritt)", + "ringsTooltip": "Dein Weg zum Erwerb der Kompetenz. (Rot = Kompetenzbeherrschung, Grün = Fortschritt)", "ringsTooltipHideProgress": "Du hast dein Verständnis dieser Kompetenz noch nicht bewertet. Bitte bewerte es, um deinen Fortschritt zu sehen.", "exerciseNote": "Bitte beachte, dass bei Aufgabeneinheiten immer die letzte Einreichung berücksichtigt wird. Auch Einreichungen nach dem Fälligkeitsdatum. Das bedeutet, dass du z. B. deinen Fortschritt verbessern kannst, indem du ein Quiz beliebig oft erneut versuchst. Es ist daher normal, dass deine Bewertung hier von der Bewertung, welche du offiziell in einer Übung erreicht hast, abweichen kann.", "delete": { diff --git a/src/main/webapp/i18n/en/competency.json b/src/main/webapp/i18n/en/competency.json index ddb4e29f9c7d..cdf46a420410 100644 --- a/src/main/webapp/i18n/en/competency.json +++ b/src/main/webapp/i18n/en/competency.json @@ -14,14 +14,20 @@ "optionalDescription": "Optional competencies are not required to complete the learning path. They can provide additional practice or may cover material that exceeds the courses requirements.", "course": "Course", "progress": "Progress", - "progressDescription": "The percentage of completed lecture units and exercises linked to this competency.", - "confidence": "Confidence", - "confidenceDescription": "The average score in all linked exercises in relation to the threshold required for mastering the competency defined by the instructor.", + "progressDescription": "The progress is influenced by the percentage of completed lecture units and your score in the exercises.", "mastery": "Mastery", - "masteryDescription": "A weighted metric of your progress and confidence, which shows your overall advancement towards competency mastery.", + "masteryDescription": "A weighted metric of your progress, which shows your overall advancement towards competency mastery.", "masteredStudents": "Mastered students", "mastered": "Mastered", "competencies": "Competencies", + "confidenceReasons": { + "NO_REASON": "The confidence is roughly the same as the mastery", + "RECENT_SCORES_LOWER": "Since your recent scores are lower than your average in the competency, the mastery is lower than the progress", + "RECENT_SCORES_HIGHER": "Since your recent scores are higher than your average in the competency, the mastery is higher than the progress", + "MORE_EASY_POINTS": "Since you mostly scored your points in easy exercises, the mastery is lower than the progress", + "MORE_HARD_POINTS": "Since you mostly scored your points in hard exercises, the mastery is higher than the progress", + "QUICKLY_SOLVED_EXERCISES": "Since you solved some exercises quickly, the mastery is higher than the progress" + }, "taxonomies": { "REMEMBER": "Remember", "UNDERSTAND": "Understand", @@ -124,7 +130,7 @@ "softDueDate": "Recommended date of completion", "softDueDateHint": "Guide students by selecting when you expect them to have mastered this competency. This is not a hard deadline.", "suggestedTaxonomy": "Suggested", - "averageStudentScore": "Current average confidence", + "averageStudentScore": "Average student score", "connectWithLectureUnits": "Link the competency with lecture units", "selectLecture": "Select a Lecture", "noLectures": "This course has no lectures", @@ -152,7 +158,7 @@ "competencyCard": { "connectedLectureUnits": "Linked Lecture Units", "connectedLectureUnitsExplanation": "The following lecture units are linked to this competency:", - "ringsTooltip": "Your route to competency mastery. (Red = Mastery, Green = Confidence, Blue = Progress)", + "ringsTooltip": "Your route to competency mastery. (Red = Mastery, Green = Progress)", "ringsTooltipHideProgress": "You have not yet rated your mastery of this competency, please rate it to see your progress.", "exerciseNote": "Please note that we take always the last submission into account for exercise units. Even submissions after the due date. This means for example that you can improve your progress by re-trying a quiz as often as you like. It is therefore normal, that the score here might differ from the score you officially achieved in an exercise.", "delete": { diff --git a/src/test/java/de/tum/in/www1/artemis/MetricsIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/MetricsIntegrationTest.java index 83a428bfa7e2..faa5c86b201a 100644 --- a/src/test/java/de/tum/in/www1/artemis/MetricsIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/MetricsIntegrationTest.java @@ -1,6 +1,6 @@ package de.tum.in.www1.artemis; -import static de.tum.in.www1.artemis.service.util.ZonedDateTimeUtil.toRelativeTime; +import static de.tum.in.www1.artemis.service.util.TimeUtil.toRelativeTime; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; diff --git a/src/test/java/de/tum/in/www1/artemis/StudentScoreUtilService.java b/src/test/java/de/tum/in/www1/artemis/StudentScoreUtilService.java index f0f783cd4955..506939100468 100644 --- a/src/test/java/de/tum/in/www1/artemis/StudentScoreUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/StudentScoreUtilService.java @@ -4,6 +4,7 @@ import org.springframework.stereotype.Service; import de.tum.in.www1.artemis.domain.Exercise; +import de.tum.in.www1.artemis.domain.Result; import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.domain.scores.StudentScore; import de.tum.in.www1.artemis.repository.StudentScoreRepository; @@ -29,6 +30,24 @@ public void createStudentScore(Exercise exercise, User user, double score) { studentScore.setExercise(exercise); studentScore.setUser(user); studentScore.setLastScore(score); + studentScore.setLastPoints(exercise.getMaxPoints() * score / 100); + studentScoreRepository.save(studentScore); + } + + /** + * Creates student score for given exercise and user. + * + * @param exercise the exercise to link the student score to + * @param user the user that is linked to the score + * @param result the result that the specified user has reached for the given exercise + */ + public void createStudentScore(Exercise exercise, User user, Result result) { + final var studentScore = new StudentScore(); + studentScore.setExercise(exercise); + studentScore.setUser(user); + studentScore.setLastResult(result); + studentScore.setLastScore(result.getScore()); + studentScore.setLastPoints(exercise.getMaxPoints() * result.getScore() / 100); studentScoreRepository.save(studentScore); } } diff --git a/src/test/java/de/tum/in/www1/artemis/competency/CompetencyJolIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/competency/CompetencyJolIntegrationTest.java index 6c786bcd54b5..1e3d5a75e4bf 100644 --- a/src/test/java/de/tum/in/www1/artemis/competency/CompetencyJolIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/competency/CompetencyJolIntegrationTest.java @@ -54,7 +54,7 @@ void setup() { competencyNotInCourse = competencyUtilService.createCompetency(null); student = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); - competencyProgress = competencyProgressUtilService.createCompetencyProgress(competency[0], student, 0.25, 0.25); + competencyProgress = competencyProgressUtilService.createCompetencyProgress(competency[0], student, 25, 1); userUtilService.addStudent(TEST_PREFIX + "otherstudents", TEST_PREFIX + "otherstudent1"); } diff --git a/src/test/java/de/tum/in/www1/artemis/lecture/CompetencyIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/lecture/CompetencyIntegrationTest.java index 7dd0745c4748..105e05fda709 100644 --- a/src/test/java/de/tum/in/www1/artemis/lecture/CompetencyIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/lecture/CompetencyIntegrationTest.java @@ -1,6 +1,7 @@ package de.tum.in.www1.artemis.lecture; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.within; import static org.awaitility.Awaitility.await; import java.time.ZonedDateTime; @@ -9,7 +10,9 @@ import java.util.List; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.glassfish.jersey.internal.util.Producer; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -20,6 +23,7 @@ import org.springframework.security.test.context.support.WithMockUser; import de.tum.in.www1.artemis.AbstractSpringIntegrationLocalCILocalVCTest; +import de.tum.in.www1.artemis.StudentScoreUtilService; import de.tum.in.www1.artemis.competency.CompetencyProgressUtilService; import de.tum.in.www1.artemis.competency.CompetencyUtilService; import de.tum.in.www1.artemis.competency.StandardizedCompetencyUtilService; @@ -28,6 +32,8 @@ import de.tum.in.www1.artemis.domain.DomainObject; import de.tum.in.www1.artemis.domain.Exercise; import de.tum.in.www1.artemis.domain.Lecture; +import de.tum.in.www1.artemis.domain.ProgrammingExercise; +import de.tum.in.www1.artemis.domain.ProgrammingSubmission; import de.tum.in.www1.artemis.domain.Result; import de.tum.in.www1.artemis.domain.Submission; import de.tum.in.www1.artemis.domain.TextExercise; @@ -38,16 +44,21 @@ import de.tum.in.www1.artemis.domain.competency.CompetencyRelation; import de.tum.in.www1.artemis.domain.competency.CompetencyTaxonomy; import de.tum.in.www1.artemis.domain.competency.RelationType; +import de.tum.in.www1.artemis.domain.enumeration.DifficultyLevel; import de.tum.in.www1.artemis.domain.enumeration.ExerciseMode; import de.tum.in.www1.artemis.domain.enumeration.IncludedInOverallScore; import de.tum.in.www1.artemis.domain.enumeration.SubmissionType; +import de.tum.in.www1.artemis.domain.lecture.AttachmentUnit; import de.tum.in.www1.artemis.domain.lecture.ExerciseUnit; import de.tum.in.www1.artemis.domain.lecture.LectureUnit; import de.tum.in.www1.artemis.domain.lecture.TextUnit; import de.tum.in.www1.artemis.domain.participation.Participant; import de.tum.in.www1.artemis.domain.participation.StudentParticipation; +import de.tum.in.www1.artemis.exercise.programming.ProgrammingExerciseFactory; import de.tum.in.www1.artemis.exercise.text.TextExerciseFactory; import de.tum.in.www1.artemis.participation.ParticipationFactory; +import de.tum.in.www1.artemis.participation.ParticipationUtilService; +import de.tum.in.www1.artemis.repository.AttachmentUnitRepository; import de.tum.in.www1.artemis.repository.CompetencyRelationRepository; import de.tum.in.www1.artemis.repository.CompetencyRepository; import de.tum.in.www1.artemis.repository.CourseRepository; @@ -86,6 +97,9 @@ class CompetencyIntegrationTest extends AbstractSpringIntegrationLocalCILocalVCT @Autowired private ParticipationService participationService; + @Autowired + private ParticipationUtilService participationUtilService; + @Autowired private UserRepository userRepository; @@ -98,6 +112,9 @@ class CompetencyIntegrationTest extends AbstractSpringIntegrationLocalCILocalVCT @Autowired private TextUnitRepository textUnitRepository; + @Autowired + private AttachmentUnitRepository attachmentUnitRepository; + @Autowired private ExerciseUnitRepository exerciseUnitRepository; @@ -137,6 +154,9 @@ class CompetencyIntegrationTest extends AbstractSpringIntegrationLocalCILocalVCT @Autowired private StandardizedCompetencyUtilService standardizedCompetencyUtilService; + @Autowired + private StudentScoreUtilService studentScoreUtilService; + private Course course; private Course course2; @@ -145,11 +165,13 @@ class CompetencyIntegrationTest extends AbstractSpringIntegrationLocalCILocalVCT private Lecture lecture; - private Long idOfTextUnitOfLectureOne; + private long idOfTextUnitOfLectureOne; - private Exercise teamTextExercise; + private long idOfAttachmentUnitOfLectureOne; - private Exercise textExercise; + private TextExercise teamTextExercise; + + private TextExercise textExercise; @BeforeEach void setupTestScenario() { @@ -211,6 +233,12 @@ private void creatingLectureUnitsOfLecture(Competency competency) { textUnit = textUnitRepository.save(textUnit); idOfTextUnitOfLectureOne = textUnit.getId(); + AttachmentUnit attachmentUnit = new AttachmentUnit(); + attachmentUnit.setName("AttachmentUnitOfLectureOne"); + attachmentUnit.setCompetencies(Set.of(competency)); + attachmentUnit = attachmentUnitRepository.save(attachmentUnit); + idOfAttachmentUnitOfLectureOne = attachmentUnit.getId(); + ExerciseUnit textExerciseUnit = new ExerciseUnit(); textExerciseUnit.setExercise(textExercise); exerciseUnitRepository.save(textExerciseUnit); @@ -219,7 +247,7 @@ private void creatingLectureUnitsOfLecture(Competency competency) { teamTextExerciseUnit.setExercise(teamTextExercise); exerciseUnitRepository.save(teamTextExerciseUnit); - for (LectureUnit lectureUnit : List.of(textUnit, textExerciseUnit, teamTextExerciseUnit)) { + for (LectureUnit lectureUnit : List.of(textUnit, attachmentUnit, textExerciseUnit, teamTextExerciseUnit)) { lecture.addLectureUnit(lectureUnit); } @@ -235,10 +263,10 @@ private Lecture createLecture(Course course) { return lecture; } - private TextExercise createTextExercise(ZonedDateTime pastTimestamp, ZonedDateTime futureTimestamp, ZonedDateTime futureFutureTimestamp, Set competencies, + private TextExercise createTextExercise(ZonedDateTime releaseDate, ZonedDateTime dueDate, ZonedDateTime assassmentDueDate, Set competencies, boolean isTeamExercise) { // creating text exercise with Result - TextExercise textExercise = TextExerciseFactory.generateTextExercise(pastTimestamp, futureTimestamp, futureFutureTimestamp, course); + TextExercise textExercise = TextExerciseFactory.generateTextExercise(releaseDate, dueDate, assassmentDueDate, course); if (isTeamExercise) { textExercise.setMode(ExerciseMode.TEAM); @@ -247,28 +275,52 @@ private TextExercise createTextExercise(ZonedDateTime pastTimestamp, ZonedDateTi textExercise.setMaxPoints(10.0); textExercise.setBonusPoints(0.0); textExercise.setCompetencies(competencies); - exerciseRepository.save(textExercise); - return textExercise; + return exerciseRepository.save(textExercise); } - private void createTextExerciseParticipationSubmissionAndResult(Exercise exercise, Participant participant, Double pointsOfExercise, Double bonusPointsOfExercise, + private ProgrammingExercise createProgrammingExercise(int i, Set competencies) { + ProgrammingExercise programmingExercise = ProgrammingExerciseFactory.generateProgrammingExercise(null, null, course); + + programmingExercise.setMaxPoints(i * 10.0); + programmingExercise.setCompetencies(competencies); + programmingExercise.setDifficulty(i == 1 ? DifficultyLevel.EASY : i == 2 ? DifficultyLevel.MEDIUM : DifficultyLevel.HARD); + + return programmingExerciseRepository.save(programmingExercise); + } + + private Result createTextExerciseParticipationSubmissionAndResult(TextExercise exercise, Participant participant, double pointsOfExercise, double bonusPointsOfExercise, long scoreAwarded, boolean rated) { + StudentParticipation studentParticipation = participationService.startExercise(exercise, participant, false); + return createExerciseParticipationSubmissionAndResult(exercise, studentParticipation, participant, pointsOfExercise, bonusPointsOfExercise, scoreAwarded, rated, + TextSubmission::new, 1); + } + + private Result createProgrammingExerciseParticipationSubmissionAndResult(ProgrammingExercise exercise, Participant participant, long scoreAwarded, boolean rated, + int numberOfSubmissions) { + StudentParticipation studentParticipation = participationUtilService.createAndSaveParticipationForExercise(exercise, participant.getParticipantIdentifier()); + return createExerciseParticipationSubmissionAndResult(exercise, studentParticipation, participant, exercise.getMaxPoints(), 0, scoreAwarded, rated, + ProgrammingSubmission::new, numberOfSubmissions); + } + + private Result createExerciseParticipationSubmissionAndResult(Exercise exercise, StudentParticipation studentParticipation, Participant participant, double pointsOfExercise, + double bonusPointsOfExercise, long scoreAwarded, boolean rated, Producer submissionConstructor, int numberOfSubmissions) { if (!exercise.getMaxPoints().equals(pointsOfExercise)) { exercise.setMaxPoints(pointsOfExercise); } if (!exercise.getBonusPoints().equals(bonusPointsOfExercise)) { exercise.setBonusPoints(bonusPointsOfExercise); } - exercise = exerciseRepository.save(exercise); - - StudentParticipation studentParticipation = participationService.startExercise(exercise, participant, false); + exerciseRepository.save(exercise); - Submission submission = new TextSubmission(); + Submission submission = null; - submission.setType(SubmissionType.MANUAL); - submission.setParticipation(studentParticipation); - submission = submissionRepository.save(submission); + for (int i = 0; i < numberOfSubmissions; i++) { + submission = submissionConstructor.call(); + submission.setType(SubmissionType.MANUAL); + submission.setParticipation(studentParticipation); + submission = submissionRepository.save(submission); + } // result Result result = ParticipationFactory.generateResult(rated, scoreAwarded); @@ -279,6 +331,8 @@ private void createTextExerciseParticipationSubmissionAndResult(Exercise exercis submission.addResult(result); result.setSubmission(submission); submissionRepository.save(submission); + + return result; } @Nested @@ -360,12 +414,11 @@ void shouldReturnCompetencyForStudent() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void testShouldOnlySendUserSpecificData() throws Exception { - User student1 = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); - competencyProgressUtilService.createCompetencyProgress(competency, student1, 0, 0); + competencyProgressUtilService.createCompetencyProgress(competency, student1, 0, 1); User student2 = userUtilService.getUserByLogin(TEST_PREFIX + "student2"); - competencyProgressUtilService.createCompetencyProgress(competency, student2, 1, 1); + competencyProgressUtilService.createCompetencyProgress(competency, student2, 10, 1); final var textUnit = textUnitRepository.findById(idOfTextUnitOfLectureOne).get(); lectureUtilService.completeLectureUnitForUser(textUnit, student2); @@ -574,7 +627,7 @@ void deleteLectureShouldUpdateCompetency() throws Exception { void deleteLectureUnitShouldUpdateCompetency() throws Exception { request.delete("/api/lectures/" + lecture.getId() + "/lecture-units/" + idOfTextUnitOfLectureOne, HttpStatus.OK); Competency competency = request.get("/api/courses/" + course.getId() + "/competencies/" + this.competency.getId(), HttpStatus.OK, Competency.class); - assertThat(competency.getLectureUnits()).isEmpty(); + assertThat(competency.getLectureUnits()).map(LectureUnit::getId).containsExactly(idOfAttachmentUnitOfLectureOne); } @Nested @@ -621,28 +674,68 @@ void shouldGetCompetencyCourseProgress() throws Exception { } } - @Test - @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void getCompetencyStudentProgressShouldReturnProgress() throws Exception { - User student1 = userRepository.findOneByLogin(TEST_PREFIX + "student1").orElseThrow(); - lectureUnitService.setLectureUnitCompletion(textUnitRepository.findById(idOfTextUnitOfLectureOne).orElseThrow(), student1, true); + @Nested + class GetCompetencyStudentProgress { - createTextExerciseParticipationSubmissionAndResult(textExercise, student1, 10.0, 0.0, 90, true); // will be ignored in favor of last submission from team - createTextExerciseParticipationSubmissionAndResult(textExercise, student1, 10.0, 0.0, 85, false); + private ProgrammingExercise[] programmingExercises; - await().until(() -> participantScoreScheduleService.isIdle()); + @BeforeEach + void setupTestScenario() { + programmingExercises = IntStream.range(1, 4).mapToObj(i -> createProgrammingExercise(i, Set.of(competency))).toArray(ProgrammingExercise[]::new); + } - CompetencyProgress studentCompetencyProgress1 = request.get("/api/courses/" + course.getId() + "/competencies/" + competency.getId() + "/student-progress?refresh=true", - HttpStatus.OK, CompetencyProgress.class); + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void getCompetencyStudentProgressShouldReturnProgress() throws Exception { + User student1 = userRepository.findOneByLogin(TEST_PREFIX + "student1").orElseThrow(); + lectureUnitService.setLectureUnitCompletion(textUnitRepository.findById(idOfTextUnitOfLectureOne).orElseThrow(), student1, true); - assertThat(studentCompetencyProgress1.getProgress()).isEqualTo(66.7); - assertThat(studentCompetencyProgress1.getConfidence()).isEqualTo(85.0); + createTextExerciseParticipationSubmissionAndResult(textExercise, student1, 10.0, 0.0, 90, true); // will be ignored in favor of last submission from team + createTextExerciseParticipationSubmissionAndResult(textExercise, student1, 10.0, 0.0, 85, false); + + await().until(() -> participantScoreScheduleService.isIdle()); - CompetencyProgress studentCompetencyProgress2 = request.get("/api/courses/" + course.getId() + "/competencies/" + competency.getId() + "/student-progress?refresh=false", - HttpStatus.OK, CompetencyProgress.class); + CompetencyProgress studentCompetencyProgress1 = request.get("/api/courses/" + course.getId() + "/competencies/" + competency.getId() + "/student-progress?refresh=true", + HttpStatus.OK, CompetencyProgress.class); - assertThat(studentCompetencyProgress2.getProgress()).isEqualTo(66.7); - assertThat(studentCompetencyProgress2.getConfidence()).isEqualTo(85.0); + assertThat(studentCompetencyProgress1.getProgress()).isEqualTo(22); + assertThat(studentCompetencyProgress1.getConfidence()).isEqualTo(0.75); + + lectureUnitService.setLectureUnitCompletion(attachmentUnitRepository.findById(idOfAttachmentUnitOfLectureOne).orElseThrow(), student1, true); + + CompetencyProgress studentCompetencyProgress2 = request + .get("/api/courses/" + course.getId() + "/competencies/" + competency.getId() + "/student-progress?refresh=false", HttpStatus.OK, CompetencyProgress.class); + + assertThat(studentCompetencyProgress2.getProgress()).isEqualTo(22); + assertThat(studentCompetencyProgress2.getConfidence()).isEqualTo(0.75); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void getCompetencyStudentProgressMultipleExercises() throws Exception { + // The scheduling for all results interferes with the competency progress calculation, since only one per second is allowed + // Therefore creating the participant scores manually + participantScoreScheduleService.shutdown(); + + User student1 = userRepository.findOneByLogin(TEST_PREFIX + "student1").orElseThrow(); + Result textResult = createTextExerciseParticipationSubmissionAndResult(textExercise, student1, textExercise.getMaxPoints(), 0.0, 90, true); + Result programming1Result = createProgrammingExerciseParticipationSubmissionAndResult(programmingExercises[0], student1, 85, true, 10); + Result programming2Result = createProgrammingExerciseParticipationSubmissionAndResult(programmingExercises[1], student1, 75, false, 1); + Result programming3Result = createProgrammingExerciseParticipationSubmissionAndResult(programmingExercises[2], student1, 95, false, 1); + + studentScoreUtilService.createStudentScore(textExercise, student1, textResult); + studentScoreUtilService.createStudentScore(programmingExercises[0], student1, programming1Result); + studentScoreUtilService.createStudentScore(programmingExercises[1], student1, programming2Result); + studentScoreUtilService.createStudentScore(programmingExercises[2], student1, programming3Result); + + CompetencyProgress studentCompetencyProgress = request.get("/api/courses/" + course.getId() + "/competencies/" + competency.getId() + "/student-progress?refresh=true", + HttpStatus.OK, CompetencyProgress.class); + + // No lecture units are completed and no participation in team exercise + assertThat(studentCompetencyProgress.getProgress()).isEqualTo(54); + // Slightly more points in an easy exercise but solved one programming exercise quickly + assertThat(studentCompetencyProgress.getConfidence()).isCloseTo(1.328, within(0.001)); + } } @Nested diff --git a/src/test/java/de/tum/in/www1/artemis/lecture/LectureUnitIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/lecture/LectureUnitIntegrationTest.java index 3facb541173b..34b345f29d8a 100644 --- a/src/test/java/de/tum/in/www1/artemis/lecture/LectureUnitIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/lecture/LectureUnitIntegrationTest.java @@ -243,7 +243,6 @@ void setLectureUnitCompletion() throws Exception { assertThat(lectureUnit.getCompletedUsers()).isNotEmpty(); assertThat(lectureUnit.isCompletedFor(userRepo.getUser())).isTrue(); - assertThat(lectureUnit.getCompletionDate(userRepo.getUser())).isNotNull(); // Set lecture unit as uncompleted for user request.postWithoutLocation("/api/lectures/" + lecture1.getId() + "/lecture-units/" + lecture1.getLectureUnits().get(0).getId() + "/completion?completed=false", null, @@ -254,7 +253,6 @@ void setLectureUnitCompletion() throws Exception { assertThat(lectureUnit.getCompletedUsers()).isEmpty(); assertThat(lectureUnit.isCompletedFor(userRepo.getUser())).isFalse(); - assertThat(lectureUnit.getCompletionDate(userRepo.getUser())).isEmpty(); } @Test diff --git a/src/test/java/de/tum/in/www1/artemis/lecture/LectureUtilService.java b/src/test/java/de/tum/in/www1/artemis/lecture/LectureUtilService.java index 92afa619514d..fb3f1d3074d9 100644 --- a/src/test/java/de/tum/in/www1/artemis/lecture/LectureUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/lecture/LectureUtilService.java @@ -10,6 +10,7 @@ import java.util.Set; import org.apache.commons.io.FileUtils; +import org.hibernate.Hibernate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.ResourceUtils; @@ -352,6 +353,11 @@ public LectureUnit completeLectureUnitForUser(LectureUnit lectureUnit, User user lectureUnitCompletion.setUser(user); lectureUnitCompletion.setCompletedAt(ZonedDateTime.now()); lectureUnitCompletion = lectureUnitCompletionRepository.save(lectureUnitCompletion); + + if (Hibernate.isInitialized(lectureUnit.getCompletedUsers())) { + lectureUnit.getCompletedUsers().add(lectureUnitCompletion); + } + return lectureUnitCompletion.getLectureUnit(); } } diff --git a/src/test/java/de/tum/in/www1/artemis/service/LearningObjectServiceTest.java b/src/test/java/de/tum/in/www1/artemis/service/LearningObjectServiceTest.java new file mode 100644 index 000000000000..68ff09304bcf --- /dev/null +++ b/src/test/java/de/tum/in/www1/artemis/service/LearningObjectServiceTest.java @@ -0,0 +1,93 @@ +package de.tum.in.www1.artemis.service; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; + +import de.tum.in.www1.artemis.AbstractSpringIntegrationIndependentTest; +import de.tum.in.www1.artemis.StudentScoreUtilService; +import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.LearningObject; +import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.domain.competency.Competency; +import de.tum.in.www1.artemis.exercise.programming.ProgrammingExerciseUtilService; +import de.tum.in.www1.artemis.lecture.LectureFactory; +import de.tum.in.www1.artemis.lecture.LectureUtilService; + +class LearningObjectServiceTest extends AbstractSpringIntegrationIndependentTest { + + private static final String TEST_PREFIX = "learningobjectservice"; + + @Autowired + private LearningObjectService learningObjectService; + + @Autowired + private StudentScoreUtilService studentScoreUtilService; + + @Autowired + private LectureUtilService lectureUtilService; + + @Autowired + private ProgrammingExerciseUtilService programmingExerciseUtilService; + + private User student; + + private Course course; + + @BeforeEach + void setup() { + userUtilService.addUsers(TEST_PREFIX, 1, 0, 0, 0); + student = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); + course = programmingExerciseUtilService.addCourseWithOneProgrammingExercise(); + } + + @ParameterizedTest(name = "{displayName} [{index}] {arguments}") + @ValueSource(booleans = { true, false }) + void testIsCompletedByUserExercise(boolean completed) { + var programmingExercise = course.getExercises().stream().findFirst().get(); + studentScoreUtilService.createStudentScore(programmingExercise, student, completed ? 84.0 : 42.0); + + assertThat(learningObjectService.isCompletedByUser(programmingExercise, student)).isEqualTo(completed); + } + + @ParameterizedTest(name = "{displayName} [{index}] {arguments}") + @ValueSource(booleans = { true, false }) + void testIsCompletedByUserLectureUnit(boolean completed) { + var lectureUnit = LectureFactory.generateAttachmentUnit(); + + if (completed) { + lectureUtilService.completeLectureUnitForUser(lectureUnit, student); + } + + assertThat(learningObjectService.isCompletedByUser(lectureUnit, student)).isEqualTo(completed); + } + + @Test + void testIsCompletedByUserInvalidLearningObject() { + LearningObject unexpectedSubclass = new LearningObject() { + + @Override + public boolean isCompletedFor(User user) { + return false; + } + + @Override + public Long getId() { + return 0L; + } + + @Override + public Set getCompetencies() { + return Set.of(); + } + }; + assertThatThrownBy(() -> learningObjectService.isCompletedByUser(unexpectedSubclass, student)).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/de/tum/in/www1/artemis/service/LearningPathServiceTest.java b/src/test/java/de/tum/in/www1/artemis/service/LearningPathServiceTest.java index c9385f311fcb..82178e1bbef7 100644 --- a/src/test/java/de/tum/in/www1/artemis/service/LearningPathServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/service/LearningPathServiceTest.java @@ -320,7 +320,7 @@ class GenerateNgxPathRepresentation { @BeforeEach void setup() { final var users = userUtilService.addUsers(TEST_PREFIX, 1, 0, 0, 0); - user = users.get(0); + user = users.getFirst(); course = CourseFactory.generateCourse(null, ZonedDateTime.now().minusDays(8), ZonedDateTime.now().minusDays(8), new HashSet<>(), TEST_PREFIX + "tumuser", TEST_PREFIX + "tutor", TEST_PREFIX + "editor", TEST_PREFIX + "instructor"); course = courseRepository.save(course); @@ -369,7 +369,7 @@ class GenerateNgxPathRepresentationCompetencyOrder { @BeforeEach void setup() { final var users = userUtilService.addUsers(TEST_PREFIX, 1, 0, 0, 0); - user = users.get(0); + user = users.getFirst(); course = CourseFactory.generateCourse(null, ZonedDateTime.now().minusDays(8), ZonedDateTime.now().minusDays(8), new HashSet<>(), TEST_PREFIX + "tumuser", TEST_PREFIX + "tutor", TEST_PREFIX + "editor", TEST_PREFIX + "instructor"); course = courseRepository.save(course); @@ -474,8 +474,8 @@ void testOrderOfCompetenciesByMasteryUtility() { competency2.setMasteryThreshold(100); competency2 = competencyRepository.save(competency2); - competencyProgressUtilService.createCompetencyProgress(competency1, user, 30, 30); - competencyProgressUtilService.createCompetencyProgress(competency2, user, 10, 10); + competencyProgressUtilService.createCompetencyProgress(competency1, user, 30, 1.1); + competencyProgressUtilService.createCompetencyProgress(competency2, user, 10, 0.9); var sourceNodeId = LearningPathNgxService.getCompetencyEndNodeId(competency1.getId()); var targetNodeId = LearningPathNgxService.getCompetencyStartNodeId(competency2.getId()); @@ -486,7 +486,7 @@ void testOrderOfCompetenciesByMasteryUtility() { private void masterCompetencies(Competency... competencies) { for (var competency : competencies) { - competencyProgressUtilService.createCompetencyProgress(competency, user, 100, 100); + competencyProgressUtilService.createCompetencyProgress(competency, user, 100, 1); } } } @@ -509,7 +509,7 @@ class GenerateNgxPathRepresentationLearningObjectOrder { @BeforeEach void setup() { final var users = userUtilService.addUsers(TEST_PREFIX, 1, 0, 0, 0); - user = users.get(0); + user = users.getFirst(); course = CourseFactory.generateCourse(null, ZonedDateTime.now().minusDays(8), ZonedDateTime.now().minusDays(8), new HashSet<>(), TEST_PREFIX + "tumuser", TEST_PREFIX + "tutor", TEST_PREFIX + "editor", TEST_PREFIX + "instructor"); course = courseRepository.save(course); @@ -517,7 +517,7 @@ void setup() { lecture = lectureUtilService.createLecture(course, ZonedDateTime.now()); competency = competencyUtilService.createCompetency(course); - competency.setMasteryThreshold(90); + competency.setMasteryThreshold(100); competency = competencyRepository.save(competency); expectedNodes = new HashSet<>(getExpectedNodesOfEmptyCompetency(competency)); expectedEdges = new HashSet<>(); @@ -574,11 +574,11 @@ void testAvoidReschedulingCompletedLearningObjects() { @Test void testRecommendCorrectAmountOfLearningObjects() { - competency.setMasteryThreshold(55); + competency.setMasteryThreshold(40); competency = competencyRepository.save(competency); generateLectureUnits(1); - generateExercises(10); + generateExercises(9); exercises[0].setDifficulty(DifficultyLevel.EASY); exercises[1].setDifficulty(DifficultyLevel.MEDIUM); exercises[2].setDifficulty(DifficultyLevel.HARD); diff --git a/src/test/java/de/tum/in/www1/artemis/service/util/ZonedDateTimeUtilTest.java b/src/test/java/de/tum/in/www1/artemis/service/util/TimeUtilTest.java similarity index 91% rename from src/test/java/de/tum/in/www1/artemis/service/util/ZonedDateTimeUtilTest.java rename to src/test/java/de/tum/in/www1/artemis/service/util/TimeUtilTest.java index 3c2bd6863b37..f712c049019f 100644 --- a/src/test/java/de/tum/in/www1/artemis/service/util/ZonedDateTimeUtilTest.java +++ b/src/test/java/de/tum/in/www1/artemis/service/util/TimeUtilTest.java @@ -1,13 +1,13 @@ package de.tum.in.www1.artemis.service.util; -import static de.tum.in.www1.artemis.service.util.ZonedDateTimeUtil.toRelativeTime; +import static de.tum.in.www1.artemis.service.util.TimeUtil.toRelativeTime; import static org.assertj.core.api.Assertions.assertThat; import java.time.ZonedDateTime; import org.junit.jupiter.api.Test; -class ZonedDateTimeUtilTest { +class TimeUtilTest { @Test void testToRelativeTimeAtStartTime() { diff --git a/src/test/javascript/spec/component/competencies/competency-accordion.component.spec.ts b/src/test/javascript/spec/component/competencies/competency-accordion.component.spec.ts new file mode 100644 index 000000000000..2c9bbcfcb5f7 --- /dev/null +++ b/src/test/javascript/spec/component/competencies/competency-accordion.component.spec.ts @@ -0,0 +1,61 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { MockComponent, MockPipe, MockProvider } from 'ng-mocks'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { NgbTooltipMocksModule } from '../../helpers/mocks/directive/ngbTooltipMocks.module'; +import { CompetencyAccordionComponent } from 'app/course/competencies/competency-accordion/competency-accordion.component'; +import { CompetencyRingsComponent } from 'app/course/competencies/competency-rings/competency-rings.component'; +import { CompetencyMetrics, ExerciseInformation, ExerciseMetrics, LectureUnitInformation, LectureUnitStudentMetricsDTO, StudentMetrics } from 'app/entities/student-metrics.model'; +import dayjs from 'dayjs/esm'; +import { ExerciseType } from 'app/entities/exercise.model'; +import { LectureUnitType } from 'app/entities/lecture-unit/lectureUnit.model'; + +describe('CompetencyAccordionComponent', () => { + let fixture: ComponentFixture; + let component: CompetencyAccordionComponent; + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NgbTooltipMocksModule], + declarations: [CompetencyAccordionComponent, MockPipe(ArtemisTranslatePipe), MockComponent(FaIconComponent), MockComponent(CompetencyRingsComponent)], + providers: [MockProvider(TranslateService)], + schemas: [], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(CompetencyAccordionComponent); + component = fixture.componentInstance; + }); + }); + + it('should calculate exercise progress', () => { + const exerciseInformation0: ExerciseInformation = { maxPoints: 10, startDate: dayjs(), title: '', type: ExerciseType.PROGRAMMING, id: 0 }; + const exerciseInformation1: ExerciseInformation = { maxPoints: 20, startDate: dayjs(), title: '', type: ExerciseType.MODELING, id: 1 }; + const exerciseMetrics: ExerciseMetrics = { exerciseInformation: { 0: exerciseInformation0, 1: exerciseInformation1 }, score: { 0: 80, 1: 40 }, completed: [0] }; + const competencyMetrics: CompetencyMetrics = { exercises: { 42: [0, 1] } }; + const metrics: StudentMetrics = { exerciseMetrics, competencyMetrics }; + component.competency = { description: '', masteryThreshold: 80, optional: false, title: '', id: 42 }; + component.metrics = metrics; + const progress = component.calculateExercisesProgress(); + // achieved points decided by total points + expect(progress).toBeCloseTo((80 * 10 + 40 * 20) / (10 + 20), 1); + }); + + it('should calculate lecture progress', () => { + const before = dayjs().subtract(1, 'day'); + const lectureUnitInformation0: LectureUnitInformation = { lectureTitle: '', name: '', type: LectureUnitType.ATTACHMENT, id: 0, lectureId: 21, releaseDate: before }; + const lectureUnitInformation1: LectureUnitInformation = { lectureTitle: '', name: '', type: LectureUnitType.TEXT, id: 1, lectureId: 21, releaseDate: before }; + const lectureUnitInformation2: LectureUnitInformation = { lectureTitle: '', name: '', type: LectureUnitType.VIDEO, id: 2, lectureId: 21, releaseDate: before }; + const lectureUnitStudentMetricsDTO: LectureUnitStudentMetricsDTO = { + lectureUnitInformation: { 0: lectureUnitInformation0, 1: lectureUnitInformation1, 2: lectureUnitInformation2 }, + completed: [0, 2], + }; + const competencyMetrics: CompetencyMetrics = { lectureUnits: { 42: [0, 1, 2] } }; + const metrics: StudentMetrics = { lectureUnitStudentMetricsDTO, competencyMetrics }; + component.competency = { description: '', masteryThreshold: 80, optional: false, title: '', id: 42 }; + component.metrics = metrics; + const progress = component.calculateLectureUnitsProgress(); + // completed 2 out of 3 lecture units + expect(progress).toBeCloseTo((2 / 3) * 100, 0); + }); +}); diff --git a/src/test/javascript/spec/component/competencies/competency-card.component.spec.ts b/src/test/javascript/spec/component/competencies/competency-card.component.spec.ts index b0d87deab169..0424545cf688 100644 --- a/src/test/javascript/spec/component/competencies/competency-card.component.spec.ts +++ b/src/test/javascript/spec/component/competencies/competency-card.component.spec.ts @@ -47,7 +47,7 @@ describe('CompetencyCardComponent', () => { userProgress: [ { progress: 45, - confidence: 60, + confidence: 1.1, } as CompetencyProgress, ], } as Competency; @@ -55,8 +55,7 @@ describe('CompetencyCardComponent', () => { competencyCardComponentFixture.detectChanges(); expect(competencyCardComponent.progress).toBe(45); - expect(competencyCardComponent.confidence).toBe(75); - expect(competencyCardComponent.mastery).toBe(65); + expect(competencyCardComponent.mastery).toBe(Math.round(45 * 1.1)); expect(competencyCardComponent.isMastered).toBeFalse(); }); @@ -75,7 +74,6 @@ describe('CompetencyCardComponent', () => { competencyCardComponentFixture.detectChanges(); expect(competencyCardComponent.progress).toBe(100); - expect(competencyCardComponent.confidence).toBe(100); expect(competencyCardComponent.mastery).toBe(100); expect(competencyCardComponent.isMastered).toBeTrue(); }); diff --git a/src/test/javascript/spec/component/course/competency-rings.component.spec.ts b/src/test/javascript/spec/component/course/competency-rings.component.spec.ts index 3db94a5be427..573958737443 100644 --- a/src/test/javascript/spec/component/course/competency-rings.component.spec.ts +++ b/src/test/javascript/spec/component/course/competency-rings.component.spec.ts @@ -21,7 +21,6 @@ describe('CompetencyRings', () => { component = fixture.componentInstance; component.progress = 110; - component.confidence = 50; component.mastery = -10; }); }); @@ -34,7 +33,6 @@ describe('CompetencyRings', () => { fixture.detectChanges(); expect(component.progressPercentage).toBe(100); - expect(component.confidencePercentage).toBe(50); expect(component.masteryPercentage).toBe(0); }); @@ -47,16 +45,11 @@ describe('CompetencyRings', () => { it('should visualize using progress bars', () => { fixture.detectChanges(); - const masteryRing = fixture.debugElement.query(By.css('.ring1 .progressbar')); + const masteryRing = fixture.debugElement.query(By.css('.mastery-ring .progressbar')); expect(masteryRing).toBeTruthy(); expect(masteryRing.styles.opacity).toBe('0'); - const confidenceRing = fixture.debugElement.query(By.css('.ring2 .progressbar')); - expect(confidenceRing).toBeTruthy(); - expect(confidenceRing.styles.opacity).toBe('1'); - expect(confidenceRing.nativeElement.getAttribute('stroke-dasharray')).toBe('50, 100'); - - const progressRing = fixture.debugElement.query(By.css('.ring3 .progressbar')); + const progressRing = fixture.debugElement.query(By.css('.progress-ring .progressbar')); expect(progressRing).toBeTruthy(); expect(progressRing.styles.opacity).toBe('1'); expect(progressRing.nativeElement.getAttribute('stroke-dasharray')).toBe('100, 100');