For all participants in the experiment and their latest code change.
+ *
+ * @param experimentId The experiment id.
+ * @return The progress-variance projection.
+ * @throws IOException In case the example/solution projects cannot be parsed.
+ */
+ public ProgramProjection2D getProgressVarianceProjectionAllLatest(
+ final int experimentId
+ ) throws IOException {
+ StopWatch watch = new StopWatch();
+ watch.start();
+
+ final Map projectIdToStudentId = new HashMap<>();
+ final Map projectsById = new HashMap<>();
+ blockEventRepository
+ .findLastPerUserInExperiment(experimentId)
+ .forEach(project -> {
+ projectsById.put(project.id(), project.projectJson());
+ projectIdToStudentId.put(project.id(), project.userId());
+ });
+ if (projectsById.isEmpty()) {
+ return new ProgramProjection2D(Collections.emptyList());
+ }
+
+ final var starterProject = getStarterProject(experimentId);
+ final var solutionProject = getSolutionProject(experimentId);
+ if (starterProject == null || solutionProject == null) {
+ throw new IllegalArgumentException(
+ "Progress-Variance-Projection can only be constructed if start and solution projects are given."
+ );
+ }
+ watch.stop();
+ log.debug("Database fetching finished in {}ms.", watch.getTotalTimeMillis());
+
+ final ProgressVarianceProjectionResponse response = getProgressVarianceProjection(
+ starterProject,
+ solutionProject,
+ projectsById
+ );
+
+ return convertResponse(response, projectIdToStudentId);
+ }
+
+ /**
+ * Computes the progress-variance-projection for the given experiment.
+ *
+ *
As a timeline of program states of all given users in the experiment.
+ *
+ * @param experimentId The experiment id.
+ * @param userIds The set of users for which the progression timeline should be generated.
+ * @param stepMinutes The step in minutes between program states.
+ * @return The progress-variance projection.
+ * @throws IOException In case the example/solution projects cannot be parsed.
+ */
+ public ProgramProjection2D getProgressVarianceProjectionForUsers(
+ final int experimentId,
+ final Set userIds,
+ final int stepMinutes
+ ) throws IOException {
+ if (userIds.isEmpty()) {
+ return new ProgramProjection2D(Collections.emptyList());
+ }
+
+ StopWatch watch = new StopWatch();
+ watch.start();
+
+ final Map projectsByProjectId = new HashMap<>();
+ final Map> projectsByStudent = new HashMap<>();
+ for (final int userId : userIds) {
+ final List studentProjects = getProjectsForUser(experimentId, userId, stepMinutes);
+ studentProjects.forEach(project -> projectsByProjectId.put(project.id(), project.projectJson()));
+ projectsByStudent.put(userId, studentProjects.stream().map(Project::id).toList());
+ }
+
+ final var starterProject = getStarterProject(experimentId);
+ final var solutionProject = getSolutionProject(experimentId);
+ if (starterProject == null || solutionProject == null) {
+ throw new IllegalArgumentException(
+ "Progress-Variance-Projection can only be constructed if start and solution projects are given."
+ );
+ }
+ watch.stop();
+ log.debug("Database fetching finished in {}ms.", watch.getTotalTimeMillis());
+
+ final ProgressVarianceProjectionResponse response = getProgressVarianceProjection(
+ starterProject,
+ solutionProject,
+ projectsByProjectId
+ );
+ if (response == null) {
+ return new ProgramProjection2D(Collections.emptyList());
+ }
+
+ return convertPerStudentResponse(response, projectsByStudent);
+ }
+
+ private List getProjectsForUser(final int experimentId, final int userId, final int stepMinutes) {
+ return codeService.getFilteredJsons(userId, experimentId, stepMinutes, 0, 0, Optional.empty())
+ .stream()
+ .sorted(Comparator.comparing(BlockEventJSONProjection::getDate))
+ .map(projection -> new Project(projection.getId(), userId, null, projection.getCode()))
+ .toList();
+ }
+
+ private ProgramProjection2D convertResponse(
+ final ProgressVarianceProjectionResponse response,
+ final Map projectIdToStudentId
+ ) {
+ final Map>> datapoints = new HashMap<>();
+ for (final Projection projection : response.projections()) {
+ final int userId = projectIdToStudentId.get(projection.id());
+ datapoints.compute(userId, (k, ps) -> {
+ if (ps == null) {
+ ps = new ArrayList<>();
+ }
+ ps.add(projection.xy());
+ return ps;
+ });
+ }
+
+ final List data = new ArrayList<>(datapoints.size());
+ for (final var entry : datapoints.entrySet()) {
+ data.add(new DataSeries(entry.getKey(), entry.getValue()));
+ }
+
+ return new ProgramProjection2D(data);
+ }
+
+ private ProgramProjection2D convertPerStudentResponse(
+ final ProgressVarianceProjectionResponse response,
+ final Map> studentProjects
+ ) {
+ final Map> rawDatapointsByProjectId = new HashMap<>();
+ for (final Projection projection : response.projections()) {
+ rawDatapointsByProjectId.put(projection.id(), projection.xy());
+ }
+
+ final Map>> datapoints = new HashMap<>();
+ for (final var entry : studentProjects.entrySet()) {
+ final List> studentProjections = new ArrayList<>();
+ for (final var projectId : entry.getValue()) {
+ studentProjections.add(rawDatapointsByProjectId.get(projectId));
+ }
+ datapoints.put(entry.getKey(), studentProjections);
+ }
+
+ final List data = new ArrayList<>(datapoints.size());
+ for (final var entry : datapoints.entrySet()) {
+ data.add(new DataSeries(entry.getKey(), entry.getValue()));
+ }
+
+ return new ProgramProjection2D(data);
+ }
+
+ @Nullable
+ private String getStarterProject(final int experimentId) throws IOException {
+ final byte[] starterProjectSb3 = experimentRepository.getExperimentStarterProject(experimentId);
+ if (starterProjectSb3 == null) {
+ return null;
+ }
+
+ return getProjectJson(starterProjectSb3);
+ }
+
+ @Nullable
+ private String getSolutionProject(final int experimentId) throws IOException {
+ final ExampleSolution solutionSb3 = experimentService.getExampleSolution(experimentId);
+ if (solutionSb3 == null) {
+ return null;
+ }
+
+ return getProjectJson(solutionSb3.getSb3Project());
+ }
+
+ /**
+ * Returns the progress variance projection for the given projects.
+ *
+ * @param templateProject The template given to all students at the beginning of a session
+ * @param solutionProject The example solution of a session.
+ * @param studentProjects Some student projects. The same IDs will be used in the response. Can
+ * be for example the latest projects per student, or only projects of a
+ * single student over time.
+ * @return The 2D-progress-variance-projection of the student projects.
+ */
+ private ProgressVarianceProjectionResponse getProgressVarianceProjection(
+ final String templateProject,
+ final String solutionProject,
+ final Map studentProjects
+ ) {
+ final StopWatch watch = new StopWatch();
+ watch.start();
+ final var request = buildProgressVarianceProjectionRequest(
+ templateProject, solutionProject, studentProjects
+ );
+ watch.stop();
+ log.debug("preprocessing done in {}ms.", watch.getTotalTimeMillis());
+
+ return apiRequest(
+ codeEmbeddingConfiguration.getModel() + "/progress-variance-projection",
+ request,
+ ProgressVarianceProjectionResponse.class
+ );
+ }
+
+ private ProgressVarianceProjectionRequest buildProgressVarianceProjectionRequest(
+ final String templateProgramJson,
+ final String solutionProgramJson,
+ final Map studentProgramJsons
+ ) {
+ final var templateProgram = getProcessedProgram(templateProgramJson);
+ final var solutionProgram = getProcessedProgram(solutionProgramJson);
+
+ final Map studentPrograms = preprocessStudentProgramJsons(studentProgramJsons);
+
+ return new ProgressVarianceProjectionRequest(
+ templateProgram,
+ solutionProgram,
+ studentPrograms
+ );
+ }
+
+ private Map preprocessStudentProgramJsons(
+ final Map studentProgramJsons
+ ) {
+ return studentProgramJsons
+ .entrySet()
+ .parallelStream()
+ .map(entry -> {
+ try {
+ final var processedProject = getProcessedProgram(entry.getValue());
+ return new AbstractMap.SimpleImmutableEntry<>(
+ entry.getKey(),
+ processedProject
+ );
+ } catch (RuntimeException e) {
+ // ignore projects we cannot parse -> we cannot compute an embedding in this case
+ return null;
+ }
+ })
+ .filter(Objects::nonNull)
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+ }
+
+ /**
+ * Retrieves the embedding distances between latest student projects and the example solution.
+ *
+ * @param experimentId Some experiment.
+ * @return A mapping of user ID to embedding distance in range {@code [0, 1]}.
+ * @throws IOException In case the solution project cannot be parsed.
+ */
+ public Map getEmbeddingDistancesAllLatest(final int experimentId) throws IOException {
+ final StopWatch watch = new StopWatch();
+ watch.start();
+
+ final List projects = blockEventRepository.findLastPerUserInExperiment(experimentId);
+
+ final Map projectIdToUserId = new HashMap<>();
+ projects.forEach(project -> projectIdToUserId.put(project.id(), project.userId()));
+
+ final Map distancesByProjectId = getEmbeddingDistances(experimentId, projects);
+
+ final Map distancesByUserId = new HashMap<>();
+ distancesByProjectId.forEach((projectId, distance) -> {
+ final int userId = projectIdToUserId.get(projectId);
+ distancesByUserId.put(userId, distance);
+ });
+
+ return distancesByUserId;
+ }
+
+ /**
+ * Retrieves the embedding distances between the given projects and the example solution.
+ *
+ * @param experimentId Some experiment.
+ * @param projects Some projects in the experiment.
+ * @return A mapping of project ID to embedding distance in range {@code [0, 1]}.
+ * @throws IOException In case the solution project cannot be parsed.
+ */
+ public Map getEmbeddingDistances(
+ final int experimentId, final List projects
+ ) throws IOException {
+ final StopWatch watch = new StopWatch();
+ watch.start();
+
+ final Map projectsById = new HashMap<>();
+ projects.forEach(project -> projectsById.put(project.id(), project.projectJson()));
+
+ final var solutionProject = getSolutionProject(experimentId);
+ if (solutionProject == null) {
+ throw new IllegalArgumentException(
+ "Embedding distance can only be computed if solution projects is given."
+ );
+ }
+ watch.stop();
+ log.debug("Database fetching finished in {}ms.", watch.getTotalTimeMillis());
+
+ final EmbeddingDistanceResponse response = getEmbeddingDistances(
+ solutionProject,
+ projectsById
+ );
+ if (response == null) {
+ return Collections.emptyMap();
+ }
+
+ final Map distances = new HashMap<>();
+ for (final var distance : response.distances()) {
+ distances.put(distance.id(), distance.d());
+ }
+ return distances;
+ }
+
+ private EmbeddingDistanceResponse getEmbeddingDistances(
+ final String solutionProject,
+ final Map studentProjects
+ ) {
+ final StopWatch watch = new StopWatch();
+ watch.start();
+ final var request = buildEmbeddingDistanceRequest(
+ solutionProject, studentProjects
+ );
+ watch.stop();
+ log.debug("GGNN preprocessing done in {}ms.", watch.getTotalTimeMillis());
+
+ return apiRequest(
+ codeEmbeddingConfiguration.getModel() + "/embedding-distance",
+ request,
+ EmbeddingDistanceResponse.class
+ );
+ }
+
+ private EmbeddingDistanceRequest buildEmbeddingDistanceRequest(
+ final String solutionProgramJson,
+ final Map studentProgramJsons
+ ) {
+ final var solutionProgram = getProcessedProgram(solutionProgramJson);
+ final Map studentPrograms = preprocessStudentProgramJsons(studentProgramJsons);
+
+ return new EmbeddingDistanceRequest(solutionProgram, studentPrograms);
+ }
+
+ /**
+ * Fetches the embedding and test distances for the latest project of all users in the experiment.
+ *
+ *
The resulting datapoints will have the test distance on the x-Axis (0th list element) and the embedding
+ * distance as y-Axis (1st element).
+ *
+ *
Requires the {@link Constants#PROFILE_WHISKER} profile to be active.
+ *
+ * @param experimentId Some experiment.
+ * @return The embedding/test-distance projection for the latest projects of all users.
+ * @throws IllegalStateException In case the {@link Constants#PROFILE_WHISKER} profile is not active.
+ */
+ public ProgramProjection2D getEmbeddingVsTestDistanceLatestProjects(final int experimentId) throws IOException {
+ if (testFitnessService.isEmpty()) {
+ throw new IllegalStateException(
+ "Missing the Whisker test data. Cannot compute embeding/test distance projection."
+ );
+ }
+
+ final List latestProjects = blockEventRepository.findLastPerUserInExperiment(experimentId);
+
+ final Map embeddingDistances = getEmbeddingDistances(experimentId, latestProjects);
+ final Map testFitnesses = testFitnessService.orElseThrow().getTestFitnesses(
+ latestProjects.stream().map(Project::id).collect(Collectors.toSet())
+ );
+
+ final List data = new ArrayList<>();
+ for (final Project project : latestProjects) {
+ final double embeddingDistance = embeddingDistances.getOrDefault(project.id(), 1.0);
+ final double testFitness = testFitnesses.getOrDefault(project.id(), 0.0);
+ final List datapoint = List.of(testFitness, 1 - embeddingDistance);
+
+ data.add(new DataSeries(project.userId(), List.of(datapoint)));
+ }
+
+ return new ProgramProjection2D(data);
+ }
+
+ private WholeProgramOutput processProgramForGgnn(final Program program) {
+ return ggnnProgramPreprocessor.processWholeProgram(program)
+ .findFirst()
+ .orElseThrow();
+ }
+
+ private String getProcessedProgram(final String programJson) {
+ try {
+ return switch (codeEmbeddingConfiguration.getModel()) {
+ case "ggnn" -> jsonMapper.writeValueAsString(ggnnCache.get(programJson));
+ case "llm" -> processProgramForLlm(programJson);
+ default -> throw new IllegalStateException("Unknown model: " + codeEmbeddingConfiguration.getModel());
+ };
+ } catch (ParsingException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private WholeProgramOutput processProgramForGgnn(
+ final String programJson
+ ) throws ParsingException {
+ final Program program = parseProgram(programJson);
+ return processProgramForGgnn(program);
+ }
+
+ private String processProgramForLlm(final String programJson) throws ParsingException {
+ final Program program = parseProgram(programJson);
+ return processProgramForLlm(program);
+ }
+
+ private String processProgramForLlm(final Program program) {
+ return ScratchBlocksVisitor.of(program);
+ }
+
+ private Program parseProgram(final String programJson) throws ParsingException {
+ final Scratch3Parser parser = new Scratch3Parser();
+ return parser.parseString("project", programJson);
+ }
+
+ private String getProjectJson(final byte[] programSb3) throws IOException {
+ if (programSb3 == null) {
+ return null;
+ }
+
+ try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(programSb3))) {
+ ZipEntry entry;
+ while ((entry = zis.getNextEntry()) != null) {
+ if ("project.json".equals(entry.getName())) {
+ byte[] bytes = zis.readNBytes((int) entry.getSize());
+ return new String(bytes, StandardCharsets.UTF_8);
+ }
+ }
+ }
+
+ log.warn("Project SB3 did not contain a project.json");
+
+ return null;
+ }
+
+ private R apiRequest(final String path, final B body, final Class responseType) {
+ final StopWatch watch = new StopWatch();
+
+ try {
+ watch.start();
+ var response = restClient.post()
+ .uri(codeEmbeddingConfiguration.getEmbeddingConnectorUrl().resolve(path))
+ .body(body)
+ .retrieve()
+ .body(responseType);
+ watch.stop();
+ log.debug("Embedding API request done in {}ms.", watch.getTotalTimeMillis());
+
+ return response;
+ } catch (RestClientResponseException | ResourceAccessException e) {
+ log.error("Could not make API call to embedding model.", e);
+ throw e;
+ }
+ }
+
+ public record ProgressVarianceProjectionRequest(
+ String templateProgram,
+ String solutionProgram,
+ Map studentPrograms
+ ) {
+ }
+
+ public record ProgressVarianceProjectionResponse(
+ List projections
+ ) {
+ }
+
+ public record Projection(int id, List xy) {
+ }
+
+ public record ProgramProjection2D(List data) {
+ }
+
+ /**
+ * A series of data.
+ *
+ * @param userId The user which created the datapoints.
+ * @param datapoints The series of datapoints with their x/y coordinates.
+ */
+ public record DataSeries(int userId, List> datapoints) {
+ }
+
+ private record EmbeddingDistanceRequest(
+ String solutionProgram,
+ Map studentPrograms
+ ) {
+ }
+
+ private record EmbeddingDistanceResponse(List distances) {
+ }
+
+ private record Distance(int id, double d) {
+ }
+
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/EventService.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/EventService.java
index 20438fa..889c1aa 100644
--- a/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/EventService.java
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/EventService.java
@@ -40,6 +40,7 @@
import de.uni_passau.fim.se2.scratchlog.persistence.repository.QuestionEventRepository;
import de.uni_passau.fim.se2.scratchlog.persistence.repository.ResourceEventRepository;
import de.uni_passau.fim.se2.scratchlog.persistence.repository.UserRepository;
+import de.uni_passau.fim.se2.scratchlog.spring.events.TestExecutionRequestEvent;
import de.uni_passau.fim.se2.scratchlog.util.enums.LibraryResource;
import de.uni_passau.fim.se2.scratchlog.web.dto.BlockEventDTO;
import de.uni_passau.fim.se2.scratchlog.web.dto.ClickEventDTO;
@@ -54,6 +55,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -74,6 +76,8 @@ public class EventService {
*/
private static final Logger log = LoggerFactory.getLogger(EventService.class);
+ private final ApplicationEventPublisher applicationEventPublisher;
+
/**
* The event count repository to use for event count queries.
*/
@@ -124,31 +128,20 @@ public class EventService {
*/
private final ExperimentRepository experimentRepository;
- /**
- * Constructs an event service with the given dependencies.
- *
- * @param eventCountRepository The {@link EventCountRepository} to use.
- * @param codesDataRepository The {@link CodesDataRepository} to use.
- * @param blockEventRepository The {@link BlockEventRepository} to use.
- * @param clickEventRepository The {@link ClickEventRepository} to use.
- * @param debuggerEventRepository The {@link DebuggerEventRepository} to use.
- * @param questionEventRepository The {@link QuestionEventRepository} to use.
- * @param resourceEventRepository The {@link ResourceEventRepository} to use.
- * @param participantRepository The {@link ParticipantRepository} to use.
- * @param userRepository The {@link UserRepository} to use.
- * @param experimentRepository The {@link ExperimentRepository} to use.
- */
@Autowired
- public EventService(final EventCountRepository eventCountRepository,
- final CodesDataRepository codesDataRepository,
- final BlockEventRepository blockEventRepository,
- final ClickEventRepository clickEventRepository,
- final DebuggerEventRepository debuggerEventRepository,
- final QuestionEventRepository questionEventRepository,
- final ResourceEventRepository resourceEventRepository,
- final ParticipantRepository participantRepository,
- final UserRepository userRepository,
- final ExperimentRepository experimentRepository) {
+ public EventService(
+ final EventCountRepository eventCountRepository,
+ final CodesDataRepository codesDataRepository,
+ final BlockEventRepository blockEventRepository,
+ final ClickEventRepository clickEventRepository,
+ final DebuggerEventRepository debuggerEventRepository,
+ final QuestionEventRepository questionEventRepository,
+ final ResourceEventRepository resourceEventRepository,
+ final ParticipantRepository participantRepository,
+ final UserRepository userRepository,
+ final ExperimentRepository experimentRepository,
+ final ApplicationEventPublisher applicationEventPublisher
+ ) {
this.eventCountRepository = eventCountRepository;
this.codesDataRepository = codesDataRepository;
this.blockEventRepository = blockEventRepository;
@@ -159,6 +152,7 @@ public EventService(final EventCountRepository eventCountRepository,
this.participantRepository = participantRepository;
this.userRepository = userRepository;
this.experimentRepository = experimentRepository;
+ this.applicationEventPublisher = applicationEventPublisher;
}
/**
@@ -175,7 +169,9 @@ public void saveBlockEvent(final BlockEventDTO blockEventDTO) {
if (isParticipant(user, experiment, blockEventDTO.getUser(), blockEventDTO.getExperiment())
&& isValidEvent(user, experiment, blockEventDTO.getDate())) {
BlockEvent blockEvent = createBlockEvent(blockEventDTO, user, experiment);
- blockEventRepository.save(blockEvent);
+ blockEvent = blockEventRepository.save(blockEvent);
+
+ publishBlockEventUploadMessage(experiment.getId(), blockEvent);
}
} catch (ConstraintViolationException e) {
log.error(
@@ -186,6 +182,14 @@ && isValidEvent(user, experiment, blockEventDTO.getDate())) {
}
}
+ private void publishBlockEventUploadMessage(final int experimentId, final BlockEvent blockEvent) {
+ if (blockEvent != null && blockEvent.getCode() != null) {
+ applicationEventPublisher.publishEvent(
+ new TestExecutionRequestEvent(this, experimentId, blockEvent.getId())
+ );
+ }
+ }
+
/**
* Creates a new click event with the given parameters in the database.
*
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/ExperimentService.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/ExperimentService.java
index 360c738..7937cc4 100644
--- a/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/ExperimentService.java
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/ExperimentService.java
@@ -20,11 +20,15 @@
package de.uni_passau.fim.se2.scratchlog.application.service;
import de.uni_passau.fim.se2.scratchlog.application.exception.NotFoundException;
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.ExampleSolution;
import de.uni_passau.fim.se2.scratchlog.persistence.entity.Experiment;
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.TestSuite;
import de.uni_passau.fim.se2.scratchlog.persistence.projection.ExperimentProjection;
+import de.uni_passau.fim.se2.scratchlog.persistence.repository.ExampleSolutionRepository;
import de.uni_passau.fim.se2.scratchlog.persistence.repository.ExperimentRepository;
+import de.uni_passau.fim.se2.scratchlog.persistence.repository.TestSuiteRepository;
import de.uni_passau.fim.se2.scratchlog.web.dto.ExperimentDTO;
-import jakarta.persistence.EntityNotFoundException;
+import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -49,14 +53,19 @@ public class ExperimentService {
*/
private final ExperimentRepository experimentRepository;
- /**
- * Constructs an experiment service with the given dependencies.
- *
- * @param experimentRepository The experiment repository to use.
- */
+ private final ExampleSolutionRepository exampleSolutionRepository;
+
+ private final TestSuiteRepository testSuiteRepository;
+
@Autowired
- public ExperimentService(final ExperimentRepository experimentRepository) {
+ public ExperimentService(
+ final ExperimentRepository experimentRepository,
+ final ExampleSolutionRepository exampleSolutionRepository,
+ final TestSuiteRepository testSuiteRepository
+ ) {
this.experimentRepository = experimentRepository;
+ this.exampleSolutionRepository = exampleSolutionRepository;
+ this.testSuiteRepository = testSuiteRepository;
}
/**
@@ -169,7 +178,6 @@ public ExperimentDTO changeExperimentStatus(final boolean status, final int id)
* @param id The experiment ID.
* @param project The sb3 project to upload.
* @throws IllegalArgumentException if the passed project is null or the id is invalid.
- * @throws NotFoundException if no corresponding experiment could be found.
*/
@Transactional
public void uploadSb3Project(final int id, final byte[] project) {
@@ -177,15 +185,87 @@ public void uploadSb3Project(final int id, final byte[] project) {
throw new IllegalArgumentException("Cannot upload sb3 project null!");
}
- try {
- Experiment experiment = experimentRepository.getReferenceById(id);
- experiment.setProject(project);
- experimentRepository.save(experiment);
- } catch (EntityNotFoundException e) {
- log.error("Could not find experiment with id {} when trying to upload an sb3 project!", id, e);
- throw new NotFoundException("Could not find experiment with id " + id + " when trying to upload an sb3 "
- + "project!", e);
+ Experiment experiment = experimentRepository.getReferenceById(id);
+ experiment.setProject(project);
+ experimentRepository.save(experiment);
+ }
+
+ /**
+ * Adds an example solution to an experiment.
+ *
+ * @param experimentId The id of an experiment.
+ * @param filename The filename of the example solution file.
+ * @param exampleSolutionSb3 The SB3 file content.
+ */
+ public void addExampleSolution(final int experimentId, final String filename, final byte[] exampleSolutionSb3) {
+ if (exampleSolutionSb3 == null) {
+ throw new IllegalArgumentException("Cannot upload sb3 project null!");
}
+
+ // workaround: At the moment the UI only supports one example solution,
+ // so we have to ensure only one exists in the database.
+ // The database schema is already prepared to allow for multiple example
+ // solutions to allow for future extension.
+ deleteExampleSolution(experimentId);
+
+ final Experiment experiment = experimentRepository.getReferenceById(experimentId);
+
+ final ExampleSolution solution = new ExampleSolution();
+ solution.setExperiment(experiment);
+ solution.setFilename(filename);
+ solution.setSb3Project(exampleSolutionSb3);
+
+ exampleSolutionRepository.save(solution);
+ }
+
+ /**
+ * Finds the filename of the example solution.
+ *
+ * @param experimentId Some experiment id.
+ * @return The filename of the example solution, if one exists. {@code null} otherwise.
+ */
+ @Nullable
+ public ExampleSolution getExampleSolution(final int experimentId) {
+ return exampleSolutionRepository
+ .findExampleSolutionsByExperiment_Id(experimentId)
+ .stream()
+ .findFirst()
+ .orElse(null);
+ }
+
+ /**
+ * Adds a test suite to an experiment.
+ *
+ * @param experimentId The id of an experiment.
+ * @param filename The filename of the test suite file.
+ * @param testSuite The test suite content.
+ */
+ public void addTestSuite(final int experimentId, final String filename, final String testSuite) {
+ if (testSuite == null) {
+ throw new IllegalArgumentException("Cannot upload empty test suite!");
+ }
+
+ deleteTestSuite(experimentId);
+
+ final Experiment experiment = experimentRepository.getReferenceById(experimentId);
+
+ final TestSuite suite = new TestSuite();
+ suite.setExperiment(experiment);
+ suite.setFilename(filename);
+ suite.setTestImplementation(testSuite);
+
+ testSuiteRepository.save(suite);
+ }
+
+ /**
+ * Finds the test suite of the experiment.
+ *
+ * @param experimentId The ID of an experiment.
+ * @return The test suite for this experiment, or {@code null} if none exists.
+ */
+ @Nullable
+ public TestSuite getTestSuite(final int experimentId) {
+ return testSuiteRepository.getByExperiment_Id(experimentId).orElse(null);
}
/**
@@ -197,15 +277,27 @@ public void uploadSb3Project(final int id, final byte[] project) {
*/
@Transactional
public void deleteSb3Project(final int id) {
- try {
- Experiment experiment = experimentRepository.getReferenceById(id);
- experiment.setProject(null);
- experimentRepository.save(experiment);
- } catch (EntityNotFoundException e) {
- log.error("Could not find experiment with id {} when trying to delete an sb3 project!", id, e);
- throw new NotFoundException("Could not find experiment with id " + id + " when trying to delete an sb3 "
- + "project!", e);
- }
+ Experiment experiment = experimentRepository.getReferenceById(id);
+ experiment.setProject(null);
+ experimentRepository.save(experiment);
+ }
+
+ /**
+ * Deletes the example solution(s) of an experiment.
+ *
+ * @param id The id of an experiment.
+ */
+ public void deleteExampleSolution(final int id) {
+ exampleSolutionRepository.deleteExampleSolutionByExperiment_Id(id);
+ }
+
+ /**
+ * Deletes the test suite of an experiment.
+ *
+ * @param id The id of an experiment.
+ */
+ public void deleteTestSuite(final int id) {
+ testSuiteRepository.deleteByExperiment_Id(id);
}
/**
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/ParticipantService.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/ParticipantService.java
index 8ac4346..351e232 100644
--- a/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/ParticipantService.java
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/ParticipantService.java
@@ -47,6 +47,7 @@
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
+import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -175,6 +176,43 @@ public List getParticipants(final int experimentId) {
.map(this::createParticipantDTO).toList();
}
+ /**
+ * Retrieves the ids and usernames of all participants of the experiment with the given id.
+ *
+ * @param experimentId The experiment id.
+ * @return A list of participant ids and usernames.
+ */
+ public List getParticipantNames(final int experimentId) {
+ Experiment experiment = experimentRepository.getReferenceById(experimentId);
+
+ List participants = participantRepository.findAllByExperiment(experiment);
+ return sortByIdAsc(participants);
+ }
+
+ /**
+ * Retrieves participants of the experiment that made at least one change to the program.
+ *
+ * @param experimentId The experiment id.
+ * @return A list of participant ids and usernames.
+ */
+ public List getActiveParticipantNames(final int experimentId) {
+ final List participants = participantRepository.findByExperimentWithBlockEvents(experimentId);
+ return sortByIdAsc(participants);
+ }
+
+ private List sortByIdAsc(final List participants) {
+ return participants
+ .stream()
+ .map(participant -> new ParticipantIdName(
+ participant.getUser().getId(), participant.getUser().getUsername()
+ ))
+ .sorted(Comparator.comparingInt(ParticipantIdName::id))
+ .toList();
+ }
+
+ public record ParticipantIdName(int id, String username) {
+ }
+
/**
* Adds the users participating in the course with the given id as participants to the experiment with the given id.
*
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/TestExecutionService.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/TestExecutionService.java
new file mode 100644
index 0000000..937f331
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/TestExecutionService.java
@@ -0,0 +1,188 @@
+package de.uni_passau.fim.se2.scratchlog.application.service;
+
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.BlockEvent;
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.TestCase;
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.TestResult;
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.TestSuite;
+import de.uni_passau.fim.se2.scratchlog.persistence.repository.BlockEventRepository;
+import de.uni_passau.fim.se2.scratchlog.persistence.repository.TestCaseRepository;
+import de.uni_passau.fim.se2.scratchlog.persistence.repository.TestResultRepository;
+import de.uni_passau.fim.se2.scratchlog.spring.events.TestExecutionFinishedEvent;
+import de.uni_passau.fim.se2.scratchlog.spring.events.TestExecutionRequestEvent;
+import de.uni_passau.fim.se2.scratchlog.spring.configuration.WhiskerConfiguration;
+import de.uni_passau.fim.se2.scratchlog.util.Constants;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.context.annotation.Profile;
+import org.springframework.context.event.EventListener;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+@Service
+@Profile(Constants.PROFILE_WHISKER)
+public class TestExecutionService {
+
+ private static final Logger log = LoggerFactory.getLogger(TestExecutionService.class);
+
+ private final ApplicationEventPublisher applicationEventPublisher;
+
+ private final BlockEventRepository blockEventRepository;
+
+ private final ThreadPoolTaskExecutor taskExecutor;
+
+ private final ExperimentService experimentService;
+
+ private final TestCaseRepository testCaseRepository;
+
+ private final TestResultRepository testResultRepository;
+
+ private final WhiskerService whiskerService;
+
+ private final ZipExportService zipExportService;
+
+ @Autowired
+ public TestExecutionService(
+ final ApplicationEventPublisher applicationEventPublisher,
+ final WhiskerConfiguration whiskerConfiguration,
+ final WhiskerService whiskerService,
+ final ZipExportService zipExportService,
+ final ExperimentService experimentService,
+ final TestResultRepository testResultRepository,
+ final TestCaseRepository testCaseRepository,
+ final BlockEventRepository blockEventRepository
+ ) {
+ this.whiskerService = whiskerService;
+
+ this.taskExecutor = new ThreadPoolTaskExecutor();
+ taskExecutor.setThreadNamePrefix("whisker-executor");
+ taskExecutor.setCorePoolSize(whiskerConfiguration.getMaxParallel());
+ taskExecutor.setMaxPoolSize(whiskerConfiguration.getMaxParallel());
+ taskExecutor.initialize();
+
+ this.applicationEventPublisher = applicationEventPublisher;
+ this.blockEventRepository = blockEventRepository;
+ this.experimentService = experimentService;
+ this.testCaseRepository = testCaseRepository;
+ this.testResultRepository = testResultRepository;
+ this.zipExportService = zipExportService;
+ }
+
+ /**
+ * Queues the test execution for a given block event.
+ *
+ *
Sends a {@link TestExecutionFinishedEvent} when the test execution has completed.
+ *
+ * @param event An application event notifying about a test execution request.
+ */
+ @EventListener
+ public void queueTestExecution(final TestExecutionRequestEvent event) {
+ taskExecutor.submit(() -> {
+ final Optional blockEvent = blockEventRepository.findBlockEventById(event.getBlockEventId());
+ if (blockEvent.isEmpty()) {
+ return;
+ }
+
+ final List testResults = runTests(event.getExperimentId(), blockEvent.get());
+ applicationEventPublisher.publishEvent(
+ new TestExecutionFinishedEvent(this, blockEvent.get(), testResults)
+ );
+ });
+ }
+
+ /**
+ * Queues the test execution for all block events in the experiment for which we do not yet have test results.
+ *
+ * @param experimentId Some experiment.
+ */
+ public void queueTestRuns(final int experimentId) {
+ final Set blockEventIds = blockEventRepository.findBlockEventIdsWithoutTestResults(experimentId);
+ log.info("Queueing test execution for {} events in experiment {}.", blockEventIds.size(), experimentId);
+ blockEventIds
+ .forEach(eventId -> queueTestExecution(new TestExecutionRequestEvent(this, experimentId, eventId)));
+ }
+
+ /**
+ * Queues the test execution for all block events in the experiment.
+ *
+ *
Test results for events that already have a test result will be overwritten.
+ *
+ * @param experimentId Some experiment.
+ */
+ public void queueTestRunsAll(final int experimentId) {
+ final Set blockEventIds = blockEventRepository.findBlockEventIdsWithCodeInExercise(experimentId);
+ log.info(
+ "Queueing test execution for {} events in experiment {}. Ignoring existing test results.",
+ blockEventIds.size(),
+ experimentId
+ );
+ blockEventIds
+ .forEach(eventId -> queueTestExecution(new TestExecutionRequestEvent(this, experimentId, eventId)));
+ }
+
+ /**
+ * Executes the tests for the program produced by the given block event.
+ *
+ * @param experimentId The experiment the event was produced in.
+ * @param event A block event.
+ * @return The results for the individual test cases. An empty list in case the tests could not be executed.
+ */
+ private List runTests(final int experimentId, final BlockEvent event) {
+ final byte[] sb3 = zipExportService.exportSb3ForEvent(experimentId, event.getId());
+ final TestSuite testSuite = experimentService.getTestSuite(experimentId);
+
+ if (testSuite == null || sb3.length == 0) {
+ // nothing to do here, we need both to be able to run the tests
+ log.debug("Skipping test execution, conditions not met: {}, {}", testSuite, sb3.length);
+ return Collections.emptyList();
+ }
+
+ final WhiskerService.WhiskerApiResponse result = whiskerService.runTests(
+ sb3, testSuite.getTestImplementation()
+ );
+ if (result != null) {
+ return saveTestResults(testSuite, event, result);
+ } else {
+ log.debug("Whisker test execution failed (experiment={}, event={}).", experimentId, event.getId());
+ }
+
+ return Collections.emptyList();
+ }
+
+ private List saveTestResults(
+ final TestSuite testSuite,
+ final BlockEvent blockEvent,
+ final WhiskerService.WhiskerApiResponse response
+ ) {
+ final List testResults = response.testResult().stream().map(result -> {
+ final TestResult testResult = new TestResult();
+ testResult.setTestCase(getRelevantTestCase(testSuite, result.name()));
+ testResult.setProject(blockEvent);
+ testResult.setResult(result.result());
+ return testResult;
+ }).toList();
+
+ testResultRepository.deleteAllByProject_Id(blockEvent.getId());
+ return testResultRepository.saveAll(testResults);
+ }
+
+ private TestCase getRelevantTestCase(final TestSuite testSuite, final String testName) {
+ return testSuite
+ .getTestCases()
+ .stream()
+ .filter(t -> testName.equals(t.getName()))
+ .findAny()
+ .orElseGet(() -> {
+ final TestCase testCase = new TestCase();
+ testCase.setTestSuite(testSuite);
+ testCase.setName(testName);
+ return testCaseRepository.save(testCase);
+ });
+ }
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/TestFitnessService.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/TestFitnessService.java
new file mode 100644
index 0000000..29d2d0d
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/TestFitnessService.java
@@ -0,0 +1,105 @@
+package de.uni_passau.fim.se2.scratchlog.application.service;
+
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.TestResultState;
+import de.uni_passau.fim.se2.scratchlog.persistence.projection.TestCaseCountSummary;
+import de.uni_passau.fim.se2.scratchlog.persistence.repository.TestResultRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+@Service
+public class TestFitnessService {
+
+ private final TestResultRepository testResultRepository;
+
+ @Autowired
+ public TestFitnessService(final TestResultRepository testResultRepository) {
+ this.testResultRepository = testResultRepository;
+ }
+
+ /**
+ * Computes the test fitness of a program.
+ *
+ *
Assumes a test result already exists in the database for this project.
+ *
+ *
The test fitness is defined as {@code (#passed tests)/(#total tests in the test suite)}. A program that
+ * passes all tests therefore has a fitness of {@code 1} and one that fails all tests a fitness of {@code 0}.
+ *
+ * @param blockEventId The id of the event that created the program.
+ * @return The test fitness in range {@code [0, 1]} if it could be computed.
+ */
+ public Optional getTestFitness(final int blockEventId) {
+ final List summary = testResultRepository.getTestResultSummaries(Set.of(blockEventId));
+ if (summary.isEmpty()) {
+ return Optional.empty();
+ }
+
+ final Map testResultStateCounts = processSummaries(summary).get(blockEventId);
+ return computeFitness(testResultStateCounts);
+ }
+
+ /**
+ * Computes the test fitnesses of a set of programs.
+ *
+ *
Assumes test results already exists in the database for these projects. Projects without test results are
+ * skipped and thus absent from the resulting fitness map.
+ *
+ *
For a definition of how the fitness is computed, see {@link #getTestFitness(int)}.
+ *
+ * @param blockEventIds The ids of the events that created the programs.
+ * @return The block event IDs mapped to test fitnesses with values in range {@code [0, 1]}. May not contain entries
+ * for all input IDs since the fitness cannot be computed if there are no test results yet.
+ */
+ public Map getTestFitnesses(final Set blockEventIds) {
+ final List summary = testResultRepository.getTestResultSummaries(blockEventIds);
+ if (summary.isEmpty()) {
+ return Collections.emptyMap();
+ }
+
+ final var summaryCounts = processSummaries(summary);
+
+ final Map fitnesses = new HashMap<>();
+ summaryCounts.forEach((blockEventId, testResults) -> {
+ computeFitness(testResults).ifPresent(fitness -> fitnesses.put(blockEventId, fitness));
+ });
+
+ return fitnesses;
+ }
+
+ private Optional computeFitness(final Map testResultStateCounts) {
+ final long totalTests = testResultStateCounts.values().stream().mapToLong(c -> c).sum();
+ if (totalTests == 0) {
+ // should not be possible, since only positive values are even added to the map
+ // safety against division by zero below
+ return Optional.empty();
+ }
+
+ final long passedTests = testResultStateCounts.getOrDefault(TestResultState.PASS, 0L);
+
+ return Optional.of(passedTests / (double) totalTests);
+ }
+
+ private Map> processSummaries(final List summaries) {
+ final Map> results = new HashMap<>();
+
+ for (final TestCaseCountSummary summary : summaries) {
+ results.compute(summary.blockEventId(), (k, v) -> {
+ if (v == null) {
+ v = new EnumMap<>(TestResultState.class);
+ }
+ v.put(summary.result(), summary.count());
+
+ return v;
+ });
+ }
+
+ return results;
+ }
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/TestResultService.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/TestResultService.java
new file mode 100644
index 0000000..85a3d3e
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/TestResultService.java
@@ -0,0 +1,84 @@
+package de.uni_passau.fim.se2.scratchlog.application.service;
+
+import de.uni_passau.fim.se2.scratchlog.application.service.dto.ExperimentTestResults;
+import de.uni_passau.fim.se2.scratchlog.application.service.dto.UserProgramTestResults;
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.TestCase;
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.TestResult;
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.TestResultState;
+import de.uni_passau.fim.se2.scratchlog.persistence.repository.BlockEventRepository;
+import de.uni_passau.fim.se2.scratchlog.persistence.repository.Project;
+import de.uni_passau.fim.se2.scratchlog.persistence.repository.TestCaseRepository;
+import de.uni_passau.fim.se2.scratchlog.persistence.repository.TestResultRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Service
+public class TestResultService {
+
+ private final TestResultRepository testResultRepository;
+
+ private final TestCaseRepository testCaseRepository;
+
+ private final BlockEventRepository blockEventRepository;
+
+ @Autowired
+ public TestResultService(
+ final TestResultRepository testResultRepository,
+ final TestCaseRepository testCaseRepository,
+ final BlockEventRepository blockEventRepository
+ ) {
+ this.testResultRepository = testResultRepository;
+ this.testCaseRepository = testCaseRepository;
+ this.blockEventRepository = blockEventRepository;
+ }
+
+ /**
+ * Retrieves the test results of the latest programs of all users in the course.
+ *
+ *
Assumes the tests have already run. Does not trigger a test execution.
+ *
+ * @param experimentId Some experiment.
+ * @return The test results of the latest program of each user.
+ */
+ public ExperimentTestResults getLatestTestResults(final int experimentId) {
+ final List latestProjects = blockEventRepository.findLastPerUserInExperiment(experimentId);
+ final Map> testResultsPerProject = testResultRepository
+ .findAllByProjectIdIn(latestProjects.stream().map(Project::id).collect(Collectors.toSet()))
+ .stream()
+ .collect(Collectors.groupingBy(result -> result.getProject().getId()));
+
+ final List testResults = new ArrayList<>();
+
+ for (final Project project : latestProjects) {
+ final List testResultsForProject = testResultsPerProject.get(project.id());
+ if (testResultsForProject == null) {
+ continue;
+ }
+
+ final Map testCaseResults = testResultsForProject
+ .stream()
+ .collect(Collectors.toMap(r -> r.getTestCase().getName(), TestResult::getResult));
+
+ testResults.add(new UserProgramTestResults(
+ project.userId(),
+ project.username(),
+ project.id(),
+ testCaseResults
+ ));
+ }
+
+ final List testNames = testCaseRepository
+ .findTestCasesUsedInExperiment(experimentId)
+ .stream()
+ .map(TestCase::getName)
+ .toList();
+
+ return new ExperimentTestResults(experimentId, testNames, testResults);
+ }
+
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/WhiskerService.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/WhiskerService.java
new file mode 100644
index 0000000..fc333e5
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/WhiskerService.java
@@ -0,0 +1,70 @@
+package de.uni_passau.fim.se2.scratchlog.application.service;
+
+import com.fasterxml.jackson.annotation.JsonValue;
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.TestResultState;
+import de.uni_passau.fim.se2.scratchlog.spring.configuration.WhiskerConfiguration;
+import de.uni_passau.fim.se2.scratchlog.util.Constants;
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Profile;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.core.io.Resource;
+import org.springframework.stereotype.Service;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.client.RestClientResponseException;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+@Service
+@Profile(Constants.PROFILE_WHISKER)
+public class WhiskerService {
+
+ private static final Logger log = LoggerFactory.getLogger(WhiskerService.class);
+
+ private final WhiskerConfiguration whiskerConfiguration;
+
+ private final RestClient restClient;
+
+ @Autowired
+ public WhiskerService(final WhiskerConfiguration whiskerConfiguration) {
+ this.whiskerConfiguration = whiskerConfiguration;
+ this.restClient = RestClient.create();
+ }
+
+ /**
+ * Calls the external Whisker API to run the test suite for the given project.
+ *
+ * @param projectSb3 A Scratch project in SB3 format.
+ * @param testSuite A Whisker test suite.
+ * @return The test case execution results.
+ */
+ @Nullable
+ WhiskerApiResponse runTests(final byte[] projectSb3, final String testSuite) {
+ final MultiValueMap body = new LinkedMultiValueMap<>();
+ body.add("project", new ByteArrayResource(projectSb3));
+ body.add("testsuite", new ByteArrayResource(testSuite.getBytes(StandardCharsets.UTF_8)));
+
+ try {
+ return restClient
+ .post()
+ .uri(whiskerConfiguration.getBaseUrl().resolve("/test"))
+ .body(body)
+ .retrieve()
+ .body(WhiskerApiResponse.class);
+ } catch (RestClientResponseException e) {
+ log.warn("The Whisker API could not process our request.", e);
+ return null;
+ }
+ }
+
+ public record WhiskerApiResponse(@JsonValue List testResult) {
+ }
+
+ public record WhiskerTestResult(String name, int index, TestResultState result) {
+ }
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/ZipExportService.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/ZipExportService.java
index 00d75ed..ba61fd0 100644
--- a/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/ZipExportService.java
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/ZipExportService.java
@@ -25,6 +25,7 @@
import de.uni_passau.fim.se2.scratchlog.persistence.projection.BlockEventJSONProjection;
import de.uni_passau.fim.se2.scratchlog.persistence.projection.BlockEventXMLProjection;
import de.uni_passau.fim.se2.scratchlog.persistence.projection.ExperimentProjection;
+import de.uni_passau.fim.se2.scratchlog.persistence.repository.BlockEventRepository;
import de.uni_passau.fim.se2.scratchlog.persistence.repository.UserRepository;
import de.uni_passau.fim.se2.scratchlog.web.dto.FileDTO;
import de.uni_passau.fim.se2.scratchlog.web.dto.ParticipantDTO;
@@ -55,6 +56,8 @@ public class ZipExportService {
private static final Logger log = LoggerFactory.getLogger(ZipExportService.class);
+ private final BlockEventRepository blockEventRepository;
+
private final CodeService codeService;
private final ExperimentService experimentService;
@@ -70,12 +73,38 @@ public ZipExportService(
final ExperimentService experimentService,
final FileService fileService,
final ParticipantService participantService,
- final UserRepository userRepository) {
+ final UserRepository userRepository,
+ final BlockEventRepository blockEventRepository
+ ) {
this.codeService = codeService;
this.experimentService = experimentService;
this.fileService = fileService;
this.participantService = participantService;
this.userRepository = userRepository;
+ this.blockEventRepository = blockEventRepository;
+ }
+
+ /**
+ * Exports the SB3 archive for a single event.
+ *
+ * @param experimentId The experiment the event was generated in.
+ * @param eventId The event id.
+ * @return The bytes of the SB3 archive, or an empty array in case no event was found.
+ */
+ public byte[] exportSb3ForEvent(final int experimentId, final int eventId) {
+ final Optional userId = blockEventRepository.getCreatorIdOfEvent(eventId);
+ if (userId.isEmpty()) {
+ return new byte[0];
+ }
+
+ final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try {
+ exportSb3ForEvent(baos, experimentId, userId.get(), eventId);
+ } catch (IOException e) {
+ return new byte[0];
+ }
+
+ return baos.toByteArray();
}
/**
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/dto/ExperimentTestResults.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/dto/ExperimentTestResults.java
new file mode 100644
index 0000000..5c91693
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/dto/ExperimentTestResults.java
@@ -0,0 +1,17 @@
+package de.uni_passau.fim.se2.scratchlog.application.service.dto;
+
+import java.util.List;
+
+/**
+ * Collection of test results for the users in an experiment.
+ *
+ * @param exerciseId The experiment.
+ * @param testCaseNames The names of the test cases.
+ * @param userProgramTestResults The test results for user programs.
+ */
+public record ExperimentTestResults(
+ int exerciseId,
+ List testCaseNames,
+ List userProgramTestResults
+) {
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/dto/UserProgramTestResults.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/dto/UserProgramTestResults.java
new file mode 100644
index 0000000..e709a64
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/dto/UserProgramTestResults.java
@@ -0,0 +1,21 @@
+package de.uni_passau.fim.se2.scratchlog.application.service.dto;
+
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.TestResultState;
+
+import java.util.Map;
+
+/**
+ * The test results for a user program.
+ *
+ * @param userId The ID of a user.
+ * @param username The name of a user.
+ * @param blockEventId The ID of the block event (ie program) these test results are for.
+ * @param testResults A map of test case name to result.
+ */
+public record UserProgramTestResults(
+ int userId,
+ String username,
+ int blockEventId,
+ Map testResults
+) {
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/dto/package-info.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/dto/package-info.java
new file mode 100644
index 0000000..b85a11c
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/application/service/dto/package-info.java
@@ -0,0 +1 @@
+package de.uni_passau.fim.se2.scratchlog.application.service.dto;
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/entity/ExampleSolution.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/entity/ExampleSolution.java
new file mode 100644
index 0000000..e69c76f
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/entity/ExampleSolution.java
@@ -0,0 +1,39 @@
+package de.uni_passau.fim.se2.scratchlog.persistence.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Setter
+@Getter
+@AllArgsConstructor
+@NoArgsConstructor
+@Entity
+@Table(name = "example_solution")
+public class ExampleSolution {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Integer id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "experiment_id", referencedColumnName = "id")
+ private Experiment experiment;
+
+ @Column(name = "filename")
+ private String filename;
+
+ @Column(name = "sb3_project")
+ private byte[] sb3Project;
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/entity/TestCase.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/entity/TestCase.java
new file mode 100644
index 0000000..46d3983
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/entity/TestCase.java
@@ -0,0 +1,36 @@
+package de.uni_passau.fim.se2.scratchlog.persistence.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Entity
+@Table(name = "test_case")
+@Setter
+@Getter
+@AllArgsConstructor
+@NoArgsConstructor
+public class TestCase {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Integer id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "test_suite_id", referencedColumnName = "id")
+ private TestSuite testSuite;
+
+ @Column(name = "name")
+ private String name;
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/entity/TestResult.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/entity/TestResult.java
new file mode 100644
index 0000000..8c2ae96
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/entity/TestResult.java
@@ -0,0 +1,43 @@
+package de.uni_passau.fim.se2.scratchlog.persistence.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+@Entity
+@Table(name = "test_result")
+@Setter
+@Getter
+@AllArgsConstructor
+@NoArgsConstructor
+public class TestResult {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Integer id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "test_case_id", referencedColumnName = "id")
+ private TestCase testCase;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "project_id", referencedColumnName = "id")
+ private BlockEvent project;
+
+ @Column(name = "result")
+ @Enumerated(EnumType.STRING)
+ private TestResultState result;
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/entity/TestResultState.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/entity/TestResultState.java
new file mode 100644
index 0000000..da6622d
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/entity/TestResultState.java
@@ -0,0 +1,34 @@
+package de.uni_passau.fim.se2.scratchlog.persistence.entity;
+
+import com.fasterxml.jackson.annotation.JsonValue;
+
+public enum TestResultState {
+
+ /**
+ * A passing test case.
+ */
+ PASS("pass"),
+ /**
+ * A failed test case.
+ */
+ FAIL("fail"),
+ /**
+ * A test case that resulted in a crash.
+ */
+ ERROR("error"),
+ /**
+ * A skipped test case.
+ */
+ SKIP("skip");
+
+ private final String key;
+
+ TestResultState(final String key) {
+ this.key = key;
+ }
+
+ @JsonValue
+ public String getKey() {
+ return key;
+ }
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/entity/TestSuite.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/entity/TestSuite.java
new file mode 100644
index 0000000..8f8815e
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/entity/TestSuite.java
@@ -0,0 +1,51 @@
+package de.uni_passau.fim.se2.scratchlog.persistence.entity;
+
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.util.Set;
+
+@Entity
+@Table(name = "test_suite")
+@Setter
+@Getter
+@AllArgsConstructor
+@NoArgsConstructor
+public class TestSuite {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id")
+ private Integer id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "experiment_id", referencedColumnName = "id")
+ private Experiment experiment;
+
+ @Column(name = "filename")
+ private String filename;
+
+ @Column(name = "test_implementation")
+ private String testImplementation;
+
+ @OneToMany(
+ mappedBy = "testSuite",
+ orphanRemoval = true,
+ cascade = CascadeType.ALL,
+ fetch = FetchType.EAGER
+ )
+ private Set testCases;
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/projection/TestCaseCountSummary.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/projection/TestCaseCountSummary.java
new file mode 100644
index 0000000..373da1a
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/projection/TestCaseCountSummary.java
@@ -0,0 +1,6 @@
+package de.uni_passau.fim.se2.scratchlog.persistence.projection;
+
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.TestResultState;
+
+public record TestCaseCountSummary(int blockEventId, TestResultState result, long count) {
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/BlockEventRepository.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/BlockEventRepository.java
index f9fa6d5..24ae0e0 100644
--- a/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/BlockEventRepository.java
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/BlockEventRepository.java
@@ -30,8 +30,11 @@
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
import java.util.List;
+import java.util.Optional;
+import java.util.Set;
import java.util.stream.Stream;
/**
@@ -39,6 +42,8 @@
*/
public interface BlockEventRepository extends JpaRepository {
+ List event(BlockEventSpecific event);
+
/**
* Returns all xml data with the corresponding id of the block event saved for the given user in the given
* experiment, if any exist. The returned list is sorted ascendingly by date.
@@ -92,6 +97,8 @@ Page findAllByUserAndExperimentAndXmlIsNotNull(User user,
List findAllByUserAndExperimentAndEvent(User user, Experiment experiment,
BlockEventSpecific event);
+ Optional findBlockEventById(int blockEventId);
+
/**
* Returns a {@link BlockEventJSONProjection} containing the last non-null JSON code that was saved for the given
* user during the given experiment.
@@ -103,4 +110,66 @@ List findAllByUserAndExperimentAndEvent(User user, Experiment e
BlockEventJSONProjection findFirstByUserAndExperimentAndCodeIsNotNullOrderByDateDesc(User user,
Experiment experiment);
+ @Query("""
+ with latest_events as (
+ select be2.user.id as user_id, max(be2.id) as id, max(be2.date) as date
+ from BlockEvent be2
+ where be2.code is not null
+ and be2.experiment.id = :experimentId
+ group by be2.user.id
+ )
+ select new de.uni_passau.fim.se2.scratchlog.persistence.repository.Project(
+ be.id, be.user.id, be.user.username, be.code
+ )
+ from BlockEvent be, latest_events e
+ where be.experiment.id = :experimentId
+ and be.id = e.id
+ and be.user.id = e.user_id
+ and be.date = e.date
+ """)
+ List findLastPerUserInExperiment(int experimentId);
+
+ @Query("""
+ select be.user.id
+ from BlockEvent be
+ where be.id = :eventId
+ """)
+ Optional getCreatorIdOfEvent(int eventId);
+
+ /**
+ * Finds all block events in the experiment that do not yet have test results.
+ *
+ *
Block events that do not have associated {@link BlockEvent::getCode} are ignored.
+ *
+ * @param experimentId Some experiment.
+ * @return The IDs of all block events with code but without test results in the experiment.
+ */
+ // implementation note `order by be.id desc`: run tests for latest projects first, then backfill with older ones
+ @Query("""
+ select distinct be.id
+ from BlockEvent be
+ where be.code is not null
+ and be.experiment.id = :experimentId
+ and be.id not in (
+ select distinct tr.project.id
+ from TestResult tr
+ where tr.testCase.testSuite.experiment.id = :experimentId
+ )
+ order by be.id desc
+ """)
+ Set findBlockEventIdsWithoutTestResults(int experimentId);
+
+ /**
+ * Finds all block events in the experiment which contain code.
+ *
+ * @param experimentId Some experiment.
+ * @return The IDs of all block events with code in the experiment.
+ */
+ @Query("""
+ select distinct be.id
+ from BlockEvent be
+ where be.code is not null
+ and be.experiment.id = :experimentId
+ """)
+ Set findBlockEventIdsWithCodeInExercise(int experimentId);
}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/ExampleSolutionRepository.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/ExampleSolutionRepository.java
new file mode 100644
index 0000000..ef141df
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/ExampleSolutionRepository.java
@@ -0,0 +1,15 @@
+package de.uni_passau.fim.se2.scratchlog.persistence.repository;
+
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.ExampleSolution;
+import jakarta.transaction.Transactional;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface ExampleSolutionRepository extends JpaRepository {
+
+ @Transactional
+ void deleteExampleSolutionByExperiment_Id(int experimentId);
+
+ List findExampleSolutionsByExperiment_Id(int experimentId);
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/ExperimentRepository.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/ExperimentRepository.java
index 04b7720..286cb82 100644
--- a/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/ExperimentRepository.java
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/ExperimentRepository.java
@@ -208,4 +208,10 @@ List findExperimentResults(@Param("query") String que
+ " p.experiment_id = e.id WHERE p.user_id = :participant")
int getParticipantPageCount(@Param("participant") int userId);
+ @Query("""
+ select e.project
+ from Experiment e
+ where e.id = :experimentId
+ """)
+ byte[] getExperimentStarterProject(int experimentId);
}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/ParticipantRepository.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/ParticipantRepository.java
index c79f687..ce03e02 100644
--- a/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/ParticipantRepository.java
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/ParticipantRepository.java
@@ -26,6 +26,7 @@
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
import java.time.LocalDateTime;
import java.util.List;
@@ -105,4 +106,17 @@ public interface ParticipantRepository extends JpaRepository findAllByEndIsNullAndUser(User user);
+ @Query("""
+ select p
+ from Participant p
+ where p.experiment.id = :experimentId
+ and exists (
+ select be.user.id
+ from BlockEvent be
+ where be.experiment.id = :experimentId
+ and be.code is not null
+ and p.user.id = be.user.id
+ )
+ """)
+ List findByExperimentWithBlockEvents(int experimentId);
}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/Project.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/Project.java
new file mode 100644
index 0000000..587932c
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/Project.java
@@ -0,0 +1,12 @@
+package de.uni_passau.fim.se2.scratchlog.persistence.repository;
+
+/**
+ * Represents a Scratch project JSON.
+ *
+ * @param id The ID of the block event that resulted in this project.
+ * @param userId The ID of the user that created this project.
+ * @param username The name of the user that created this project.
+ * @param projectJson The project JSON.
+ */
+public record Project(int id, int userId, String username, String projectJson) {
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/TestCaseRepository.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/TestCaseRepository.java
new file mode 100644
index 0000000..39ce5cf
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/TestCaseRepository.java
@@ -0,0 +1,19 @@
+package de.uni_passau.fim.se2.scratchlog.persistence.repository;
+
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.TestCase;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+import java.util.List;
+
+public interface TestCaseRepository extends JpaRepository {
+
+ @Query("""
+ select distinct tr.testCase
+ from TestResult tr
+ where tr.testCase.testSuite.experiment.id = :experimentId
+ order by tr.testCase.name
+ """)
+ List findTestCasesUsedInExperiment(int experimentId);
+
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/TestResultRepository.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/TestResultRepository.java
new file mode 100644
index 0000000..d57726f
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/TestResultRepository.java
@@ -0,0 +1,41 @@
+package de.uni_passau.fim.se2.scratchlog.persistence.repository;
+
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.TestResult;
+import de.uni_passau.fim.se2.scratchlog.persistence.projection.TestCaseCountSummary;
+import org.springframework.data.jpa.repository.EntityGraph;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Set;
+
+public interface TestResultRepository extends JpaRepository {
+
+ @Modifying
+ @Transactional
+ void deleteAllByProject_Id(int projectId);
+
+ @EntityGraph(attributePaths = {"testCase"})
+ List findAllByProjectIdIn(Set projectIds);
+
+ /**
+ * Counts how many test case results exist for the given block events.
+ *
+ * @param blockEventIds Some block events.
+ * @return The number of test case results grouped by block event and kind of result.
+ */
+ @Query("""
+ select new de.uni_passau.fim.se2.scratchlog.persistence.projection.TestCaseCountSummary(
+ t.project.id,
+ t.result,
+ count(*)
+ )
+ from TestResult t
+ where t.project.id in :blockEventIds
+ group by t.project.id, t.result
+ """)
+ List getTestResultSummaries(Set blockEventIds);
+
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/TestSuiteRepository.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/TestSuiteRepository.java
new file mode 100644
index 0000000..8227b02
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/persistence/repository/TestSuiteRepository.java
@@ -0,0 +1,14 @@
+package de.uni_passau.fim.se2.scratchlog.persistence.repository;
+
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.TestSuite;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface TestSuiteRepository extends JpaRepository {
+
+ Optional getByExperiment_Id(int experimentId);
+
+ void deleteByExperiment_Id(int experimentId);
+
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/spring/configuration/CodeEmbeddingConfiguration.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/spring/configuration/CodeEmbeddingConfiguration.java
new file mode 100644
index 0000000..cf16f03
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/spring/configuration/CodeEmbeddingConfiguration.java
@@ -0,0 +1,16 @@
+package de.uni_passau.fim.se2.scratchlog.spring.configuration;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.net.URI;
+
+@Getter
+@Setter
+public class CodeEmbeddingConfiguration {
+
+ private String model;
+
+ private URI embeddingConnectorUrl;
+
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/spring/configuration/ConfigurationPropertiesFactory.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/spring/configuration/ConfigurationPropertiesFactory.java
new file mode 100644
index 0000000..0520039
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/spring/configuration/ConfigurationPropertiesFactory.java
@@ -0,0 +1,35 @@
+package de.uni_passau.fim.se2.scratchlog.spring.configuration;
+
+import de.uni_passau.fim.se2.scratchlog.util.Constants;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+
+@Configuration
+@Profile("!test")
+public class ConfigurationPropertiesFactory {
+
+ /**
+ * Automatically provided code embedding model configuration.
+ * @return The configuration
+ */
+ @Bean
+ @Profile(Constants.PROFILE_CODE_EMBEDDINGS)
+ @ConfigurationProperties(prefix = "code-embeddings")
+ public CodeEmbeddingConfiguration codeEmbeddingConfiguration() {
+ return new CodeEmbeddingConfiguration();
+ }
+
+ /**
+ * Automatically provided Whisker configuration.
+ * @return The configuration
+ */
+ @Bean
+ @Profile(Constants.PROFILE_WHISKER)
+ @ConfigurationProperties(prefix = "whisker")
+ public WhiskerConfiguration whiskerConfiguration() {
+ return new WhiskerConfiguration();
+ }
+
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/spring/configuration/WhiskerConfiguration.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/spring/configuration/WhiskerConfiguration.java
new file mode 100644
index 0000000..ff57ee5
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/spring/configuration/WhiskerConfiguration.java
@@ -0,0 +1,16 @@
+package de.uni_passau.fim.se2.scratchlog.spring.configuration;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.net.URI;
+
+@Getter
+@Setter
+public class WhiskerConfiguration {
+
+ private URI baseUrl;
+
+ private int maxParallel = 1;
+
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/spring/events/TestExecutionFinishedEvent.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/spring/events/TestExecutionFinishedEvent.java
new file mode 100644
index 0000000..3b379e3
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/spring/events/TestExecutionFinishedEvent.java
@@ -0,0 +1,27 @@
+package de.uni_passau.fim.se2.scratchlog.spring.events;
+
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.BlockEvent;
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.TestResult;
+import lombok.Getter;
+import org.springframework.context.ApplicationEvent;
+
+import java.util.List;
+
+@Getter
+public class TestExecutionFinishedEvent extends ApplicationEvent {
+
+ private final BlockEvent blockEvent;
+
+ private final List testResults;
+
+ public TestExecutionFinishedEvent(
+ final Object source,
+ final BlockEvent blockEvent,
+ final List testResults
+ ) {
+ super(source);
+
+ this.blockEvent = blockEvent;
+ this.testResults = testResults;
+ }
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/spring/events/TestExecutionRequestEvent.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/spring/events/TestExecutionRequestEvent.java
new file mode 100644
index 0000000..a5dc6b9
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/spring/events/TestExecutionRequestEvent.java
@@ -0,0 +1,20 @@
+package de.uni_passau.fim.se2.scratchlog.spring.events;
+
+import lombok.Getter;
+import org.springframework.context.ApplicationEvent;
+
+@Getter
+public class TestExecutionRequestEvent extends ApplicationEvent {
+
+ private final int experimentId;
+
+ private final int blockEventId;
+
+ public TestExecutionRequestEvent(final Object source, final int experimentId, final int blockEventId) {
+ super(source);
+
+ this.experimentId = experimentId;
+ this.blockEventId = blockEventId;
+ }
+
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/spring/events/package-info.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/spring/events/package-info.java
new file mode 100644
index 0000000..8dfa078
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/spring/events/package-info.java
@@ -0,0 +1 @@
+package de.uni_passau.fim.se2.scratchlog.spring.events;
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/util/ApplicationProperties.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/util/ApplicationProperties.java
index 3674724..efa942b 100644
--- a/src/main/java/de/uni_passau/fim/se2/scratchlog/util/ApplicationProperties.java
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/util/ApplicationProperties.java
@@ -131,6 +131,24 @@ public boolean useSamlAuthentication() {
return springProfiles.contains("saml2");
}
+ /**
+ * Checks if the code embeddings feature is enabled.
+ *
+ * @return True, if enabled.
+ */
+ public boolean codeEmbeddingsActive() {
+ return springProfiles.contains(Constants.PROFILE_CODE_EMBEDDINGS);
+ }
+
+ /**
+ * Checks if the Whisker feature is enabled.
+ *
+ * @return True, if enabled.
+ */
+ public boolean whiskerActive() {
+ return springProfiles.contains(Constants.PROFILE_WHISKER);
+ }
+
@Override
public final String toString() {
return "ApplicationProperties{"
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/util/Constants.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/util/Constants.java
index 25ef3c4..24f4505 100644
--- a/src/main/java/de/uni_passau/fim/se2/scratchlog/util/Constants.java
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/util/Constants.java
@@ -147,4 +147,14 @@ private Constants() {
*/
public static final Language DEFAULT_LANGUAGE = Language.GERMAN;
+ /**
+ * The Spring profile name for the code embeddings feature.
+ */
+ public static final String PROFILE_CODE_EMBEDDINGS = "embeddings";
+
+ /**
+ * The Spring profile name for the Whisker integration feature.
+ */
+ public static final String PROFILE_WHISKER = "whisker";
+
}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/web/controller/DashboardRestController.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/web/controller/DashboardRestController.java
index 9aa94e9..7188541 100644
--- a/src/main/java/de/uni_passau/fim/se2/scratchlog/web/controller/DashboardRestController.java
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/web/controller/DashboardRestController.java
@@ -20,6 +20,7 @@
package de.uni_passau.fim.se2.scratchlog.web.controller;
import de.uni_passau.fim.se2.scratchlog.application.service.DashboardService;
+import de.uni_passau.fim.se2.scratchlog.application.service.ParticipantService;
import de.uni_passau.fim.se2.scratchlog.util.enums.BlockEventSpecific;
import de.uni_passau.fim.se2.scratchlog.util.enums.ClickEventSpecific;
import de.uni_passau.fim.se2.scratchlog.util.enums.ResourceEventSpecific;
@@ -43,6 +44,8 @@ public class DashboardRestController {
*/
private final DashboardService dashboardService;
+ private final ParticipantService participantService;
+
/**
* String corresponding to the id request parameter.
*/
@@ -58,14 +61,12 @@ public class DashboardRestController {
*/
private static final String EVENT = "event";
- /**
- * Constructs a new dashboard REST controller with the given dependencies.
- *
- * @param dashboardService The {@link DashboardService} to use.
- */
@Autowired
- public DashboardRestController(final DashboardService dashboardService) {
+ public DashboardRestController(
+ final DashboardService dashboardService, final ParticipantService participantService
+ ) {
this.dashboardService = dashboardService;
+ this.participantService = participantService;
}
/**
@@ -89,8 +90,8 @@ public DashboardService.ExperimentDataDto getExperimentData(@RequestParam(ID) fi
* @throws IllegalArgumentException if the passed id is invalid.
*/
@GetMapping("/participants")
- public List getParticipantData(@RequestParam(ID) final int experimentId) {
- return dashboardService.getParticipants(experimentId);
+ public List getParticipantData(@RequestParam(ID) final int experimentId) {
+ return participantService.getParticipantNames(experimentId);
}
/**
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/web/controller/EmbeddingController.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/web/controller/EmbeddingController.java
new file mode 100644
index 0000000..96bf1e6
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/web/controller/EmbeddingController.java
@@ -0,0 +1,82 @@
+package de.uni_passau.fim.se2.scratchlog.web.controller;
+
+import de.uni_passau.fim.se2.scratchlog.application.service.EmbeddingModelService;
+import de.uni_passau.fim.se2.scratchlog.util.Constants;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Profile;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.io.IOException;
+import java.util.Set;
+
+@RestController
+@RequestMapping("/embeddings/")
+@Profile(Constants.PROFILE_CODE_EMBEDDINGS)
+public class EmbeddingController {
+
+ private final EmbeddingModelService embeddingModelService;
+
+ @Autowired
+ public EmbeddingController(final EmbeddingModelService embeddingModelService) {
+ this.embeddingModelService = embeddingModelService;
+ }
+
+ /**
+ * Computes the progress-variance-projection for the given experiment.
+ *
+ *
For all participants in the experiment and their latest code change.
+ *
+ * @param experimentId The experiment id.
+ * @return The progress-variance projection.
+ * @throws IOException In case the example/solution projects cannot be parsed.
+ */
+ @GetMapping("/progress-variance-projection/all/latest")
+ public EmbeddingModelService.ProgramProjection2D getProgressVarianceProjectionAllLatest(
+ @RequestParam("experimentId") final int experimentId
+ ) throws IOException {
+ return embeddingModelService.getProgressVarianceProjectionAllLatest(experimentId);
+ }
+
+ /**
+ * Computes the progress-variance-projection for the given experiment.
+ *
+ *
As a timeline of program states of all given users in the experiment.
+ *
+ * @param experimentId The experiment id.
+ * @param userIds The set of users for which the progression timeline should be generated.
+ * @param stepMinutes The step in minutes between program states.
+ * @return The progress-variance projection.
+ * @throws IOException In case the example/solution projects cannot be parsed.
+ */
+ @GetMapping("/progress-variance-projection/timeline")
+ public EmbeddingModelService.ProgramProjection2D getProgressVarianceProjectionOverTime(
+ @RequestParam("experimentId") final int experimentId,
+ @RequestParam(value = "userIds", defaultValue = "") final Set userIds,
+ @RequestParam(value = "stepMinutes", defaultValue = "1") final int stepMinutes
+ ) throws IOException {
+ return embeddingModelService.getProgressVarianceProjectionForUsers(experimentId, userIds, stepMinutes);
+ }
+
+ /**
+ * Fetches the embedding and test distances for the latest project of all users in the experiment.
+ *
+ *
The resulting datapoints will have the test distance on the x-Axis (0th list element) and the embedding
+ * distance as y-Axis (1st element).
+ *
+ *
Requires the {@link Constants#PROFILE_WHISKER} profile to be active.
+ *
+ * @param experimentId Some experiment.
+ * @return For each user a datapoint representing the latest project’s test and embedding distances.
+ * @throws IOException In case the solution project cannot be parsed.
+ */
+ @GetMapping("/embedding-test-distance/all/latest")
+ public EmbeddingModelService.ProgramProjection2D getTestVsEmbeddingDistanceAllLatest(
+ @RequestParam("experimentId") final int experimentId
+ ) throws IOException {
+ return embeddingModelService.getEmbeddingVsTestDistanceLatestProjects(experimentId);
+ }
+
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/web/controller/EmbeddingDashboardController.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/web/controller/EmbeddingDashboardController.java
new file mode 100644
index 0000000..a9250df
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/web/controller/EmbeddingDashboardController.java
@@ -0,0 +1,39 @@
+package de.uni_passau.fim.se2.scratchlog.web.controller;
+
+import de.uni_passau.fim.se2.scratchlog.application.service.ParticipantService;
+import de.uni_passau.fim.se2.scratchlog.util.Constants;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.annotation.Secured;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+
+@Controller
+@RequestMapping("/embedding-dashboard")
+public class EmbeddingDashboardController {
+
+ private final ParticipantService participantService;
+
+ @Autowired
+ public EmbeddingDashboardController(final ParticipantService participantService) {
+ this.participantService = participantService;
+ }
+
+ /**
+ * Model init for the embedding dashboard page.
+ *
+ * @param experimentId The experiment id.
+ * @param model The view model.
+ * @return Redirect to the embedding dashboard page.
+ */
+ @GetMapping("")
+ @Secured(Constants.ROLE_ADMIN)
+ public String getDashboard(@RequestParam("id") final int experimentId, final Model model) {
+ model.addAttribute("experiment", experimentId);
+ model.addAttribute("participants", participantService.getActiveParticipantNames(experimentId));
+
+ return "embedding-dashboard";
+ }
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/web/controller/ExperimentController.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/web/controller/ExperimentController.java
index b59d78d..0205dc6 100644
--- a/src/main/java/de/uni_passau/fim/se2/scratchlog/web/controller/ExperimentController.java
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/web/controller/ExperimentController.java
@@ -29,8 +29,10 @@
import de.uni_passau.fim.se2.scratchlog.application.service.PageService;
import de.uni_passau.fim.se2.scratchlog.application.service.ParticipantService;
import de.uni_passau.fim.se2.scratchlog.application.service.UserService;
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.ExampleSolution;
import de.uni_passau.fim.se2.scratchlog.persistence.entity.Experiment;
import de.uni_passau.fim.se2.scratchlog.persistence.entity.Participant;
+import de.uni_passau.fim.se2.scratchlog.persistence.entity.TestSuite;
import de.uni_passau.fim.se2.scratchlog.util.ApplicationProperties;
import de.uni_passau.fim.se2.scratchlog.util.Constants;
import de.uni_passau.fim.se2.scratchlog.util.FieldErrorHandler;
@@ -40,11 +42,14 @@
import de.uni_passau.fim.se2.scratchlog.util.validation.FiletypeValidator;
import de.uni_passau.fim.se2.scratchlog.util.validation.StringValidator;
import de.uni_passau.fim.se2.scratchlog.web.dto.ExperimentDTO;
+import de.uni_passau.fim.se2.scratchlog.web.dto.JsFileDTO;
import de.uni_passau.fim.se2.scratchlog.web.dto.ParticipantDTO;
import de.uni_passau.fim.se2.scratchlog.web.dto.PasswordDTO;
+import de.uni_passau.fim.se2.scratchlog.web.dto.Sb3FileDTO;
import de.uni_passau.fim.se2.scratchlog.web.dto.UserDTO;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -65,6 +70,7 @@
import java.io.IOException;
import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -636,7 +642,7 @@ public void downloadLitterBoxAnalysis(@RequestParam(ID) final int experimentId,
* @param model The model used to return error messages.
* @return The experiment page on success, or if the file was invalid, or the error page otherwise.
*/
- @PostMapping("/upload")
+ @PostMapping("/starter-project/upload")
@Secured(Constants.ROLE_ADMIN)
public String uploadProjectFile(@RequestParam("file") final MultipartFile file,
@RequestParam(ID) final int experimentId, final Model model) {
@@ -663,8 +669,6 @@ public String uploadProjectFile(@RequestParam("file") final MultipartFile file,
try {
experimentService.uploadSb3Project(experimentId, file.getBytes());
return REDIRECT_EXPERIMENT + experimentId;
- } catch (NotFoundException e) {
- return Constants.ERROR;
} catch (IOException e) {
log.error("Could not upload file due to IOException", e);
return Constants.ERROR;
@@ -678,15 +682,98 @@ public String uploadProjectFile(@RequestParam("file") final MultipartFile file,
* @param experimentId The experiment id to search for.
* @return The experiment page on success, or the error page otherwise.
*/
- @GetMapping("/sb3")
+ @GetMapping("/starter-project/delete")
@Secured(Constants.ROLE_ADMIN)
public String deleteProjectFile(@RequestParam(ID) final int experimentId) {
+ experimentService.deleteSb3Project(experimentId);
+ return REDIRECT_EXPERIMENT + experimentId;
+ }
+
+ /**
+ * Adds an example solution to the given experiment.
+ *
+ * @param exampleSolution The example solution SB3.
+ * @param experimentId The id of some experiment.
+ * @param model The model attribute container.
+ * @return A redirect to the experiment page.
+ */
+ @PostMapping("/example-solution/upload")
+ @Secured(Constants.ROLE_ADMIN)
+ public String uploadExampleSolution(
+ @Valid @ModelAttribute("exampleSolution") final Sb3FileDTO exampleSolution,
+ @RequestParam(ID) final int experimentId,
+ final Model model
+ ) {
+ final byte[] fileContents;
try {
- experimentService.deleteSb3Project(experimentId);
+ fileContents = exampleSolution.getFile().getBytes();
+ } catch (IOException e) {
+ log.error("Could not read sb3 example solution.", e);
+ model.addAttribute(ERROR, "Could not read the SB3 file.");
+ return REDIRECT_EXPERIMENT + experimentId;
+ }
+
+ experimentService.addExampleSolution(
+ experimentId, exampleSolution.getFile().getOriginalFilename(), fileContents
+ );
+
+ return REDIRECT_EXPERIMENT + experimentId;
+ }
+
+ /**
+ * Deletes the example solution(s) for the given experiment.
+ *
+ * @param experimentId The id of some experiment.
+ * @return A redirect to the experiment page.
+ */
+ @GetMapping("/example-solution/delete")
+ @Secured(Constants.ROLE_ADMIN)
+ public String deleteExampleSolution(@RequestParam(ID) final int experimentId) {
+ experimentService.deleteExampleSolution(experimentId);
+ return REDIRECT_EXPERIMENT + experimentId;
+ }
+
+ /**
+ * Adds a test suite to the given experiment.
+ *
+ * @param testSuite The test suite JavaScript file.
+ * @param experimentId The id of some experiment.
+ * @param model The model attribute container.
+ * @return A redirect to the experiment page.
+ */
+ @PostMapping("/test-suite/upload")
+ @Secured(Constants.ROLE_ADMIN)
+ public String uploadTestSuite(
+ @Valid @ModelAttribute("testSuite") final JsFileDTO testSuite,
+ @RequestParam(ID) final int experimentId,
+ final Model model
+ ) {
+ final String fileContents;
+ try {
+ fileContents = new String(testSuite.getFile().getBytes(), StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ log.error("Could not read test suite.", e);
+ model.addAttribute(ERROR, "Could not read the test suite file.");
return REDIRECT_EXPERIMENT + experimentId;
- } catch (NotFoundException e) {
- return Constants.ERROR;
}
+
+ experimentService.addTestSuite(
+ experimentId, testSuite.getFile().getOriginalFilename(), fileContents
+ );
+
+ return REDIRECT_EXPERIMENT + experimentId;
+ }
+
+ /**
+ * Deletes the test suite of the given experiment.
+ *
+ * @param experimentId The id of some experiment.
+ * @return A redirect to the experiment page.
+ */
+ @GetMapping("/test-suite/delete")
+ public String deleteTestSuite(@RequestParam(ID) final int experimentId) {
+ experimentService.deleteTestSuite(experimentId);
+ return REDIRECT_EXPERIMENT + experimentId;
}
/**
@@ -785,6 +872,19 @@ private void addExperimentInfo(final ExperimentDTO experimentDTO, final Model mo
model.addAttribute("experimentDTO", experimentDTO);
model.addAttribute("passwordDTO", new PasswordDTO());
+
+ if (!model.containsAttribute("exampleSolution")) {
+ model.addAttribute("exampleSolution", new Sb3FileDTO());
+ }
+ if (!model.containsAttribute("testSuite")) {
+ model.addAttribute("testSuite", new JsFileDTO());
+ }
+
+ final ExampleSolution exampleSolution = experimentService.getExampleSolution(experimentDTO.getId());
+ model.addAttribute("exampleSolutionName", exampleSolution != null ? exampleSolution.getFilename() : null);
+
+ final TestSuite testSuite = experimentService.getTestSuite(experimentDTO.getId());
+ model.addAttribute("testSuiteName", testSuite != null ? testSuite.getFilename() : null);
}
/**
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/web/controller/TestController.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/web/controller/TestController.java
new file mode 100644
index 0000000..4312d68
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/web/controller/TestController.java
@@ -0,0 +1,56 @@
+package de.uni_passau.fim.se2.scratchlog.web.controller;
+
+import de.uni_passau.fim.se2.scratchlog.application.service.TestExecutionService;
+import de.uni_passau.fim.se2.scratchlog.application.service.TestResultService;
+import de.uni_passau.fim.se2.scratchlog.application.service.dto.ExperimentTestResults;
+import de.uni_passau.fim.se2.scratchlog.util.Constants;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Profile;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Profile(Constants.PROFILE_WHISKER)
+@RestController
+@RequestMapping("/whisker/test/")
+public class TestController {
+
+ private final TestResultService testResultService;
+
+ private final TestExecutionService testExecutionService;
+
+ @Autowired
+ public TestController(
+ final TestResultService testResultService,
+ final TestExecutionService testExecutionService
+ ) {
+ this.testResultService = testResultService;
+ this.testExecutionService = testExecutionService;
+ }
+
+ /**
+ * Retrieves the test results of the latest project of each user in the experiment.
+ *
+ * @param experimentId Some experiment.
+ * @return The test results for the latest project per user.
+ */
+ @GetMapping("latest")
+ public ExperimentTestResults latestTestResults(
+ @RequestParam("experimentId") final int experimentId
+ ) {
+ return testResultService.getLatestTestResults(experimentId);
+ }
+
+ /**
+ * Triggers the test execution for all projects in the experiment for which we do not yet have test results.
+ *
+ * @param experimentId Some experiment.
+ */
+ @PutMapping("trigger/all-missing")
+ public void triggerTestExecution(@RequestParam("experimentId") final int experimentId) {
+ testExecutionService.queueTestRuns(experimentId);
+ }
+
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/web/dto/JsFileDTO.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/web/dto/JsFileDTO.java
new file mode 100644
index 0000000..9d33656
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/web/dto/JsFileDTO.java
@@ -0,0 +1,28 @@
+package de.uni_passau.fim.se2.scratchlog.web.dto;
+
+import de.uni_passau.fim.se2.scratchlog.util.validation.annotation.ValidFile;
+import jakarta.validation.constraints.NotNull;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.ToString;
+import org.springframework.web.multipart.MultipartFile;
+
+@Builder
+@Getter
+@Setter
+@ToString
+@NoArgsConstructor
+@AllArgsConstructor
+public class JsFileDTO {
+
+ @ValidFile(
+ contentTypes = {"application/x-javascript", "text/javascript"},
+ fileEndings = {"js"}
+ )
+ @NotNull
+ private MultipartFile file;
+
+}
diff --git a/src/main/java/de/uni_passau/fim/se2/scratchlog/web/dto/Sb3FileDTO.java b/src/main/java/de/uni_passau/fim/se2/scratchlog/web/dto/Sb3FileDTO.java
new file mode 100644
index 0000000..2407460
--- /dev/null
+++ b/src/main/java/de/uni_passau/fim/se2/scratchlog/web/dto/Sb3FileDTO.java
@@ -0,0 +1,25 @@
+package de.uni_passau.fim.se2.scratchlog.web.dto;
+
+import de.uni_passau.fim.se2.scratchlog.util.validation.annotation.ValidFile;
+import jakarta.validation.constraints.NotNull;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.ToString;
+import org.springframework.web.multipart.MultipartFile;
+
+@Builder
+@Getter
+@Setter
+@ToString
+@NoArgsConstructor
+@AllArgsConstructor
+public class Sb3FileDTO {
+
+ @NotNull
+ @ValidFile(contentTypes = {"application/octet-stream"}, fileEndings = {"sb3"})
+ private MultipartFile file;
+
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 2ad6f62..7071b8c 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -63,5 +63,5 @@ spring.session.timeout=20
server.servlet.session.timeout=20m
# Maximum file size for uploading scratch projects
-spring.http.multipart.max-file-size=10MB
-spring.http.multipart.max-request-size=10MB
+spring.servlet.multipart.max-file-size=10MB
+spring.servlet.multipart.max-request-size=10MB
diff --git a/src/main/resources/db/migration/V8__experiment_example_solution.sql b/src/main/resources/db/migration/V8__experiment_example_solution.sql
new file mode 100644
index 0000000..01f832a
--- /dev/null
+++ b/src/main/resources/db/migration/V8__experiment_example_solution.sql
@@ -0,0 +1,8 @@
+create table example_solution (
+ id int not null auto_increment,
+ experiment_id int not null,
+ filename varchar(255),
+ sb3_project longblob,
+ primary key (id),
+ foreign key (experiment_id) references experiment (id) on delete cascade
+);
diff --git a/src/main/resources/db/migration/V9__whisker_integration.sql b/src/main/resources/db/migration/V9__whisker_integration.sql
new file mode 100644
index 0000000..7d324dc
--- /dev/null
+++ b/src/main/resources/db/migration/V9__whisker_integration.sql
@@ -0,0 +1,26 @@
+create table test_suite (
+ id int not null auto_increment,
+ experiment_id int not null unique,
+ filename varchar(255),
+ test_implementation longtext not null,
+ primary key (id),
+ foreign key (experiment_id) references experiment (id) on delete cascade
+);
+
+create table test_case (
+ id int not null auto_increment,
+ test_suite_id int not null,
+ name varchar(255) not null,
+ primary key (id),
+ foreign key (test_suite_id) references test_suite (id) on delete cascade
+);
+
+create table test_result (
+ id int not null auto_increment,
+ test_case_id int not null,
+ project_id int not null,
+ result enum('PASS', 'FAIL', 'ERROR', 'SKIP'),
+ primary key (id),
+ foreign key (test_case_id) references test_case (id) on delete cascade,
+ foreign key (project_id) references block_event (id) on delete cascade
+);
diff --git a/src/main/resources/db/undo/V8__experiment_example_solution.sql b/src/main/resources/db/undo/V8__experiment_example_solution.sql
new file mode 100644
index 0000000..1d1a0ec
--- /dev/null
+++ b/src/main/resources/db/undo/V8__experiment_example_solution.sql
@@ -0,0 +1 @@
+drop table example_solution;
diff --git a/src/main/resources/db/undo/V9__whisker_integration.sql b/src/main/resources/db/undo/V9__whisker_integration.sql
new file mode 100644
index 0000000..0926afe
--- /dev/null
+++ b/src/main/resources/db/undo/V9__whisker_integration.sql
@@ -0,0 +1,3 @@
+drop table test_result;
+drop table test_case;
+drop table test_suite;
diff --git a/src/main/resources/i18n/messages_de.properties b/src/main/resources/i18n/messages_de.properties
index 1abe579..3d9faa9 100644
--- a/src/main/resources/i18n/messages_de.properties
+++ b/src/main/resources/i18n/messages_de.properties
@@ -93,7 +93,8 @@ csv = CSV Datei
litterbox_csv = LitterBox Analyse
code_analysis = Codeanalyse
project = Sb3 Datei
-heading_sb3 = Scratch Projekt
+heading_sb3_start = Startprojekt
+heading_sb3_solution = Beispiellösung
delete_sb3 = Sb3 Datei löschen
delete_sb3_warn = Sind Sie sicher, dass Sie die aktuell gespeicherte Sb3 Datei löschen möchten? Diese Änderung kann \
nicht rückgängig gemacht werden. Sie können die Datei stets erneut hochladen.
@@ -459,3 +460,31 @@ error_password_pattern = Ein Passwort muss mindestens einen Groß- und Kleinbuch
error_file_type = Der Typ der hochgeladenen Datei ist ungültig. Gültige Dateitypen sind {expected}.
error_file_ending = Der Name der hochgeladenen Datei ist ungültig. Gültige Dateien haben eine der Endungen {expected}.
+# embeddings
+embedding_dashboard = Übungsfortschritts-Dashboard
+progress_variance_explanation = Die Progress-Variance-Projektion nutzt ein Machine-Learning-Modell, um den Fortschritt \
+ der Schüler*innen in der Übung einzuschätzen. Per Konstruktion ist das Startprojekt an Punkt (0,0) und die \
+ Beispiellösung an Punkt (1,0). Die x-Achse stellt somit den Forschritt zwischen diesen beiden Projekten dar. \
+ Auf der y-Achse wird dargestellt wie weit das aktuelle Schüler*innenprojekt vom normalen Lösungsweg abweicht. \
+ Der normale Lösungsweg kann dabei auch einen Bogen beschreiben. Abweichungen sind durch einzelne Punkte außerhalb \
+ von Gruppen zu erkennen.
+embedding_dashboard_chart_latest = Progress-Variance-Projektion letzte Projekte
+embedding_dashboard_chart_timeline = Progress-Variance-Projektion im Verlauf
+embedding_dashboard_chart_timeline_explanation = Wähle Schüler*innen aus für die du den Fortschritt der Lösung \
+ nachverfolgen möchtest. Jeder Punkt repräsentiert ein Programm.
+embedding_dashboard_chart_embedding_test_distance = Test- und Embedding-Fitness
+embedding_dashboard_embedding_test_distance_explanation = Die x-Achse repräsentiert den Anteil der erfolgreichen Tests \
+ der Testsuite für das Programm. Auf der y-Achse wird dargestellt wie nah ein Machine-Learning-Modell das Programm \
+ zur Beispiellösung einschätzt.
+reset_zoom = Zoom zurücksetzen
+embedding_timeline_interval = Intervall (Minuten):
+see_embedding_dashboard = Übungsfortschritts-Dashboard ansehen
+
+# whisker
+heading_testsuite = Testsuite
+delete_testsuite = Testsuite löschen
+delete_testsuite_warn = Testsuite löschen?
+whisker_test_results_heading = Whisker Testergebnisse
+whisker_test_results_explanation = Whisker Testergebnisse für das letzte Programm jedes Nutzers. Häkchen symbolisieren \
+ erfolgreiche Tests, Kreuze fehlgeschlagene. Andere Ergebnisse („error“) können Auftreten, falls Whisker bei der \
+ Testausführung abgestürzt ist.
diff --git a/src/main/resources/i18n/messages_en.properties b/src/main/resources/i18n/messages_en.properties
index 8451c8a..7c6f76a 100644
--- a/src/main/resources/i18n/messages_en.properties
+++ b/src/main/resources/i18n/messages_en.properties
@@ -89,7 +89,8 @@ csv = CSV File
litterbox_csv = LitterBox Analysis
code_analysis = Code Analysis
project = Sb3 Project
-heading_sb3 = Scratch Project
+heading_sb3_start = Starter Project
+heading_sb3_solution = Example Solution
delete_sb3 = Delete Sb3 File
delete_sb3_warn = Are you sure you want to delete the current sb3 project file? This change cannot be reverted. You \
can always upload the file again later.
@@ -438,3 +439,32 @@ error_password_pattern = A password must contain at least an uppercase and lower
special character.
error_file_type = The content type of the uploaded file is invalid. Valid file types are {expected}.
error_file_ending = The name of the uploaded file is invalid. Valid files end in one of {expected}.
+
+# embeddings
+embedding_dashboard = Exercise Progress Dashboard
+progress_variance_explanation = The progress-variance-projection uses a machine learning model to estimate the student \
+ progress in the exercise. Per construction the starting project is placed at point (0,0) and the example solution is \
+ at (1,0). Therefore, the x-axis represents the progress between these two projects. The y-axis denotes how far away \
+ current student projects are from the usual solution path. The usual solution path may follow a curved trajectory. \
+ Abnormal student projects can be discerned by standalone points apart from groups of points. Each point represents \
+ a student program.
+embedding_dashboard_chart_latest = Progress-Variance-Projection Latest Projects
+embedding_dashboard_chart_timeline = Progress-Variance-Projection over Time
+embedding_dashboard_chart_timeline_explanation = Select the students for which you want to view the progress towards \
+ the solution over time.
+embedding_dashboard_chart_embedding_test_distance = Test- and Embedding-Fitness
+embedding_dashboard_embedding_test_distance_explanation = The x-axis represents the proportion of successful tests of \
+ the test suite for the program. The y-axis shows an estimation of closeness of the program to the example solution \
+ as given by a machine learning model (1=perfect match).
+reset_zoom = Reset zoom
+embedding_timeline_interval = Interval (minutes):
+see_embedding_dashboard = View Exercise Progress Dashboard
+
+# whisker
+heading_testsuite = Test Suite
+delete_testsuite = Delete Test Suite
+delete_testsuite_warn = Really delete test suite?
+whisker_test_results_heading = Whisker Test Results
+whisker_test_results_explanation = Whisker test results for the newest project for each user. Checkmarks indicate \
+ passing tests, crosses indicate failing ones. Other states (e.g. ‘error’) indicate that Whisker crashed during \
+ execution.
diff --git a/src/main/resources/static/js/embeddingDashboard.js b/src/main/resources/static/js/embeddingDashboard.js
new file mode 100644
index 0000000..e44debe
--- /dev/null
+++ b/src/main/resources/static/js/embeddingDashboard.js
@@ -0,0 +1,437 @@
+/**
+ * Maps User ID to `{name, enabled}`.
+ *
+ * `enabled` marks the user as visible in the user-history trace.
+ */
+let participantsMap;
+let latestChart;
+let timelineChart;
+let timelineChartInterval = 5;
+let embeddingTestDistanceChart;
+let testResults;
+
+const fontSize = 14;
+
+const chartCommonOptions = {
+ responsive: true,
+ scales: {
+ x: {
+ title: {
+ display: true,
+ text: "Progress",
+ font: {
+ size: fontSize,
+ }
+ },
+ type: "linear",
+ ticks: {
+ font: {
+ size: fontSize,
+ }
+ }
+ },
+ y: {
+ title: {
+ display: true,
+ text: "Variance",
+ font: {
+ size: fontSize,
+ }
+ },
+ ticks: {
+ font: {
+ size: fontSize,
+ }
+ }
+ }
+ },
+ plugins: {
+ zoom: {
+ pan: {
+ enabled: true,
+ },
+ zoom: {
+ wheel: {
+ enabled: true,
+ },
+ pinch: {
+ enabled: true
+ },
+ }
+ },
+ legend: {
+ position: 'top',
+ labels: {
+ font: {
+ size: fontSize,
+ }
+ }
+ },
+ tooltip: {
+ titleFont: {
+ size: 14,
+ },
+ bodyFont: {
+ size: 12,
+ }
+ }
+
+ }
+};
+
+$(document).ready(() => {
+ participantsMap = new Map();
+ for (const p of participants) {
+ participantsMap.set(p.id, {name: p.username, enabled: false});
+ }
+ addUserSelectionEventListener()
+
+ const timelineChartIntervalInput = document.getElementById("chart-timeline-interval");
+ timelineChartIntervalInput.addEventListener("change", (event) => onTimelineIntervalChange(event.target.value));
+
+ setTimeout(() => updateAllLatestChart(), 0);
+ setTimeout(() => updateTimelineChart(), 0);
+ setTimeout(() => updateTestDistanceChart(), 0);
+ setTimeout(() => updateTestResultTable(), 0);
+});
+
+function addUserSelectionEventListener() {
+ const selection = document.getElementById("participant-selector");
+ selection.addEventListener("change", () => {
+ const values = new Set($('#participant-selector').val().map(Number));
+ participantsMap.forEach((v, k) => {
+ const enabled = values.has(k);
+ if (enabled !== v.enabled) {
+ toggleUserForTimeline(k, enabled);
+ }
+ });
+ });
+}
+
+function toggleUserForTimeline(userId, isEnabled) {
+ participantsMap.get(userId).enabled = isEnabled;
+ setTimeout(() => updateTimelineChart(), 0);
+ setTimeout(() => buildTestResultTable(), 0);
+}
+
+function xyToBubble(xy) {
+ return {x: xy[0], y: xy[1], r: 5}
+}
+
+function updateAllLatestChart() {
+ $.ajax({
+ type: "get",
+ url: contextPath + "embeddings/progress-variance-projection/all/latest",
+ data: {experimentId},
+ accept: "application/json",
+ success: function (data) {
+ const element = document.getElementById("chart-all-latest");
+
+ const labels = [];
+ const chartData = [];
+ for (const dataSeries of data.data) {
+ labels.push(participantsMap.get(dataSeries.userId).name);
+ const datapoint = dataSeries.datapoints[0];
+ chartData.push(xyToBubble(datapoint));
+ }
+
+ if (latestChart) {
+ latestChart.data.labels = labels;
+ latestChart.data.datasets = [{
+ label: translations.latestChart,
+ data: chartData,
+ }];
+ latestChart.update();
+ } else {
+ latestChart = new Chart(element, {
+ type: "bubble",
+ data: {
+ labels: labels,
+ datasets: [
+ {
+ label: "Latest Projects",
+ data: chartData,
+ },
+ {
+ label: "Starting Project",
+ data: [{x: 0, y: 0, r: 7}]
+ },
+ {
+ label: "Example Solution",
+ data: [{x: 1, y: 0, r: 7}]
+ },
+ ],
+ },
+ options: chartCommonOptions,
+ });
+ addResetZoomEventHandler("chart-all-latest-reset-zoom", latestChart);
+ }
+ },
+ error: function (err) {
+ console.log(err.statusText);
+ }
+ });
+}
+
+function updateTimelineChart() {
+ const userIds = [];
+ participantsMap.forEach((v, k) => {
+ if (v.enabled) {
+ userIds.push(k);
+ }
+ });
+
+ document.getElementById("chart-timeline").hidden = userIds.length === 0;
+ document.getElementById("chart-timeline-reset-zoom").hidden = userIds.length === 0;
+
+ if (userIds.length === 0) {
+ if (timelineChart) {
+ timelineChart.data.labels = [];
+ timelineChart.data.datasets = [];
+ timelineChart.update();
+ }
+ return;
+ }
+
+ $.ajax({
+ type: "get",
+ url: contextPath + "embeddings/progress-variance-projection/timeline",
+ data: {experimentId, userIds, stepMinutes: timelineChartInterval},
+ accept: "application/json",
+ success: function (data) {
+ const element = document.getElementById("chart-timeline");
+
+ const labels = [];
+ const datasets = [
+ {
+ label: "Starting Project",
+ data: [{x: 0, y: 0, r: 7}],
+ type: "bubble",
+ },
+ {
+ label: "Example Solution",
+ data: [{x: 1, y: 0, r: 7}],
+ type: "bubble",
+ },
+ ];
+ for (const dataSeries of data.data) {
+ datasets.push({
+ label: participantsMap.get(dataSeries.userId).name,
+ data: dataSeries.datapoints.map(xyToBubble),
+ tension: 0.1,
+ borderWidth: 3,
+ pointBorderWidth: 5,
+ });
+ for (let idx = 1; idx <= dataSeries.datapoints.length; ++idx) {
+ labels.push(idx.toString());
+ }
+ }
+
+ if (timelineChart) {
+ timelineChart.data.labels = labels;
+ timelineChart.data.datasets = datasets;
+ timelineChart.update();
+ } else {
+ timelineChart = new Chart(element, {
+ type: "line",
+ data: {
+ labels: labels,
+ datasets: datasets,
+ },
+ options: chartCommonOptions,
+ });
+ addResetZoomEventHandler("chart-timeline-reset-zoom", timelineChart);
+ }
+
+ },
+ error: function (err) {
+ console.log(err.statusText);
+ }
+ });
+}
+
+function updateTestDistanceChart() {
+ const element = document.getElementById("chart-embedding-test-distances");
+ if (!element) {
+ return;
+ }
+
+ $.ajax({
+ type: "get",
+ url: contextPath + "embeddings/embedding-test-distance/all/latest",
+ data: {experimentId},
+ accept: "application/json",
+ success: function (data) {
+ const labels = [];
+ const chartData = [];
+ for (const dataSeries of data.data) {
+ labels.push(participantsMap.get(dataSeries.userId).name);
+ const datapoint = dataSeries.datapoints[0];
+ if (datapoint[0] === 0 && datapoint[1] === 0) {
+ continue;
+ }
+ chartData.push(xyToBubble(datapoint));
+ }
+
+ if (embeddingTestDistanceChart) {
+ embeddingTestDistanceChart.data.labels = labels;
+ embeddingTestDistanceChart.data.datasets = [{
+ label: translations.embeddingTestDistanceChart,
+ data: chartData,
+ }];
+ embeddingTestDistanceChart.update();
+ } else {
+ embeddingTestDistanceChart = new Chart(element, {
+ type: "bubble",
+ data: {
+ labels: labels,
+ datasets: [
+ {
+ label: translations.embeddingTestDistanceChart,
+ data: chartData,
+ },
+ {
+ label: "ignored",
+ data: [{x: 0, y: 0}, {x: 1, y: 1}],
+ type: "line",
+ },
+ ],
+ },
+ options: {
+ scales: {
+ x: {
+ title: {
+ display: true,
+ text: "Test Fitness",
+ font: {
+ size: fontSize,
+ },
+ },
+ type: "linear",
+ ticks: {
+ font: {
+ size: fontSize,
+ },
+ },
+ },
+ y: {
+ title: {
+ display: true,
+ text: "Embedding Fitness",
+ font: {
+ size: fontSize,
+ },
+ },
+ ticks: {
+ font: {
+ size: fontSize,
+ },
+ },
+ },
+ },
+ plugins: {
+ legend: {
+ position: 'top',
+ labels: {
+ font: {
+ size: fontSize,
+ },
+ filter: function(item, chart) {
+ return !item.text.includes("ignored");
+ },
+ },
+ },
+ },
+ },
+ });
+ }
+ },
+ error: function (err) {
+ console.log(err.statusText);
+ }
+ });
+}
+
+function addResetZoomEventHandler(elementId, targetChart) {
+ const element = document.getElementById(elementId);
+ element.addEventListener("click", () => {
+ if (targetChart) {
+ targetChart.resetZoom();
+ }
+ });
+}
+
+function onTimelineIntervalChange(newValue) {
+ if (newValue !== timelineChartInterval) {
+ timelineChartInterval = newValue;
+ setTimeout(() => updateTimelineChart(), 0);
+ }
+}
+
+function updateTestResultTable() {
+ const table = document.getElementById("test-results-latest-table");
+ if (!table) {
+ return
+ }
+
+ $.ajax({
+ type: "get",
+ url: contextPath + "whisker/test/latest",
+ data: {experimentId},
+ accept: "application/json",
+ success: (data) => {
+ testResults = data;
+ buildTestResultTable();
+ },
+ });
+}
+
+function buildTestResultTable() {
+ const table = document.getElementById("test-results-latest-table");
+
+ const {
+ testCaseNames,
+ userProgramTestResults
+ } = testResults;
+
+ let html = `
User
`;
+ testCaseNames.forEach(name => html += `
${name}
`);
+ html += "
";
+
+ let atLeastOneEnabled = false
+
+ for (const user of userProgramTestResults) {
+ if (!participantsMap.get(user.userId).enabled) {
+ continue;
+ }
+
+ atLeastOneEnabled = true;
+
+ const username = user.username;
+ const userTestResults = user.testResults;
+
+ html += `