From 20eeb1a029ba26bdd8b8677b79b68b9781a02724 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Fri, 28 Nov 2025 17:23:16 +0000 Subject: [PATCH 01/65] WIP: add IT test for QuestionsFacade --- .../SegueGuiceConfigurationModule.java | 4 ++ .../api/AbstractIsaacIntegrationTest.java | 7 ++- .../cam/cl/dtg/isaac/api/CookieJarFilter.java | 33 ++++++++++ .../api/IsaacIntegrationTestWithREST.java | 45 +++++++++++--- .../cl/dtg/isaac/api/QuestionFacadeIT.java | 62 +++++++++++++++++++ 5 files changed, 141 insertions(+), 10 deletions(-) create mode 100644 src/test/java/uk/ac/cam/cl/dtg/isaac/api/CookieJarFilter.java create mode 100644 src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java b/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java index e1a05e78ea..a391dd4bde 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java @@ -1467,6 +1467,10 @@ public static synchronized Injector getGuiceInjector() { return injector; } + public static void setInjector(final Injector newInjector) { + injector = newInjector; + } + @Provides @Singleton public static Clock getDefaultClock() { diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/AbstractIsaacIntegrationTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/AbstractIsaacIntegrationTest.java index ef618bcd32..9fa413ef36 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/AbstractIsaacIntegrationTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/AbstractIsaacIntegrationTest.java @@ -47,9 +47,11 @@ import uk.ac.cam.cl.dtg.segue.api.managers.UserAccountManager; import uk.ac.cam.cl.dtg.segue.api.managers.UserAssociationManager; import uk.ac.cam.cl.dtg.segue.api.managers.UserAuthenticationManager; +import uk.ac.cam.cl.dtg.segue.api.monitors.AnonQuestionAttemptMisuseHandler; import uk.ac.cam.cl.dtg.segue.api.monitors.EmailVerificationMisuseHandler; import uk.ac.cam.cl.dtg.segue.api.monitors.GroupManagerLookupMisuseHandler; import uk.ac.cam.cl.dtg.segue.api.monitors.IMisuseMonitor; +import uk.ac.cam.cl.dtg.segue.api.monitors.IPQuestionAttemptMisuseHandler; import uk.ac.cam.cl.dtg.segue.api.monitors.InMemoryMisuseMonitor; import uk.ac.cam.cl.dtg.segue.api.monitors.RegistrationMisuseHandler; import uk.ac.cam.cl.dtg.segue.api.monitors.TeacherPasswordResetMisuseHandler; @@ -149,6 +151,7 @@ public class AbstractIsaacIntegrationTest { protected static IQuizQuestionAttemptPersistenceManager quizQuestionAttemptPersistenceManager; protected static QuizQuestionManager quizQuestionManager; protected static PgUsers pgUsers; + protected static ContentMapper contentMapper; // Services protected static AssignmentService assignmentService; @@ -240,7 +243,7 @@ public static void setUpClass() throws Exception { pgAnonymousUsers = new PgAnonymousUsers(postgresSqlDb); passwordDataManager = new PgPasswordDataManager(postgresSqlDb); - ContentMapper contentMapper = new ContentMapper(new Reflections("uk.ac.cam.cl.dtg")); + contentMapper = new ContentMapper(new Reflections("uk.ac.cam.cl.dtg")); PgQuestionAttempts pgQuestionAttempts = new PgQuestionAttempts(postgresSqlDb, contentMapper); questionManager = new QuestionManager(contentMapper, pgQuestionAttempts); @@ -314,6 +317,8 @@ public static void setUpClass() throws Exception { misuseMonitor.registerHandler(EmailVerificationMisuseHandler.class.getSimpleName(), new EmailVerificationMisuseHandler()); misuseMonitor.registerHandler(TeacherPasswordResetMisuseHandler.class.getSimpleName(), new TeacherPasswordResetMisuseHandler()); misuseMonitor.registerHandler(TokenOwnerLookupMisuseHandler.class.getSimpleName(), new TokenOwnerLookupMisuseHandler(emailManager, properties)); + misuseMonitor.registerHandler(AnonQuestionAttemptMisuseHandler.class.getSimpleName(), new AnonQuestionAttemptMisuseHandler()); + misuseMonitor.registerHandler(IPQuestionAttemptMisuseHandler.class.getSimpleName(), new IPQuestionAttemptMisuseHandler(emailManager, properties)); // todo: more handlers as required by different endpoints String someSegueAnonymousUserId = "9284723987anonymous83924923"; diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/CookieJarFilter.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/CookieJarFilter.java new file mode 100644 index 0000000000..fb4e54c346 --- /dev/null +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/CookieJarFilter.java @@ -0,0 +1,33 @@ +package uk.ac.cam.cl.dtg.isaac.api; + +import jakarta.ws.rs.client.ClientRequestContext; +import jakarta.ws.rs.client.ClientRequestFilter; +import jakarta.ws.rs.client.ClientResponseContext; +import jakarta.ws.rs.client.ClientResponseFilter; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.NewCookie; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/* + * JAX-RS Client filter for storing and retrieving cookies across requests. + */ +public class CookieJarFilter implements ClientRequestFilter, ClientResponseFilter { + private final Map cookieJar = new ConcurrentHashMap<>(); + + @Override + public void filter(final ClientRequestContext requestContext) { + if (!cookieJar.isEmpty()) { + String header = cookieJar.values().stream() + .map(c -> c.getName() + "=" + c.getValue()) + .collect(Collectors.joining("; ")); + requestContext.getHeaders().putSingle(HttpHeaders.COOKIE, header); + } + } + + @Override + public void filter(final ClientRequestContext requestContext, final ClientResponseContext responseContext) { + responseContext.getCookies().values().forEach(c -> cookieJar.put(c.getName(), c)); + } +} diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/IsaacIntegrationTestWithREST.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/IsaacIntegrationTestWithREST.java index 4055920f48..7ec37134a1 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/IsaacIntegrationTestWithREST.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/IsaacIntegrationTestWithREST.java @@ -8,10 +8,16 @@ import org.json.JSONObject; import org.junit.function.ThrowingRunnable; import org.junit.jupiter.api.AfterEach; +import uk.ac.cam.cl.dtg.isaac.dos.users.RegisteredUser; +import uk.ac.cam.cl.dtg.isaac.dto.LocalAuthDTO; +import uk.ac.cam.cl.dtg.isaac.dto.users.RegisteredUserDTO; +import jakarta.ws.rs.client.Client; import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.client.Invocation; import jakarta.ws.rs.core.Application; +import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.util.HashSet; import java.util.Map; @@ -110,22 +116,37 @@ static class TestClient { String baseUrl; Consumer registerCleanup; RequestBuilder builder; + RegisteredUserDTO currentUser; + Client client; - TestClient( - final String baseUrl, final Consumer registerCleanup, final RequestBuilder builder - ) { + TestClient(final String baseUrl, final Consumer registerCleanup, final RequestBuilder builder) { this.baseUrl = baseUrl; this.registerCleanup = registerCleanup; this.builder = builder; + this.client = ClientBuilder.newClient().register(new CookieJarFilter()); } public TestResponse get(final String url) { - try (var client = ClientBuilder.newClient()) { - var request = client.target(baseUrl + url).request(); - var response = builder.apply(request).get(); - registerCleanup.accept(response::close); - return new TestResponse(response); - } + var request = client.target(baseUrl + url).request(MediaType.APPLICATION_JSON); + var response = builder.apply(request).get(); + registerCleanup.accept(response::close); + return new TestResponse(response); + } + + public TestResponse post(final String url, final Object body) { + var request = client.target(baseUrl + url).request(MediaType.APPLICATION_JSON); + var response = builder.apply(request).post(Entity.json(body)); + registerCleanup.accept(response::close); + return new TestResponse(response); + } + + public TestClient loginAs(final RegisteredUser user) { + var request = client.target(baseUrl + "/auth/SEGUE/authenticate").request(MediaType.APPLICATION_JSON); + var body = new LocalAuthDTO(); + body.setEmail(user.getEmail()); + body.setPassword("test1234"); + this.currentUser = builder.apply(request).post(Entity.json(body), RegisteredUserDTO.class); + return this; } } @@ -161,6 +182,12 @@ T readEntity(final Class klass) { assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); return response.readEntity(klass); } + + JSONObject readEntityAsJson() { + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + String body = response.readEntity(String.class); + return new JSONObject(body); + } } interface RequestBuilder extends Function {} diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java new file mode 100644 index 0000000000..033ffe404f --- /dev/null +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -0,0 +1,62 @@ +package uk.ac.cam.cl.dtg.isaac.api; +import com.google.inject.Injector; +import org.junit.jupiter.api.Test; +import uk.ac.cam.cl.dtg.isaac.quiz.IsaacStringMatchValidator; +import uk.ac.cam.cl.dtg.segue.api.QuestionFacade; +import uk.ac.cam.cl.dtg.segue.configuration.SegueGuiceConfigurationModule; + +import jakarta.ws.rs.core.Response; + +import static org.easymock.EasyMock.createNiceMock; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.replay; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SuppressWarnings("checkstyle:MissingJavadocType") +public class QuestionFacadeIT extends IsaacIntegrationTestWithREST { + @Test + public void shows400ForMissingAnswer() throws Exception { + var response = subject().client().post("/questions/no_such_question/answer", null); + response.assertError("No answer received.", Response.Status.BAD_REQUEST); + } + + @Test + public void shows404ForMissingQuestion() throws Exception { + var response = subject().client().post("/questions/no_such_question/answer", "{}"); + response.assertError("No question object found for given id: no_such_question", Response.Status.NOT_FOUND); + } + + @Test + public void incorrectAnswerToStringMatchQuestion() throws Exception { + var questionId = "/questions/_regression_test_|acc_stringmatch_q|_regression_test_stringmatch_/answer"; + var response = subject().client().post(questionId, "{\"type\": \"stringChoice\", \"value\": \"13\"}") + .readEntityAsJson(); + + assertEquals(false, response.getBoolean("correct")); + assertEquals("13", response.getJSONObject("answer").getString("value")); + } + + @Test + public void correctAnswerToStringMatchQuestion() throws Exception { + var questionId = "/questions/_regression_test_|acc_stringmatch_q|_regression_test_stringmatch_/answer"; + var response = subject().client().post(questionId, "{\"type\": \"stringChoice\", \"value\": \"hello\"}") + .readEntityAsJson(); + + assertEquals(true, response.getBoolean("correct")); + assertEquals("hello", response.getJSONObject("answer").getString("value")); + } + + TestServer subject() throws Exception { + Injector testInjector = createNiceMock(Injector.class); + expect(testInjector.getInstance(IsaacStringMatchValidator.class)).andReturn(stringMatchValidator).anyTimes(); + replay(testInjector); + SegueGuiceConfigurationModule.setInjector(testInjector); + + return startServer( + new QuestionFacade(properties, contentMapper, contentManager, userAccountManager, + userPreferenceManager, questionManager, logManager, misuseMonitor, null, userAssociationManager) + ); + } + + private static final IsaacStringMatchValidator stringMatchValidator = new IsaacStringMatchValidator(); +} From e6422ff67249e446148b2d2079fc33cf30cf0e5f Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Fri, 28 Nov 2025 13:00:30 +0000 Subject: [PATCH 02/65] WIP: validation works for correct, incorrect answers --- .../cl/dtg/isaac/dos/IsaacDndQuestion.java | 86 ++++++++++++++- .../dtg/isaac/dos/content/DndItemChoice.java | 69 ++++++++++++ .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 74 +++++++++++++ .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 100 ++++++++++++++++++ 4 files changed, 326 insertions(+), 3 deletions(-) create mode 100644 src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java create mode 100644 src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java create mode 100644 src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java index 485bf0db11..a1e2ebe84e 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java @@ -15,13 +15,23 @@ */ package uk.ac.cam.cl.dtg.isaac.dos; +import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; import uk.ac.cam.cl.dtg.isaac.dos.content.DTOMapping; +import uk.ac.cam.cl.dtg.isaac.dos.content.DndItem; +import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; +import uk.ac.cam.cl.dtg.isaac.dos.content.Item; import uk.ac.cam.cl.dtg.isaac.dos.content.JsonContentType; +import uk.ac.cam.cl.dtg.isaac.dos.content.Question; import uk.ac.cam.cl.dtg.isaac.dto.IsaacClozeQuestionDTO; import uk.ac.cam.cl.dtg.isaac.dto.IsaacDndQuestionDTO; +import uk.ac.cam.cl.dtg.isaac.dto.IsaacItemQuestionDTO; import uk.ac.cam.cl.dtg.isaac.quiz.IsaacClozeValidator; +import uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidator; +import uk.ac.cam.cl.dtg.isaac.quiz.IsaacItemQuestionValidator; import uk.ac.cam.cl.dtg.isaac.quiz.ValidatesWith; +import java.util.List; + /** * Content DO for IsaacDndQuestions. @@ -29,8 +39,37 @@ */ @DTOMapping(IsaacDndQuestionDTO.class) @JsonContentType("isaacDndQuestion") -@ValidatesWith(IsaacClozeValidator.class) -public class IsaacDndQuestion extends IsaacItemQuestion { +@ValidatesWith(IsaacDndValidator.class) +public class IsaacDndQuestion extends Question { + private List items; + private Boolean randomiseItems; + + public List getItems() { + return items; + } + + public void setItems(final List items) { + this.items = items; + } + + /** + * Gets whether to randomiseItems. + * + * @return randomiseItems + */ + public Boolean getRandomiseItems() { + return randomiseItems; + } + + /** + * Sets the randomiseItems. + * + * @param randomiseItems + * the randomiseItems to set + */ + public void setRandomiseItems(final Boolean randomiseItems) { + this.randomiseItems = randomiseItems; + } private Boolean withReplacement; // Detailed feedback option not needed in the client so not in DTO: @@ -51,4 +90,45 @@ public Boolean getDetailedItemFeedback() { public void setDetailedItemFeedback(final Boolean detailedItemFeedback) { this.detailedItemFeedback = detailedItemFeedback; } -} \ No newline at end of file + + protected List choices; + protected Boolean randomiseChoices; + + /** + * Gets the choices. + * + * @return the choices + */ + public final List getChoices() { + return choices; + } + + /** + * Sets the choices. + * + * @param choices + * the choices to set + */ + public final void setChoices(final List choices) { + this.choices = choices; + } + + /** + * Gets the whether to randomlyOrderUnits. + * + * @return randomiseChoices + */ + public Boolean getRandomiseChoices() { + return randomiseChoices; + } + + /** + * Sets the randomiseChoices. + * + * @param randomiseChoices + * the randomiseChoices to set + */ + public void setRandomiseChoices(final Boolean randomiseChoices) { + this.randomiseChoices = randomiseChoices; + } +} diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java new file mode 100644 index 0000000000..366b83888b --- /dev/null +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java @@ -0,0 +1,69 @@ +/* + * Copyright 2019 James Sharkey + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package uk.ac.cam.cl.dtg.isaac.dos.content; + +import uk.ac.cam.cl.dtg.isaac.dto.content.ItemChoiceDTO; + +import java.util.List; +import java.util.Optional; + +/** + * Choice for Item Questions, containing a list of Items. + * + */ +@DTOMapping(ItemChoiceDTO.class) +@JsonContentType("itemChoice") +public class DndItemChoice extends Choice { + + private Boolean allowSubsetMatch; + private List items; + + /** + * Default constructor required for mapping. + */ + public DndItemChoice() { + } + + public List getItems() { + return items; + } + + public void setItems(final List items) { + this.items = items; + } + + public Boolean isAllowSubsetMatch() { + return this.allowSubsetMatch; + } + + public void setAllowSubsetMatch(final boolean allowSubsetMatch) { + this.allowSubsetMatch = allowSubsetMatch; + } + + public boolean matches(final DndItemChoice rhs) { + return this.items.stream().allMatch(lhsItem -> + rhs.getItemByDropZone(lhsItem.getDropZoneId()) + .map(rhsItem -> rhsItem.getId().equals(lhsItem.getId())) + .orElse(false) + ); + } + + private Optional getItemByDropZone(final String dropZoneId) { + return this.items.stream() + .filter(item -> item.getDropZoneId().equals(dropZoneId)) + .findFirst(); + } +} diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java new file mode 100644 index 0000000000..c240bff3bb --- /dev/null +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -0,0 +1,74 @@ +/* + * Copyright 2021 Chris Purdy, 2022 James Sharkey + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package uk.ac.cam.cl.dtg.isaac.quiz; + +import com.google.api.client.util.Lists; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import uk.ac.cam.cl.dtg.isaac.dos.IsaacClozeQuestion; +import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; +import uk.ac.cam.cl.dtg.isaac.dos.ItemValidationResponse; +import uk.ac.cam.cl.dtg.isaac.dos.QuestionValidationResponse; +import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; +import uk.ac.cam.cl.dtg.isaac.dos.content.Content; +import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; +import uk.ac.cam.cl.dtg.isaac.dos.content.Item; +import uk.ac.cam.cl.dtg.isaac.dos.content.ItemChoice; +import uk.ac.cam.cl.dtg.isaac.dos.content.Question; + +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static uk.ac.cam.cl.dtg.isaac.api.Constants.*; + +/** + * Validator that only provides functionality to validate Cloze questions. + */ +public class IsaacDndValidator implements IValidator { + private static final Logger log = LoggerFactory.getLogger(IsaacClozeValidator.class); + protected static final String NULL_CLOZE_ITEM_ID = "NULL_CLOZE_ITEM"; + + @Override + public final QuestionValidationResponse validateQuestionResponse(final Question question, final Choice answer) { + Objects.requireNonNull(question); + Objects.requireNonNull(answer); + + if (!(answer instanceof DndItemChoice)) { + throw new IllegalArgumentException(String.format( + "This validator only works with IsaacDndQuestions (%s is not DnDuestion)", question.getId())); + } + + if (!(question instanceof IsaacDndQuestion)) { + throw new IllegalArgumentException(String.format( + "This validator only works with IsaacDndQuestions (%s is not DnDuestion)", question.getId())); + } + IsaacDndQuestion dndQuestion = (IsaacDndQuestion) question; + DndItemChoice userAnswer = (DndItemChoice) answer; + var correctAnswers = dndQuestion.getChoices().stream().filter(Choice::isCorrect); + var isCorrect = correctAnswers.anyMatch(correctAnswer -> correctAnswer.matches(userAnswer)); + return new ItemValidationResponse(question.getId(), answer, isCorrect, null, null, new Date()); + } + + @Override + public List getOrderedChoices(final List choices) { + return IsaacItemQuestionValidator.getOrderedChoicesWithSubsets(choices); + } +} diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java new file mode 100644 index 0000000000..41ca733b48 --- /dev/null +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -0,0 +1,100 @@ +/* + * Copyright 2022 James Sharkey + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package uk.ac.cam.cl.dtg.isaac.quiz; + +import com.google.common.collect.ImmutableList; +import org.junit.Test; +import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; +import uk.ac.cam.cl.dtg.isaac.dos.QuestionValidationResponse; +import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; +import uk.ac.cam.cl.dtg.isaac.dos.content.DndItem; +import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; +import uk.ac.cam.cl.dtg.isaac.dos.content.Item; +import uk.ac.cam.cl.dtg.isaac.dos.content.ItemChoice; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class IsaacDndValidatorTest { + + /* + Test that correct answers are recognised. + */ + @Test + public final void correctItems_CorrectResponseShouldBeReturned() { + var question = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + ); + var choices = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); + + var response = testValidate(question, choices); + + assertTrue(response.isCorrect()); + } + + /* + Test that incorrect answers are not recognised. + */ + @Test + public final void incorrectItems_IncorrectResponseShouldBeReturned() { + var question = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + ); + var choices = answer(choose(item_4cm, "leg_1"), choose(item_5cm, "leg_2"), choose(item_3cm, "hypothenuse")); + + var response = testValidate(question, choices); + + assertFalse(response.isCorrect()); + } + + private static QuestionValidationResponse testValidate(final IsaacDndQuestion question, final Choice choice) { + return new IsaacDndValidator().validateQuestionResponse(question, choice); + } + + private static DndItemChoice answer(final DndItem... list) { + var c = new DndItemChoice(); + c.setItems(List.of(list)); + return c; + } + + private static DndItem choose(final Item item, final String str) { + return new DndItem(item.getId(), item.getValue(), str); + } + + private static IsaacDndQuestion createQuestion(final DndItemChoice... answers) { + var question = new IsaacDndQuestion(); + question.setItems(List.of(item_3cm, item_4cm, item_5cm, item_6cm)); + question.setChoices(List.of(answers)); + return question; + } + + private static DndItemChoice correct(final DndItemChoice choice) { + choice.setCorrect(true); + return choice; + } + + private static final Item item_3cm = new Item("6d3d", "3 cm"); + private static final Item item_4cm = new Item("6d3e", "4 cm"); + private static final Item item_5cm = new Item("6d3f", "5 cm"); + private static final Item item_6cm = new Item("6d3g", "5 cm"); +} From 160016bbf08f788685b4bbb6534e73e4b0dcbc57 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Fri, 28 Nov 2025 17:33:03 +0000 Subject: [PATCH 03/65] does this trigger a build? From 049c50fc9776c72ccd0b3f813e1b5304faac432d Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Fri, 28 Nov 2025 17:42:27 +0000 Subject: [PATCH 04/65] gentle modifications to QuestionFacadeIT --- .../cl/dtg/isaac/api/QuestionFacadeIT.java | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java index 033ffe404f..2665f5eb93 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -11,6 +11,8 @@ import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.replay; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; @SuppressWarnings("checkstyle:MissingJavadocType") public class QuestionFacadeIT extends IsaacIntegrationTestWithREST { @@ -28,21 +30,23 @@ public void shows404ForMissingQuestion() throws Exception { @Test public void incorrectAnswerToStringMatchQuestion() throws Exception { - var questionId = "/questions/_regression_test_|acc_stringmatch_q|_regression_test_stringmatch_/answer"; - var response = subject().client().post(questionId, "{\"type\": \"stringChoice\", \"value\": \"13\"}") - .readEntityAsJson(); + var response = subject().client().post( + "/questions/_regression_test_|acc_stringmatch_q|_regression_test_stringmatch_/answer", + "{\"type\": \"stringChoice\", \"value\": \"13\"}" + ).readEntityAsJson(); - assertEquals(false, response.getBoolean("correct")); + assertFalse(response.getBoolean("correct")); assertEquals("13", response.getJSONObject("answer").getString("value")); } @Test public void correctAnswerToStringMatchQuestion() throws Exception { - var questionId = "/questions/_regression_test_|acc_stringmatch_q|_regression_test_stringmatch_/answer"; - var response = subject().client().post(questionId, "{\"type\": \"stringChoice\", \"value\": \"hello\"}") - .readEntityAsJson(); + var response = subject().client().post( + "/questions/_regression_test_|acc_stringmatch_q|_regression_test_stringmatch_/answer", + "{\"type\": \"stringChoice\", \"value\": \"hello\"}" + ).readEntityAsJson(); - assertEquals(true, response.getBoolean("correct")); + assertTrue(response.getBoolean("correct")); assertEquals("hello", response.getJSONObject("answer").getString("value")); } From 783fc11f679df658c29352b2d1ca342ea4c67a67 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 1 Dec 2025 10:57:35 +0000 Subject: [PATCH 05/65] fix failing integration tests --- .../ac/cam/cl/dtg/isaac/api/AbstractIsaacIntegrationTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/AbstractIsaacIntegrationTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/AbstractIsaacIntegrationTest.java index 5fe5c382a3..82f3353168 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/AbstractIsaacIntegrationTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/AbstractIsaacIntegrationTest.java @@ -247,9 +247,9 @@ public static void setUpClass() throws Exception { contentMapper = new ContentSubclassMapper(new Reflections("uk.ac.cam.cl.dtg")); PgQuestionAttempts pgQuestionAttempts = new PgQuestionAttempts(postgresSqlDb, contentMapper); + mainMapper = MainMapper.INSTANCE; questionManager = new QuestionManager(contentMapper, mainMapper, pgQuestionAttempts); - mainMapper = MainMapper.INSTANCE; providersToRegister = new HashMap<>(); providersToRegister.put(AuthenticationProvider.RASPBERRYPI, new RaspberryPiOidcAuthenticator( From 558eab8b394af83522da22605a9c17ae79975761 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 1 Dec 2025 11:03:34 +0000 Subject: [PATCH 06/65] test that build can be broken --- src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java index 2665f5eb93..18a0ff41aa 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -46,7 +46,7 @@ public void correctAnswerToStringMatchQuestion() throws Exception { "{\"type\": \"stringChoice\", \"value\": \"hello\"}" ).readEntityAsJson(); - assertTrue(response.getBoolean("correct")); + assertFalse(response.getBoolean("correct")); assertEquals("hello", response.getJSONObject("answer").getString("value")); } From 28fc161a32e5939bca3964fcfc3776622f34a1ee Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 1 Dec 2025 11:07:42 +0000 Subject: [PATCH 07/65] Revert "test that build can be broken" This reverts commit 558eab8b394af83522da22605a9c17ae79975761. --- src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java index 18a0ff41aa..2665f5eb93 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -46,7 +46,7 @@ public void correctAnswerToStringMatchQuestion() throws Exception { "{\"type\": \"stringChoice\", \"value\": \"hello\"}" ).readEntityAsJson(); - assertFalse(response.getBoolean("correct")); + assertTrue(response.getBoolean("correct")); assertEquals("hello", response.getJSONObject("answer").getString("value")); } From f398bb3b723e0dd17e425a03fc81b51ca77930e1 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 1 Dec 2025 15:30:17 +0000 Subject: [PATCH 08/65] add integration test for incorrect dnd answer --- .../dtg/isaac/dos/content/DndItemChoice.java | 6 +- .../isaac/dto/content/DndItemChoiceDTO.java | 47 +++++++++++ .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 4 +- .../segue/dao/content/ChoiceDeserializer.java | 2 +- .../dtg/segue/etl/ElasticSearchIndexer.java | 4 +- .../cl/dtg/util/mappers/ContentMapper.java | 2 + .../api/AbstractIsaacIntegrationTest.java | 5 +- .../cl/dtg/isaac/api/QuestionFacadeIT.java | 84 ++++++++++++++----- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 46 ++++++---- 9 files changed, 153 insertions(+), 47 deletions(-) create mode 100644 src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/DndItemChoiceDTO.java diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java index 366b83888b..a95435592a 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java @@ -15,7 +15,7 @@ */ package uk.ac.cam.cl.dtg.isaac.dos.content; -import uk.ac.cam.cl.dtg.isaac.dto.content.ItemChoiceDTO; +import uk.ac.cam.cl.dtg.isaac.dto.content.DndItemChoiceDTO; import java.util.List; import java.util.Optional; @@ -24,8 +24,8 @@ * Choice for Item Questions, containing a list of Items. * */ -@DTOMapping(ItemChoiceDTO.class) -@JsonContentType("itemChoice") +@DTOMapping(DndItemChoiceDTO.class) +@JsonContentType("dndChoice") public class DndItemChoice extends Choice { private Boolean allowSubsetMatch; diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/DndItemChoiceDTO.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/DndItemChoiceDTO.java new file mode 100644 index 0000000000..5d0fc36668 --- /dev/null +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/DndItemChoiceDTO.java @@ -0,0 +1,47 @@ +/* + * Copyright 2019 James Sharkey + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package uk.ac.cam.cl.dtg.isaac.dto.content; + +import java.util.List; + +/** + * Choice for Dnd Questions, containing a list of DndItems. + * + */ +public class DndItemChoiceDTO extends ChoiceDTO { + + private Boolean allowSubsetMatch; + private List items; + + /** + * Default constructor required for mapping. + */ + public DndItemChoiceDTO() { + } + + public List getItems() { + return items; + } + + public void setItems(final List items) { + this.items = items; + } + + public Boolean isAllowSubsetMatch() { return this.allowSubsetMatch; } + + public void setAllowSubsetMatch(final boolean allowSubsetMatch) { this.allowSubsetMatch = allowSubsetMatch; } + +} diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index c240bff3bb..365d53c00a 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -53,12 +53,12 @@ public final QuestionValidationResponse validateQuestionResponse(final Question if (!(answer instanceof DndItemChoice)) { throw new IllegalArgumentException(String.format( - "This validator only works with IsaacDndQuestions (%s is not DnDuestion)", question.getId())); + "This validator only works with DndItemChoices (%s is not DndItemChoice)", question.getId())); } if (!(question instanceof IsaacDndQuestion)) { throw new IllegalArgumentException(String.format( - "This validator only works with IsaacDndQuestions (%s is not DnDuestion)", question.getId())); + "This validator only works with IsaacDndQuestions (%s is not IsaacDndQuestion)", question.getId())); } IsaacDndQuestion dndQuestion = (IsaacDndQuestion) question; DndItemChoice userAnswer = (DndItemChoice) answer; diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/content/ChoiceDeserializer.java b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/content/ChoiceDeserializer.java index b40e92c7eb..65636632b5 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/content/ChoiceDeserializer.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/content/ChoiceDeserializer.java @@ -90,7 +90,7 @@ public Choice deserialize(final JsonParser jsonParser, final DeserializationCont case "coordinateChoice": return getSingletonChoiceMapper().readValue(root.toString(), CoordinateChoice.class); case "dndChoice": - return getSingletonChoiceMapper().readValue(root.toString(), DndChoice.class); + return getSingletonChoiceMapper().readValue(root.toString(), DndItemChoice.class); case "itemChoice": return getSingletonChoiceMapper().readValue(root.toString(), ItemChoice.class); default: diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/etl/ElasticSearchIndexer.java b/src/main/java/uk/ac/cam/cl/dtg/segue/etl/ElasticSearchIndexer.java index 96ce5be527..f31d1d7205 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/etl/ElasticSearchIndexer.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/etl/ElasticSearchIndexer.java @@ -43,7 +43,7 @@ /** * Created by Ian on 17/10/2016. */ -class ElasticSearchIndexer extends ElasticSearchProvider { +public class ElasticSearchIndexer extends ElasticSearchProvider { private static final Integer BULK_REQUEST_BATCH_SIZE = 10000; // Huge requests overwhelm ES, so batch! private static final Logger log = LoggerFactory.getLogger(ElasticSearchIndexer.class); @@ -131,7 +131,7 @@ void bulkIndex(final String indexBase, final String indexType, final List> dataToIndex) + public void bulkIndexWithIDs(final String indexBase, final String indexType, final List> dataToIndex) throws SegueSearchException { Iterable>> partitions = Iterables.partition(dataToIndex, BULK_REQUEST_BATCH_SIZE); diff --git a/src/main/java/uk/ac/cam/cl/dtg/util/mappers/ContentMapper.java b/src/main/java/uk/ac/cam/cl/dtg/util/mappers/ContentMapper.java index acc9a96f00..2d4c99c2cf 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/util/mappers/ContentMapper.java +++ b/src/main/java/uk/ac/cam/cl/dtg/util/mappers/ContentMapper.java @@ -62,6 +62,7 @@ default T map(ContentDTO source, Class targetClass) { @SubclassMapping(source = RegexPattern.class, target = RegexPatternDTO.class) @SubclassMapping(source = StringChoice.class, target = StringChoiceDTO.class) // ItemChoice subclasses must come before ItemChoice + @SubclassMapping(source = DndItemChoice.class, target = DndItemChoiceDTO.class) @SubclassMapping(source = CoordinateChoice.class, target = CoordinateChoiceDTO.class) @SubclassMapping(source = ParsonsChoice.class, target = ParsonsChoiceDTO.class) @SubclassMapping(source = ItemChoice.class, target = ItemChoiceDTO.class) @@ -81,6 +82,7 @@ default T map(ContentDTO source, Class targetClass) { @SubclassMapping(source = RegexPatternDTO.class, target = RegexPattern.class) @SubclassMapping(source = StringChoiceDTO.class, target = StringChoice.class) // ItemChoiceDTO subclasses must come before ItemChoiceDTO + @SubclassMapping(source = DndItemChoiceDTO.class, target = DndItemChoice.class) @SubclassMapping(source = CoordinateChoiceDTO.class, target = CoordinateChoice.class) @SubclassMapping(source = ParsonsChoiceDTO.class, target = ParsonsChoice.class) @SubclassMapping(source = ItemChoiceDTO.class, target = ItemChoice.class) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/AbstractIsaacIntegrationTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/AbstractIsaacIntegrationTest.java index 82f3353168..18db76daa4 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/AbstractIsaacIntegrationTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/AbstractIsaacIntegrationTest.java @@ -83,6 +83,7 @@ import uk.ac.cam.cl.dtg.segue.dao.users.PgUsers; import uk.ac.cam.cl.dtg.segue.database.GitDb; import uk.ac.cam.cl.dtg.segue.database.PostgresSqlDb; +import uk.ac.cam.cl.dtg.segue.etl.ElasticSearchIndexer; import uk.ac.cam.cl.dtg.segue.search.ElasticSearchProvider; import uk.ac.cam.cl.dtg.util.AbstractConfigLoader; import uk.ac.cam.cl.dtg.util.YamlLoader; @@ -121,7 +122,7 @@ public class AbstractIsaacIntegrationTest { protected static AbstractConfigLoader properties; protected static Map globalTokens; protected static PostgresSqlDb postgresSqlDb; - protected static ElasticSearchProvider elasticSearchProvider; + protected static ElasticSearchIndexer elasticSearchProvider; protected static SchoolListReader schoolListReader; protected static MainMapper mainMapper; protected static ContentSummarizerService contentSummarizerService; @@ -184,7 +185,7 @@ public class AbstractIsaacIntegrationTest { elasticsearch.start(); try { - elasticSearchProvider = new ElasticSearchProvider(ElasticSearchProvider.getClient( + elasticSearchProvider = new ElasticSearchIndexer(ElasticSearchProvider.getClient( "localhost", elasticsearch.getMappedPort(9200), "elastic", diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java index 2665f5eb93..8a9418c717 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -1,18 +1,26 @@ package uk.ac.cam.cl.dtg.isaac.api; import com.google.inject.Injector; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; +import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; +import uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidator; import uk.ac.cam.cl.dtg.isaac.quiz.IsaacStringMatchValidator; import uk.ac.cam.cl.dtg.segue.api.QuestionFacade; import uk.ac.cam.cl.dtg.segue.configuration.SegueGuiceConfigurationModule; import jakarta.ws.rs.core.Response; +import java.util.List; + import static org.easymock.EasyMock.createNiceMock; import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.replay; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.testcontainers.shaded.com.google.common.collect.Maps.immutableEntry; +import static uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidatorTest.*; @SuppressWarnings("checkstyle:MissingJavadocType") public class QuestionFacadeIT extends IsaacIntegrationTestWithREST { @@ -28,31 +36,64 @@ public void shows404ForMissingQuestion() throws Exception { response.assertError("No question object found for given id: no_such_question", Response.Status.NOT_FOUND); } - @Test - public void incorrectAnswerToStringMatchQuestion() throws Exception { - var response = subject().client().post( - "/questions/_regression_test_|acc_stringmatch_q|_regression_test_stringmatch_/answer", - "{\"type\": \"stringChoice\", \"value\": \"13\"}" - ).readEntityAsJson(); - - assertFalse(response.getBoolean("correct")); - assertEquals("13", response.getJSONObject("answer").getString("value")); + @Nested + class StringMatchQuestion { + @Test + public void incorrect() throws Exception { + var response = subject().client().post( + url("_regression_test_|acc_stringmatch_q|_regression_test_stringmatch_"), + "{\"type\": \"stringChoice\", \"value\": \"13\"}" + ).readEntityAsJson(); + + assertFalse(response.getBoolean("correct")); + assertEquals("13", response.getJSONObject("answer").getString("value")); + } + + @Test + public void correct() throws Exception { + var response = subject().client().post( + url("_regression_test_|acc_stringmatch_q|_regression_test_stringmatch_"), + "{\"type\": \"stringChoice\", \"value\": \"hello\"}" + ).readEntityAsJson(); + + assertTrue(response.getBoolean("correct")); + assertEquals("hello", response.getJSONObject("answer").getString("value")); + } } - @Test - public void correctAnswerToStringMatchQuestion() throws Exception { - var response = subject().client().post( - "/questions/_regression_test_|acc_stringmatch_q|_regression_test_stringmatch_/answer", - "{\"type\": \"stringChoice\", \"value\": \"hello\"}" - ).readEntityAsJson(); - - assertTrue(response.getBoolean("correct")); - assertEquals("hello", response.getJSONObject("answer").getString("value")); + @Nested + class DndQuestion { + @Test + public void incorrect() throws Exception { + var dndQuestion = persist(createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + )); + var answer = answer(choose(item_3cm, "leg_2"), choose(item_4cm, "hypothenuse"), choose(item_5cm, "leg_1")); + + var response = subject().client().post(url(dndQuestion.getId()), answer).readEntityAsJson(); + + assertFalse(response.getBoolean("correct")); + DndItemChoice answerFromResponse = contentMapper.getSharedContentObjectMapper() + .readValue(response.getJSONObject("answer").toString(), DndItemChoice.class); + assertEquals(answer, answerFromResponse); + } + } + + private IsaacDndQuestion persist(final IsaacDndQuestion question) throws Exception { + elasticSearchProvider.bulkIndexWithIDs( + "6c2ba42c5c83d8f31b3b385b3a9f9400a12807c9", + "content", + List.of(immutableEntry( + question.getId(), contentMapper.getSharedContentObjectMapper().writeValueAsString(question)) + ) + ); + return question; } - TestServer subject() throws Exception { + private TestServer subject() throws Exception { Injector testInjector = createNiceMock(Injector.class); expect(testInjector.getInstance(IsaacStringMatchValidator.class)).andReturn(stringMatchValidator).anyTimes(); + expect(testInjector.getInstance(IsaacDndValidator.class)).andReturn(dndValidator).anyTimes(); replay(testInjector); SegueGuiceConfigurationModule.setInjector(testInjector); @@ -62,5 +103,10 @@ TestServer subject() throws Exception { ); } + private String url(final String questionId) { + return String.format("/questions/%s/answer", questionId); + } + private static final IsaacStringMatchValidator stringMatchValidator = new IsaacStringMatchValidator(); + private static final IsaacDndValidator dndValidator = new IsaacDndValidator(); } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 41ca733b48..c8b4a3128e 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -15,7 +15,6 @@ */ package uk.ac.cam.cl.dtg.isaac.quiz; -import com.google.common.collect.ImmutableList; import org.junit.Test; import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; import uk.ac.cam.cl.dtg.isaac.dos.QuestionValidationResponse; @@ -23,18 +22,14 @@ import uk.ac.cam.cl.dtg.isaac.dos.content.DndItem; import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; import uk.ac.cam.cl.dtg.isaac.dos.content.Item; -import uk.ac.cam.cl.dtg.isaac.dos.content.ItemChoice; import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.Stream; +import java.util.UUID; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; +@SuppressWarnings("checkstyle:MissingJavadocType") public class IsaacDndValidatorTest { /* @@ -71,30 +66,45 @@ private static QuestionValidationResponse testValidate(final IsaacDndQuestion qu return new IsaacDndValidator().validateQuestionResponse(question, choice); } - private static DndItemChoice answer(final DndItem... list) { + @SuppressWarnings("checkstyle:MissingJavadocType") + public static DndItemChoice answer(final DndItem... list) { var c = new DndItemChoice(); c.setItems(List.of(list)); + c.setType("dndChoice"); return c; } - private static DndItem choose(final Item item, final String str) { - return new DndItem(item.getId(), item.getValue(), str); + @SuppressWarnings("checkstyle:MissingJavadocType") + public static DndItem choose(final Item item, final String dropZoneId) { + var value = new DndItem(item.getId(), item.getValue(), dropZoneId); + value.setType("dndItem"); + return value; } - private static IsaacDndQuestion createQuestion(final DndItemChoice... answers) { + @SuppressWarnings("checkstyle:MissingJavadocType") + public static IsaacDndQuestion createQuestion(final DndItemChoice... answers) { var question = new IsaacDndQuestion(); + question.setId(UUID.randomUUID().toString()); question.setItems(List.of(item_3cm, item_4cm, item_5cm, item_6cm)); question.setChoices(List.of(answers)); + question.setType("isaacDndQuestion"); return question; } - private static DndItemChoice correct(final DndItemChoice choice) { + public static DndItemChoice correct(final DndItemChoice choice) { choice.setCorrect(true); return choice; } - private static final Item item_3cm = new Item("6d3d", "3 cm"); - private static final Item item_4cm = new Item("6d3e", "4 cm"); - private static final Item item_5cm = new Item("6d3f", "5 cm"); - private static final Item item_6cm = new Item("6d3g", "5 cm"); + @SuppressWarnings("checkstyle:MissingJavadocType") + public static Item item(final String id, final String value) { + Item item = new Item(id, value); + item.setType("item"); + return item; + } + + public static final Item item_3cm = item("6d3d", "3 cm"); + public static final Item item_4cm = item("6d3e", "4 cm"); + public static final Item item_5cm = item("6d3f", "5 cm"); + public static final Item item_6cm = item("6d3g", "5 cm"); } From 3dd00a0549bdbe62bbb7dc9851a50971585980e0 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 1 Dec 2025 15:35:36 +0000 Subject: [PATCH 09/65] add integration test for correct dnd answer --- .../cl/dtg/isaac/api/QuestionFacadeIT.java | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java index 8a9418c717..dc7c90871b 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -1,4 +1,5 @@ package uk.ac.cam.cl.dtg.isaac.api; +import com.fasterxml.jackson.core.JsonProcessingException; import com.google.inject.Injector; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -39,7 +40,7 @@ public void shows404ForMissingQuestion() throws Exception { @Nested class StringMatchQuestion { @Test - public void incorrect() throws Exception { + public void wrongAnswer() throws Exception { var response = subject().client().post( url("_regression_test_|acc_stringmatch_q|_regression_test_stringmatch_"), "{\"type\": \"stringChoice\", \"value\": \"13\"}" @@ -50,7 +51,7 @@ public void incorrect() throws Exception { } @Test - public void correct() throws Exception { + public void rightAnswer() throws Exception { var response = subject().client().post( url("_regression_test_|acc_stringmatch_q|_regression_test_stringmatch_"), "{\"type\": \"stringChoice\", \"value\": \"hello\"}" @@ -64,7 +65,7 @@ public void correct() throws Exception { @Nested class DndQuestion { @Test - public void incorrect() throws Exception { + public void wrongAnswer() throws Exception { var dndQuestion = persist(createQuestion( correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) )); @@ -73,9 +74,20 @@ public void incorrect() throws Exception { var response = subject().client().post(url(dndQuestion.getId()), answer).readEntityAsJson(); assertFalse(response.getBoolean("correct")); - DndItemChoice answerFromResponse = contentMapper.getSharedContentObjectMapper() - .readValue(response.getJSONObject("answer").toString(), DndItemChoice.class); - assertEquals(answer, answerFromResponse); + assertEquals(answer, readAnswer(response.getJSONObject("answer").toString())); + } + + @Test + public void rightAnswer() throws Exception { + var dndQuestion = persist(createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + )); + var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); + + var response = subject().client().post(url(dndQuestion.getId()), answer).readEntityAsJson(); + + assertTrue(response.getBoolean("correct")); + assertEquals(answer, readAnswer(response.getJSONObject("answer").toString())); } } @@ -107,6 +119,10 @@ private String url(final String questionId) { return String.format("/questions/%s/answer", questionId); } + private DndItemChoice readAnswer(final String str) throws JsonProcessingException { + return contentMapper.getSharedContentObjectMapper().readValue(str, DndItemChoice.class); + } + private static final IsaacStringMatchValidator stringMatchValidator = new IsaacStringMatchValidator(); private static final IsaacDndValidator dndValidator = new IsaacDndValidator(); } From dabefac462e199cf2951e131fe5f957f759a77d3 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 1 Dec 2025 16:59:51 +0000 Subject: [PATCH 10/65] cloze validator can return explanation --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 7 ++- .../isaac/quiz/IsaacClozeValidatorTest.java | 2 +- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 59 +++++++++++++++---- 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 365d53c00a..dfdd6a807a 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -64,7 +64,12 @@ public final QuestionValidationResponse validateQuestionResponse(final Question DndItemChoice userAnswer = (DndItemChoice) answer; var correctAnswers = dndQuestion.getChoices().stream().filter(Choice::isCorrect); var isCorrect = correctAnswers.anyMatch(correctAnswer -> correctAnswer.matches(userAnswer)); - return new ItemValidationResponse(question.getId(), answer, isCorrect, null, null, new Date()); + var explanation = dndQuestion.getChoices().stream() + .filter(choice -> choice.matches(userAnswer)) + .findFirst() + .map(choice -> (Content) choice.getExplanation()) + .orElse(dndQuestion.getDefaultFeedback()); + return new ItemValidationResponse(question.getId(), answer, isCorrect, null, explanation, new Date()); } @Override diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacClozeValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacClozeValidatorTest.java index cc18f6686b..d506fa6d09 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacClozeValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacClozeValidatorTest.java @@ -146,7 +146,7 @@ public final void isaacClozeValidator_KnownIncorrect_IncorrectResponseShouldBeRe /* Test that known incorrect answers can be matched. -*/ + */ @Test public final void isaacClozeValidator_KnownIncorrectDetailedFeedback_IncorrectResponseShouldBeReturned() { // Set up the question object: diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index c8b4a3128e..35ae4a3608 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -19,6 +19,8 @@ import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; import uk.ac.cam.cl.dtg.isaac.dos.QuestionValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; +import uk.ac.cam.cl.dtg.isaac.dos.content.Content; +import uk.ac.cam.cl.dtg.isaac.dos.content.ContentBase; import uk.ac.cam.cl.dtg.isaac.dos.content.DndItem; import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; import uk.ac.cam.cl.dtg.isaac.dos.content.Item; @@ -26,42 +28,73 @@ import java.util.List; import java.util.UUID; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertFalse; @SuppressWarnings("checkstyle:MissingJavadocType") public class IsaacDndValidatorTest { - /* - Test that correct answers are recognised. - */ + // Test that correct answers are recognised. @Test public final void correctItems_CorrectResponseShouldBeReturned() { var question = createQuestion( correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ); - var choices = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); + var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); - var response = testValidate(question, choices); + var response = testValidate(question, answer); assertTrue(response.isCorrect()); } - /* - Test that incorrect answers are not recognised. - */ + // Test that incorrect answers are not recognised. @Test public final void incorrectItems_IncorrectResponseShouldBeReturned() { var question = createQuestion( correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ); - var choices = answer(choose(item_4cm, "leg_1"), choose(item_5cm, "leg_2"), choose(item_3cm, "hypothenuse")); + var answer = answer(choose(item_4cm, "leg_1"), choose(item_5cm, "leg_2"), choose(item_3cm, "hypothenuse")); - var response = testValidate(question, choices); + var response = testValidate(question, answer); assertFalse(response.isCorrect()); } + // TODO: what if correct? Do we then show default explanation? + + // Test that subset match answers return an appropriate explanation + // TODO: what if? correct: A1, no other criteria. incorrect: A1, B1, no other criteria + @Test + public final void matchingFeedback_shouldReturnMatchingFeedback() { + var hypothenuseMustBeLargest = new Content("The hypothenuse must be the longest side of a right triangle"); + var question = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))), + incorrect(answer(choose(item_3cm, "hypothenuse")), hypothenuseMustBeLargest) + ); + var answer = answer(choose(item_3cm, "hypothenuse")); + + var response = testValidate(question, answer); + + assertFalse(response.isCorrect()); + assertEquals(response.getExplanation(), hypothenuseMustBeLargest); + } + + @Test + public final void noMatchingFeedback_shouldReturnDefaultFeedback() { + var question = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + ); + var defaultFeedback = new Content("Isaac cannot help you."); + question.setDefaultFeedback(defaultFeedback); + var answer = answer(choose(item_4cm, "leg_1"), choose(item_5cm, "leg_2"), choose(item_3cm, "hypothenuse")); + + var response = testValidate(question, answer); + + assertFalse(response.isCorrect()); + assertEquals(response.getExplanation(), defaultFeedback); + } + private static QuestionValidationResponse testValidate(final IsaacDndQuestion question, final Choice choice) { return new IsaacDndValidator().validateQuestionResponse(question, choice); } @@ -96,6 +129,12 @@ public static DndItemChoice correct(final DndItemChoice choice) { return choice; } + public static DndItemChoice incorrect(final DndItemChoice choice, ContentBase explanation) { + choice.setCorrect(false); + choice.setExplanation(explanation); + return choice; + } + @SuppressWarnings("checkstyle:MissingJavadocType") public static Item item(final String id, final String value) { Item item = new Item(id, value); From 6317e33d6192a3d0a6312f3b8ff9098ef0848d69 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 1 Dec 2025 17:43:05 +0000 Subject: [PATCH 11/65] dnd validator determines correctness based on relevance --- .../dtg/isaac/dos/content/DndItemChoice.java | 19 ++++++++------ .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 25 +++++++++++-------- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 24 +++++++++++++++--- 3 files changed, 46 insertions(+), 22 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java index a95435592a..995c77e10a 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java @@ -53,17 +53,20 @@ public void setAllowSubsetMatch(final boolean allowSubsetMatch) { this.allowSubsetMatch = allowSubsetMatch; } - public boolean matches(final DndItemChoice rhs) { - return this.items.stream().allMatch(lhsItem -> - rhs.getItemByDropZone(lhsItem.getDropZoneId()) - .map(rhsItem -> rhsItem.getId().equals(lhsItem.getId())) - .orElse(false) - ); - } - private Optional getItemByDropZone(final String dropZoneId) { return this.items.stream() .filter(item -> item.getDropZoneId().equals(dropZoneId)) .findFirst(); } + + public int matchStrength(final DndItemChoice rhs) { + return this.items.stream() + .map(lhsItem -> + rhs.getItemByDropZone(lhsItem.getDropZoneId()) + .map(rhsItem -> rhsItem.getId().equals(lhsItem.getId()) ? -1 : 0) + .orElse(0) + ) + .mapToInt(Integer::intValue) + .sum(); + } } diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index dfdd6a807a..a463f8c413 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -30,6 +30,7 @@ import uk.ac.cam.cl.dtg.isaac.dos.content.Question; import java.util.Collections; +import java.util.Comparator; import java.util.Date; import java.util.List; import java.util.Objects; @@ -62,18 +63,20 @@ public final QuestionValidationResponse validateQuestionResponse(final Question } IsaacDndQuestion dndQuestion = (IsaacDndQuestion) question; DndItemChoice userAnswer = (DndItemChoice) answer; - var correctAnswers = dndQuestion.getChoices().stream().filter(Choice::isCorrect); - var isCorrect = correctAnswers.anyMatch(correctAnswer -> correctAnswer.matches(userAnswer)); - var explanation = dndQuestion.getChoices().stream() - .filter(choice -> choice.matches(userAnswer)) - .findFirst() - .map(choice -> (Content) choice.getExplanation()) - .orElse(dndQuestion.getDefaultFeedback()); - return new ItemValidationResponse(question.getId(), answer, isCorrect, null, explanation, new Date()); + + var match = dndQuestion.getChoices().stream().sorted( + Comparator.comparingInt(c -> c.matchStrength(userAnswer)) + ).filter(choice -> choice.matchStrength(userAnswer) < 0) + .findFirst() + .orElse(defaultChoice(dndQuestion)); + + return new ItemValidationResponse(question.getId(), answer, match.isCorrect(), null, (Content) match.getExplanation(), new Date()); } - @Override - public List getOrderedChoices(final List choices) { - return IsaacItemQuestionValidator.getOrderedChoicesWithSubsets(choices); + private DndItemChoice defaultChoice(final IsaacDndQuestion question) { + var choice = new DndItemChoice(); + choice.setCorrect(false); + choice.setExplanation(question.getDefaultFeedback()); + return choice; } } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 35ae4a3608..85dab8add8 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -35,9 +35,9 @@ @SuppressWarnings("checkstyle:MissingJavadocType") public class IsaacDndValidatorTest { - // Test that correct answers are recognised. + // Test that correct answers are recognised, g @Test - public final void correctItems_CorrectResponseShouldBeReturned() { + public final void singleCorrectMatch_CorrectResponseShouldBeReturned() { var question = createQuestion( correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ); @@ -50,7 +50,7 @@ public final void correctItems_CorrectResponseShouldBeReturned() { // Test that incorrect answers are not recognised. @Test - public final void incorrectItems_IncorrectResponseShouldBeReturned() { + public final void singleIncorrectMatch_IncorrectResponseShouldBeReturned() { var question = createQuestion( correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ); @@ -61,6 +61,19 @@ public final void incorrectItems_IncorrectResponseShouldBeReturned() { assertFalse(response.isCorrect()); } + @Test + public final void moreSpecificIncorrectMatchOverridesCorrect_IncorrectResponseShouldBeReturned() { + var question = createQuestion( + correct(answer(choose(item_5cm, "hypothenuse"))), + incorrect(answer(choose(item_3cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + ); + var answer = answer(choose(item_3cm, "leg_2"), choose(item_5cm, "hypothenuse")); + + var response = testValidate(question, answer); + + assertFalse(response.isCorrect()); + } + // TODO: what if correct? Do we then show default explanation? // Test that subset match answers return an appropriate explanation @@ -129,6 +142,11 @@ public static DndItemChoice correct(final DndItemChoice choice) { return choice; } + public static DndItemChoice incorrect(final DndItemChoice choice) { + choice.setCorrect(false); + return choice; + } + public static DndItemChoice incorrect(final DndItemChoice choice, ContentBase explanation) { choice.setCorrect(false); choice.setExplanation(explanation); From 537a4e1b14ff2522a5f8368430415290b5cd9a5a Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Tue, 2 Dec 2025 10:00:52 +0000 Subject: [PATCH 12/65] test default feedback not returned for correct --- .../dtg/isaac/dos/content/DndItemChoice.java | 12 +++---- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 36 +++++++++---------- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 29 +++++++++++---- 3 files changed, 45 insertions(+), 32 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java index 995c77e10a..753a42323e 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java @@ -53,12 +53,6 @@ public void setAllowSubsetMatch(final boolean allowSubsetMatch) { this.allowSubsetMatch = allowSubsetMatch; } - private Optional getItemByDropZone(final String dropZoneId) { - return this.items.stream() - .filter(item -> item.getDropZoneId().equals(dropZoneId)) - .findFirst(); - } - public int matchStrength(final DndItemChoice rhs) { return this.items.stream() .map(lhsItem -> @@ -69,4 +63,10 @@ public int matchStrength(final DndItemChoice rhs) { .mapToInt(Integer::intValue) .sum(); } + + private Optional getItemByDropZone(final String dropZoneId) { + return this.items.stream() + .filter(item -> item.getDropZoneId().equals(dropZoneId)) + .findFirst(); + } } diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index a463f8c413..51b7a35179 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -15,37 +15,25 @@ */ package uk.ac.cam.cl.dtg.isaac.quiz; -import com.google.api.client.util.Lists; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import uk.ac.cam.cl.dtg.isaac.dos.IsaacClozeQuestion; import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; import uk.ac.cam.cl.dtg.isaac.dos.ItemValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.QuestionValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; import uk.ac.cam.cl.dtg.isaac.dos.content.Content; import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; -import uk.ac.cam.cl.dtg.isaac.dos.content.Item; -import uk.ac.cam.cl.dtg.isaac.dos.content.ItemChoice; import uk.ac.cam.cl.dtg.isaac.dos.content.Question; -import java.util.Collections; import java.util.Comparator; import java.util.Date; -import java.util.List; import java.util.Objects; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import static uk.ac.cam.cl.dtg.isaac.api.Constants.*; /** * Validator that only provides functionality to validate Cloze questions. */ public class IsaacDndValidator implements IValidator { private static final Logger log = LoggerFactory.getLogger(IsaacClozeValidator.class); - protected static final String NULL_CLOZE_ITEM_ID = "NULL_CLOZE_ITEM"; @Override public final QuestionValidationResponse validateQuestionResponse(final Question question, final Choice answer) { @@ -61,19 +49,27 @@ public final QuestionValidationResponse validateQuestionResponse(final Question throw new IllegalArgumentException(String.format( "This validator only works with IsaacDndQuestions (%s is not IsaacDndQuestion)", question.getId())); } - IsaacDndQuestion dndQuestion = (IsaacDndQuestion) question; - DndItemChoice userAnswer = (DndItemChoice) answer; + return performValidate((IsaacDndQuestion) question, (DndItemChoice) answer); + } - var match = dndQuestion.getChoices().stream().sorted( - Comparator.comparingInt(c -> c.matchStrength(userAnswer)) - ).filter(choice -> choice.matchStrength(userAnswer) < 0) + private QuestionValidationResponse performValidate(final IsaacDndQuestion question, final DndItemChoice answer) { + DndItemChoice match = question.getChoices().stream() + .sorted(Comparator.comparingInt(c -> c.matchStrength(answer))) + .filter(choice -> choice.matchStrength(answer) < 0) .findFirst() - .orElse(defaultChoice(dndQuestion)); + .orElse(incorrectAnswer(question)); - return new ItemValidationResponse(question.getId(), answer, match.isCorrect(), null, (Content) match.getExplanation(), new Date()); + return new ItemValidationResponse( + question.getId(), + answer, + match.isCorrect(), + null, + (Content) match.getExplanation(), + new Date() + ); } - private DndItemChoice defaultChoice(final IsaacDndQuestion question) { + private DndItemChoice incorrectAnswer(final IsaacDndQuestion question) { var choice = new DndItemChoice(); choice.setCorrect(false); choice.setExplanation(question.getDefaultFeedback()); diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 85dab8add8..66d4060bce 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -35,7 +35,7 @@ @SuppressWarnings("checkstyle:MissingJavadocType") public class IsaacDndValidatorTest { - // Test that correct answers are recognised, g + // Test that correct answers are recognised @Test public final void singleCorrectMatch_CorrectResponseShouldBeReturned() { var question = createQuestion( @@ -74,10 +74,12 @@ public final void moreSpecificIncorrectMatchOverridesCorrect_IncorrectResponseSh assertFalse(response.isCorrect()); } - // TODO: what if correct? Do we then show default explanation? - // Test that subset match answers return an appropriate explanation - // TODO: what if? correct: A1, no other criteria. incorrect: A1, B1, no other criteria + // TODO: correct-incorrect contradiction among levels should be invalid question (during ETL?) + // TODO: multiple matching explanations + // - on same level? (or even across levels?) + // - should return all? + // - should return just one, but predictably? @Test public final void matchingFeedback_shouldReturnMatchingFeedback() { var hypothenuseMustBeLargest = new Content("The hypothenuse must be the longest side of a right triangle"); @@ -94,9 +96,9 @@ public final void matchingFeedback_shouldReturnMatchingFeedback() { } @Test - public final void noMatchingFeedback_shouldReturnDefaultFeedback() { + public final void noMatchingFeedbackIncorrect_shouldReturnDefaultFeedback() { var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ); var defaultFeedback = new Content("Isaac cannot help you."); question.setDefaultFeedback(defaultFeedback); @@ -108,6 +110,21 @@ public final void noMatchingFeedback_shouldReturnDefaultFeedback() { assertEquals(response.getExplanation(), defaultFeedback); } + @Test + public final void noMatchingFeedbackCorrect_shouldReturnNoFeedback() { + var question = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + ); + var defaultFeedback = new Content("Isaac cannot help you."); + question.setDefaultFeedback(defaultFeedback); + var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); + + var response = testValidate(question, answer); + + assertTrue(response.isCorrect()); + assertEquals(response.getExplanation(), null); + } + private static QuestionValidationResponse testValidate(final IsaacDndQuestion question, final Choice choice) { return new IsaacDndValidator().validateQuestionResponse(question, choice); } From 530c1f37abb2edd93cbfe7f5c7adb543f584039e Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Tue, 2 Dec 2025 12:15:32 +0000 Subject: [PATCH 13/65] return correct dropzones, for correct only --- .../dtg/isaac/dos/DndValidationResponse.java | 65 +++++++++++++ .../dtg/isaac/dos/content/DndItemChoice.java | 16 +++- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 16 ++-- .../isaac/quiz/IsaacClozeValidatorTest.java | 1 + .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 95 +++++++++++++++++-- 5 files changed, 177 insertions(+), 16 deletions(-) create mode 100644 src/main/java/uk/ac/cam/cl/dtg/isaac/dos/DndValidationResponse.java diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/DndValidationResponse.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/DndValidationResponse.java new file mode 100644 index 0000000000..8e7a2ad96c --- /dev/null +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/DndValidationResponse.java @@ -0,0 +1,65 @@ +/* + * Copyright 2022 James Sharkey + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package uk.ac.cam.cl.dtg.isaac.dos; + +import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; +import uk.ac.cam.cl.dtg.isaac.dos.content.Content; + +import java.util.Date; +import java.util.Map; + + +/** + * Class for providing correctness feedback about individual items in a submitted Choice. + * + * This is unlikely to be useful for {@link IsaacItemQuestion}'s, however, since to provide + * detailed correctness feedback on them would enable questions to be answered trivially. + */ +//@DTOMapping(DndValidationResponseDTO.class) +public class DndValidationResponse extends QuestionValidationResponse { + private Map dropZonesCorrect; + + /** + * Default constructor for Jackson. + */ + public DndValidationResponse() { + } + + /** + * Full constructor. + * + * @param questionId - questionId. + * @param answer - answer. + * @param correct - correct. + * @param dropZonesCorrect - map of correctness status of each submitted item. Key: dropZoneId, value: isCorrect + * @param explanation - explanation. + * @param dateAttempted - dateAttempted. + */ + public DndValidationResponse(final String questionId, final Choice answer, + final Boolean correct, final Map dropZonesCorrect, + final Content explanation, final Date dateAttempted) { + super(questionId, answer, correct, explanation, dateAttempted); + this.dropZonesCorrect = dropZonesCorrect; + } + + public Map getDropZonesCorrect() { + return dropZonesCorrect; + } + + public void setDropZonesCorrect(final Map itemsCorrect) { + this.dropZonesCorrect = itemsCorrect; + } +} diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java index 753a42323e..3aa6274fea 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java @@ -18,7 +18,9 @@ import uk.ac.cam.cl.dtg.isaac.dto.content.DndItemChoiceDTO; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; /** * Choice for Item Questions, containing a list of Items. @@ -53,7 +55,15 @@ public void setAllowSubsetMatch(final boolean allowSubsetMatch) { this.allowSubsetMatch = allowSubsetMatch; } - public int matchStrength(final DndItemChoice rhs) { + public boolean matches(final DndItemChoice rhs) { + return this.items.stream().allMatch(lhsItem -> + rhs.getItemByDropZone(lhsItem.getDropZoneId()) + .map(rhsItem -> rhsItem.getId().equals(lhsItem.getId())) + .orElse(false) + ); + } + + public int countPartialMatchesIn(final DndItemChoice rhs) { return this.items.stream() .map(lhsItem -> rhs.getItemByDropZone(lhsItem.getDropZoneId()) @@ -64,6 +74,10 @@ public int matchStrength(final DndItemChoice rhs) { .sum(); } + public Map getDropZonesCorrect() { + return this.items.stream().collect(Collectors.toMap(DndItem::getDropZoneId, d -> true)); + } + private Optional getItemByDropZone(final String dropZoneId) { return this.items.stream() .filter(item -> item.getDropZoneId().equals(dropZoneId)) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 51b7a35179..553e223ce1 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -15,11 +15,11 @@ */ package uk.ac.cam.cl.dtg.isaac.quiz; +import org.apache.commons.lang3.BooleanUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import uk.ac.cam.cl.dtg.isaac.dos.DndValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; -import uk.ac.cam.cl.dtg.isaac.dos.ItemValidationResponse; -import uk.ac.cam.cl.dtg.isaac.dos.QuestionValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; import uk.ac.cam.cl.dtg.isaac.dos.content.Content; import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; @@ -36,7 +36,7 @@ public class IsaacDndValidator implements IValidator { private static final Logger log = LoggerFactory.getLogger(IsaacClozeValidator.class); @Override - public final QuestionValidationResponse validateQuestionResponse(final Question question, final Choice answer) { + public final DndValidationResponse validateQuestionResponse(final Question question, final Choice answer) { Objects.requireNonNull(question); Objects.requireNonNull(answer); @@ -52,18 +52,18 @@ public final QuestionValidationResponse validateQuestionResponse(final Question return performValidate((IsaacDndQuestion) question, (DndItemChoice) answer); } - private QuestionValidationResponse performValidate(final IsaacDndQuestion question, final DndItemChoice answer) { + private DndValidationResponse performValidate(final IsaacDndQuestion question, final DndItemChoice answer) { DndItemChoice match = question.getChoices().stream() - .sorted(Comparator.comparingInt(c -> c.matchStrength(answer))) - .filter(choice -> choice.matchStrength(answer) < 0) + .sorted(Comparator.comparingInt(c -> c.countPartialMatchesIn(answer))) + .filter(choice -> choice.matches(answer)) .findFirst() .orElse(incorrectAnswer(question)); - return new ItemValidationResponse( + return new DndValidationResponse( question.getId(), answer, match.isCorrect(), - null, + BooleanUtils.isTrue(question.getDetailedItemFeedback()) && match.isCorrect() ? match.getDropZonesCorrect() : null, (Content) match.getExplanation(), new Date() ); diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacClozeValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacClozeValidatorTest.java index d506fa6d09..589bd05058 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacClozeValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacClozeValidatorTest.java @@ -77,6 +77,7 @@ public final void setUp() { someIncorrectChoice.setExplanation(new Content(incorrectExplanation)); someSubsetChoice.setItems(ImmutableList.of(NULL_PLACEHOLDER, item3)); someSubsetChoice.setAllowSubsetMatch(true); + someSubsetChoice.setCorrect(false); someSubsetChoice.setExplanation(new Content(subsetMatchExplanation)); // Add both choices to question, incorrect first: diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 66d4060bce..e569cac365 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -16,8 +16,8 @@ package uk.ac.cam.cl.dtg.isaac.quiz; import org.junit.Test; +import uk.ac.cam.cl.dtg.isaac.dos.DndValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; -import uk.ac.cam.cl.dtg.isaac.dos.QuestionValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; import uk.ac.cam.cl.dtg.isaac.dos.content.Content; import uk.ac.cam.cl.dtg.isaac.dos.content.ContentBase; @@ -25,17 +25,18 @@ import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; import uk.ac.cam.cl.dtg.isaac.dos.content.Item; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; @SuppressWarnings("checkstyle:MissingJavadocType") public class IsaacDndValidatorTest { - - // Test that correct answers are recognised @Test public final void singleCorrectMatch_CorrectResponseShouldBeReturned() { var question = createQuestion( @@ -48,7 +49,6 @@ public final void singleCorrectMatch_CorrectResponseShouldBeReturned() { assertTrue(response.isCorrect()); } - // Test that incorrect answers are not recognised. @Test public final void singleIncorrectMatch_IncorrectResponseShouldBeReturned() { var question = createQuestion( @@ -61,6 +61,18 @@ public final void singleIncorrectMatch_IncorrectResponseShouldBeReturned() { assertFalse(response.isCorrect()); } + @Test + public final void partialMatchForCorrect_IncorrectResponseShouldBeReturned() { + var question = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + ); + var answer = answer(choose(item_4cm, "leg_2"), choose(item_5cm, "leg_1"), choose(item_3cm, "hypothenuse")); + + var response = testValidate(question, answer); + + assertFalse(response.isCorrect()); + } + @Test public final void moreSpecificIncorrectMatchOverridesCorrect_IncorrectResponseShouldBeReturned() { var question = createQuestion( @@ -76,10 +88,13 @@ public final void moreSpecificIncorrectMatchOverridesCorrect_IncorrectResponseSh // Test that subset match answers return an appropriate explanation // TODO: correct-incorrect contradiction among levels should be invalid question (during ETL?) + // - James says we should just accept as correct when contradiction // TODO: multiple matching explanations // - on same level? (or even across levels?) // - should return all? // - should return just one, but predictably? + // TODO: test for empty answer + // @Test public final void matchingFeedback_shouldReturnMatchingFeedback() { var hypothenuseMustBeLargest = new Content("The hypothenuse must be the longest side of a right triangle"); @@ -122,10 +137,52 @@ public final void noMatchingFeedbackCorrect_shouldReturnNoFeedback() { var response = testValidate(question, answer); assertTrue(response.isCorrect()); - assertEquals(response.getExplanation(), null); + assertNull(response.getExplanation()); + } + + @Test + public final void dropZonesCorrect_incorrectNotRequested_shouldReturnNull() { + var question = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + ); + question.setDetailedItemFeedback(false); + var answer = answer(choose(item_3cm, "leg_2"), choose(item_4cm, "leg_1"), choose(item_5cm, "hypothenuse")); + + var response = testValidate(question, answer); + assertFalse(response.isCorrect()); + assertNull(response.getDropZonesCorrect()); + } + + @Test + public final void dropZonesCorrect_correctNotRequested_shouldReturnNull() { + var question = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + ); + question.setDetailedItemFeedback(false); + var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); + + var response = testValidate(question, answer); + assertTrue(response.isCorrect()); + assertNull(response.getDropZonesCorrect()); + } + + @Test + public final void dropZonesCorrect_allCorrect() { + var question = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + ); + question.setDetailedItemFeedback(true); + var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); + + var response = testValidate(question, answer); + assertTrue(response.isCorrect()); + assertEquals( + new DropZonesCorrectFactory().setLeg1(true).setLeg2(true).setHypothenuse(true).getMap(), + response.getDropZonesCorrect() + ); } - private static QuestionValidationResponse testValidate(final IsaacDndQuestion question, final Choice choice) { + private static DndValidationResponse testValidate(final IsaacDndQuestion question, final Choice choice) { return new IsaacDndValidator().validateQuestionResponse(question, choice); } @@ -177,8 +234,32 @@ public static Item item(final String id, final String value) { return item; } + private static class DropZonesCorrectFactory { + private final Map map = new HashMap<>(); + + public DropZonesCorrectFactory setLeg1(final boolean value) { + map.put("leg_1", value); + return this; + } + + public DropZonesCorrectFactory setLeg2(final boolean value) { + map.put("leg_2", value); + return this; + } + + public DropZonesCorrectFactory setHypothenuse(final boolean value) { + map.put("hypothenuse", value); + return this; + } + + public Map getMap() { + return map; + } + } + public static final Item item_3cm = item("6d3d", "3 cm"); public static final Item item_4cm = item("6d3e", "4 cm"); public static final Item item_5cm = item("6d3f", "5 cm"); public static final Item item_6cm = item("6d3g", "5 cm"); } + From 99185f3029d6c2d23611f489eedf1c4c60b614ec Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Tue, 2 Dec 2025 13:26:48 +0000 Subject: [PATCH 14/65] test validated dnd answer can have explanation --- .../cl/dtg/isaac/api/QuestionFacadeIT.java | 26 ++++++++++--- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 38 +++++++++++++++---- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java index dc7c90871b..1f1af1b4f0 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -1,9 +1,10 @@ package uk.ac.cam.cl.dtg.isaac.api; -import com.fasterxml.jackson.core.JsonProcessingException; import com.google.inject.Injector; +import org.json.JSONObject; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; +import uk.ac.cam.cl.dtg.isaac.dos.content.Content; import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; import uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidator; import uk.ac.cam.cl.dtg.isaac.quiz.IsaacStringMatchValidator; @@ -74,7 +75,7 @@ public void wrongAnswer() throws Exception { var response = subject().client().post(url(dndQuestion.getId()), answer).readEntityAsJson(); assertFalse(response.getBoolean("correct")); - assertEquals(answer, readAnswer(response.getJSONObject("answer").toString())); + assertEquals(answer, readEntity(response.getJSONObject("answer"), DndItemChoice.class)); } @Test @@ -87,7 +88,22 @@ public void rightAnswer() throws Exception { var response = subject().client().post(url(dndQuestion.getId()), answer).readEntityAsJson(); assertTrue(response.getBoolean("correct")); - assertEquals(answer, readAnswer(response.getJSONObject("answer").toString())); + assertEquals(answer, readEntity(response.getJSONObject("answer"), DndItemChoice.class)); + } + + @Test + public void explanation() throws Exception { + var explanation = new Content("That's right!"); + var dndQuestion = persist(createQuestion(correct( + answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")), + explanation + ))); + var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); + + var response = subject().client().post(url(dndQuestion.getId()), answer).readEntityAsJson(); + + assertTrue(response.getBoolean("correct")); + assertEquals(explanation, readEntity(response.getJSONObject("explanation"), Content.class)); } } @@ -119,8 +135,8 @@ private String url(final String questionId) { return String.format("/questions/%s/answer", questionId); } - private DndItemChoice readAnswer(final String str) throws JsonProcessingException { - return contentMapper.getSharedContentObjectMapper().readValue(str, DndItemChoice.class); + private T readEntity(final JSONObject value, final Class klass) throws Exception { + return contentMapper.getSharedContentObjectMapper().readValue(value.toString(), klass); } private static final IsaacStringMatchValidator stringMatchValidator = new IsaacStringMatchValidator(); diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index e569cac365..f1126d53fa 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -38,7 +38,7 @@ @SuppressWarnings("checkstyle:MissingJavadocType") public class IsaacDndValidatorTest { @Test - public final void singleCorrectMatch_CorrectResponseShouldBeReturned() { + public final void correctness_singleCorrectMatch_CorrectResponseShouldBeReturned() { var question = createQuestion( correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ); @@ -50,7 +50,7 @@ public final void singleCorrectMatch_CorrectResponseShouldBeReturned() { } @Test - public final void singleIncorrectMatch_IncorrectResponseShouldBeReturned() { + public final void correctness_singleIncorrectMatch_IncorrectResponseShouldBeReturned() { var question = createQuestion( correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ); @@ -62,7 +62,7 @@ public final void singleIncorrectMatch_IncorrectResponseShouldBeReturned() { } @Test - public final void partialMatchForCorrect_IncorrectResponseShouldBeReturned() { + public final void correctness_partialMatchForCorrect_IncorrectResponseShouldBeReturned() { var question = createQuestion( correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ); @@ -74,7 +74,7 @@ public final void partialMatchForCorrect_IncorrectResponseShouldBeReturned() { } @Test - public final void moreSpecificIncorrectMatchOverridesCorrect_IncorrectResponseShouldBeReturned() { + public final void correctness_moreSpecificIncorrectMatchOverridesCorrect_IncorrectResponseShouldBeReturned() { var question = createQuestion( correct(answer(choose(item_5cm, "hypothenuse"))), incorrect(answer(choose(item_3cm, "leg_2"), choose(item_5cm, "hypothenuse"))) @@ -96,7 +96,7 @@ public final void moreSpecificIncorrectMatchOverridesCorrect_IncorrectResponseSh // TODO: test for empty answer // @Test - public final void matchingFeedback_shouldReturnMatchingFeedback() { + public final void explanation_exactMatchIncorrect_shouldReturnMatching() { var hypothenuseMustBeLargest = new Content("The hypothenuse must be the longest side of a right triangle"); var question = createQuestion( correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))), @@ -111,7 +111,23 @@ public final void matchingFeedback_shouldReturnMatchingFeedback() { } @Test - public final void noMatchingFeedbackIncorrect_shouldReturnDefaultFeedback() { + public final void explanation_exactMatchCorrect_shouldReturnMatching() { + var correctFeedback = new Content("That's how it's done! Observe that the hypothenuse is always the longest" + + " side of a right triangle"); + var question = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")), + correctFeedback) + ); + var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); + + var response = testValidate(question, answer); + + assertTrue(response.isCorrect()); + assertEquals(response.getExplanation(), correctFeedback); + } + + @Test + public final void explanation_defaultIncorrect_shouldReturnDefault() { var question = createQuestion( correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ); @@ -126,7 +142,7 @@ public final void noMatchingFeedbackIncorrect_shouldReturnDefaultFeedback() { } @Test - public final void noMatchingFeedbackCorrect_shouldReturnNoFeedback() { + public final void explanation_defaultCorrect_shouldReturnNone() { var question = createQuestion( correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ); @@ -167,7 +183,7 @@ public final void dropZonesCorrect_correctNotRequested_shouldReturnNull() { } @Test - public final void dropZonesCorrect_allCorrect() { + public final void dropZonesCorrect_allCorrect_shouldReturnAllCorrect() { var question = createQuestion( correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ); @@ -216,6 +232,12 @@ public static DndItemChoice correct(final DndItemChoice choice) { return choice; } + public static DndItemChoice correct(final DndItemChoice choice, ContentBase explanation) { + choice.setCorrect(true); + choice.setExplanation(explanation); + return choice; + } + public static DndItemChoice incorrect(final DndItemChoice choice) { choice.setCorrect(false); return choice; From ef85342ebf8186a3ccad6d591540249b695f2265 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Tue, 2 Dec 2025 14:08:38 +0000 Subject: [PATCH 15/65] dnd validation endpoint returns `dropZonesCorrect` --- .../dtg/isaac/dos/DndValidationResponse.java | 6 +- .../isaac/dto/DndValidationResponseDTO.java | 60 +++++++++++++++++++ .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 14 ++--- .../mappers/QuestionValidationMapper.java | 3 + .../cl/dtg/isaac/api/QuestionFacadeIT.java | 19 ++++++ .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 2 +- 6 files changed, 91 insertions(+), 13 deletions(-) create mode 100644 src/main/java/uk/ac/cam/cl/dtg/isaac/dto/DndValidationResponseDTO.java diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/DndValidationResponse.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/DndValidationResponse.java index 8e7a2ad96c..f0732bc8e1 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/DndValidationResponse.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/DndValidationResponse.java @@ -23,12 +23,8 @@ /** - * Class for providing correctness feedback about individual items in a submitted Choice. - * - * This is unlikely to be useful for {@link IsaacItemQuestion}'s, however, since to provide - * detailed correctness feedback on them would enable questions to be answered trivially. + * Class for providing correctness feedback about drag and drop questions in a submitted Choice. */ -//@DTOMapping(DndValidationResponseDTO.class) public class DndValidationResponse extends QuestionValidationResponse { private Map dropZonesCorrect; diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/DndValidationResponseDTO.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/DndValidationResponseDTO.java new file mode 100644 index 0000000000..35b9a159d9 --- /dev/null +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/DndValidationResponseDTO.java @@ -0,0 +1,60 @@ +/* + * Copyright 2022 James Sharkey + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package uk.ac.cam.cl.dtg.isaac.dto; + +import uk.ac.cam.cl.dtg.isaac.dto.content.ChoiceDTO; +import uk.ac.cam.cl.dtg.isaac.dto.content.ContentDTO; + +import java.util.Date; +import java.util.Map; + +/** + * Class for providing correctness feedback about drag and drop questions in a submitted Choice. + */ +public class DndValidationResponseDTO extends QuestionValidationResponseDTO { + private Map dropZonesCorrect; + + /** + * Default constructor for Jackson. + */ + public DndValidationResponseDTO() { + } + + /** + * Full constructor. + * + * @param questionId - questionId. + * @param answer - answer. + * @param correct - correct. + * @param dropZonesCorrect - map of correctness status of each submitted item. Key: dropZoneId, value: isCorrect + * @param explanation - explanation. + * @param dateAttempted - dateAttempted. + */ + public DndValidationResponseDTO(final String questionId, final ChoiceDTO answer, + final Boolean correct, final Map dropZonesCorrect, + final ContentDTO explanation, final Date dateAttempted) { + super(questionId, answer, correct, explanation, dateAttempted); + this.dropZonesCorrect = dropZonesCorrect; + } + + public Map getDropZonesCorrect() { + return dropZonesCorrect; + } + + public void setDropZonesCorrect(final Map dropZonesCorrect) { + this.dropZonesCorrect = dropZonesCorrect; + } +} diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 553e223ce1..4b0e13fc56 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -16,8 +16,6 @@ package uk.ac.cam.cl.dtg.isaac.quiz; import org.apache.commons.lang3.BooleanUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import uk.ac.cam.cl.dtg.isaac.dos.DndValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; @@ -30,13 +28,16 @@ import java.util.Objects; /** - * Validator that only provides functionality to validate Cloze questions. + * Validator that only provides functionality to validate Drag and drop questions. */ public class IsaacDndValidator implements IValidator { - private static final Logger log = LoggerFactory.getLogger(IsaacClozeValidator.class); - @Override public final DndValidationResponse validateQuestionResponse(final Question question, final Choice answer) { + validate(question, answer); + return mark((IsaacDndQuestion) question, (DndItemChoice) answer); + } + + private void validate(final Question question, final Choice answer) { Objects.requireNonNull(question); Objects.requireNonNull(answer); @@ -49,10 +50,9 @@ public final DndValidationResponse validateQuestionResponse(final Question quest throw new IllegalArgumentException(String.format( "This validator only works with IsaacDndQuestions (%s is not IsaacDndQuestion)", question.getId())); } - return performValidate((IsaacDndQuestion) question, (DndItemChoice) answer); } - private DndValidationResponse performValidate(final IsaacDndQuestion question, final DndItemChoice answer) { + private DndValidationResponse mark(final IsaacDndQuestion question, final DndItemChoice answer) { DndItemChoice match = question.getChoices().stream() .sorted(Comparator.comparingInt(c -> c.countPartialMatchesIn(answer))) .filter(choice -> choice.matches(answer)) diff --git a/src/main/java/uk/ac/cam/cl/dtg/util/mappers/QuestionValidationMapper.java b/src/main/java/uk/ac/cam/cl/dtg/util/mappers/QuestionValidationMapper.java index 9e326835ff..5fb9e13269 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/util/mappers/QuestionValidationMapper.java +++ b/src/main/java/uk/ac/cam/cl/dtg/util/mappers/QuestionValidationMapper.java @@ -4,11 +4,13 @@ import org.mapstruct.Mapper; import org.mapstruct.SubclassMapping; import org.mapstruct.factory.Mappers; +import uk.ac.cam.cl.dtg.isaac.dos.DndValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.FormulaValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.ItemValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.LLMFreeTextQuestionValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.QuantityValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.QuestionValidationResponse; +import uk.ac.cam.cl.dtg.isaac.dto.DndValidationResponseDTO; import uk.ac.cam.cl.dtg.isaac.dto.FormulaValidationResponseDTO; import uk.ac.cam.cl.dtg.isaac.dto.ItemValidationResponseDTO; import uk.ac.cam.cl.dtg.isaac.dto.LLMFreeTextQuestionValidationResponseDTO; @@ -24,6 +26,7 @@ public interface QuestionValidationMapper { QuestionValidationMapper INSTANCE = Mappers.getMapper(QuestionValidationMapper.class); @SubclassMapping(source = FormulaValidationResponse.class, target = FormulaValidationResponseDTO.class) + @SubclassMapping(source = DndValidationResponse.class, target = DndValidationResponseDTO.class) @SubclassMapping(source = ItemValidationResponse.class, target = ItemValidationResponseDTO.class) @SubclassMapping(source = LLMFreeTextQuestionValidationResponse.class, target = LLMFreeTextQuestionValidationResponseDTO.class) @SubclassMapping(source = QuantityValidationResponse.class, target = QuantityValidationResponseDTO.class) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java index 1f1af1b4f0..c9a6129ec2 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -14,6 +14,7 @@ import jakarta.ws.rs.core.Response; import java.util.List; +import java.util.Map; import static org.easymock.EasyMock.createNiceMock; import static org.easymock.EasyMock.expect; @@ -105,6 +106,24 @@ public void explanation() throws Exception { assertTrue(response.getBoolean("correct")); assertEquals(explanation, readEntity(response.getJSONObject("explanation"), Content.class)); } + + @Test + public void dropZonesCorrect() throws Exception { + var dndQuestion = createQuestion(correct( + answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) + )); + dndQuestion.setDetailedItemFeedback(true); + persist(dndQuestion); + var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); + + var response = subject().client().post(url(dndQuestion.getId()), answer).readEntityAsJson(); + + assertTrue(response.getBoolean("correct")); + assertEquals( + new DropZonesCorrectFactory().setLeg1(true).setLeg2(true).setHypothenuse(true).getMap(), + readEntity(response.getJSONObject("dropZonesCorrect"), Map.class) + ); + } } private IsaacDndQuestion persist(final IsaacDndQuestion question) throws Exception { diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index f1126d53fa..42afc90ee5 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -256,7 +256,7 @@ public static Item item(final String id, final String value) { return item; } - private static class DropZonesCorrectFactory { + public static class DropZonesCorrectFactory { private final Map map = new HashMap<>(); public DropZonesCorrectFactory setLeg1(final boolean value) { From bee2dba2613ed782db33cede8cdc419b4e077fd7 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Tue, 2 Dec 2025 14:47:18 +0000 Subject: [PATCH 16/65] indicate correct dropzones for incorrect answer --- .../dtg/isaac/dos/content/DndItemChoice.java | 29 ++++++++++--------- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 13 ++++++--- .../cl/dtg/isaac/api/QuestionFacadeIT.java | 2 +- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 24 ++++++++++++--- 4 files changed, 45 insertions(+), 23 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java index 3aa6274fea..c49b4aae1f 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java @@ -56,31 +56,32 @@ public void setAllowSubsetMatch(final boolean allowSubsetMatch) { } public boolean matches(final DndItemChoice rhs) { - return this.items.stream().allMatch(lhsItem -> - rhs.getItemByDropZone(lhsItem.getDropZoneId()) - .map(rhsItem -> rhsItem.getId().equals(lhsItem.getId())) - .orElse(false) - ); + return this.items.stream().allMatch(lhsItem -> dropZoneEql(lhsItem, rhs)); } public int countPartialMatchesIn(final DndItemChoice rhs) { return this.items.stream() - .map(lhsItem -> - rhs.getItemByDropZone(lhsItem.getDropZoneId()) - .map(rhsItem -> rhsItem.getId().equals(lhsItem.getId()) ? -1 : 0) - .orElse(0) - ) + .map(lhsItem -> dropZoneEql(lhsItem, rhs) ? -1 : 0) .mapToInt(Integer::intValue) .sum(); } - public Map getDropZonesCorrect() { - return this.items.stream().collect(Collectors.toMap(DndItem::getDropZoneId, d -> true)); + public Map getDropZonesCorrect(final DndItemChoice rhs) { + return this.items.stream().collect(Collectors.toMap( + DndItem::getDropZoneId, + lhsItem -> dropZoneEql(lhsItem, rhs)) + ); + } + + private static boolean dropZoneEql(DndItem lhsItem, DndItemChoice rhs) { + return rhs.getItemByDropZone(lhsItem.getDropZoneId()) + .map(rhsItem -> rhsItem.getId().equals(lhsItem.getId())) + .orElse(false); } private Optional getItemByDropZone(final String dropZoneId) { return this.items.stream() - .filter(item -> item.getDropZoneId().equals(dropZoneId)) - .findFirst(); + .filter(item -> item.getDropZoneId().equals(dropZoneId)) + .findFirst(); } } diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 4b0e13fc56..406b13260f 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -53,18 +53,23 @@ private void validate(final Question question, final Choice answer) { } private DndValidationResponse mark(final IsaacDndQuestion question, final DndItemChoice answer) { - DndItemChoice match = question.getChoices().stream() + DndItemChoice matchedAnswer = question.getChoices().stream() .sorted(Comparator.comparingInt(c -> c.countPartialMatchesIn(answer))) .filter(choice -> choice.matches(answer)) .findFirst() .orElse(incorrectAnswer(question)); + DndItemChoice closestCorrectAnswer = question.getChoices().stream() + .filter(Choice::isCorrect) + .findFirst() + .orElse(null); + return new DndValidationResponse( question.getId(), answer, - match.isCorrect(), - BooleanUtils.isTrue(question.getDetailedItemFeedback()) && match.isCorrect() ? match.getDropZonesCorrect() : null, - (Content) match.getExplanation(), + matchedAnswer.isCorrect(), + BooleanUtils.isTrue(question.getDetailedItemFeedback()) ? closestCorrectAnswer.getDropZonesCorrect(answer) : null, + (Content) matchedAnswer.getExplanation(), new Date() ); } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java index c9a6129ec2..d0f614e9e0 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -120,7 +120,7 @@ public void dropZonesCorrect() throws Exception { assertTrue(response.getBoolean("correct")); assertEquals( - new DropZonesCorrectFactory().setLeg1(true).setLeg2(true).setHypothenuse(true).getMap(), + new DropZonesCorrectFactory().setLeg1(true).setLeg2(true).setHypothenuse(true).build(), readEntity(response.getJSONObject("dropZonesCorrect"), Map.class) ); } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 42afc90ee5..491d1e4916 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -172,7 +172,7 @@ public final void dropZonesCorrect_incorrectNotRequested_shouldReturnNull() { @Test public final void dropZonesCorrect_correctNotRequested_shouldReturnNull() { var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ); question.setDetailedItemFeedback(false); var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); @@ -185,7 +185,7 @@ public final void dropZonesCorrect_correctNotRequested_shouldReturnNull() { @Test public final void dropZonesCorrect_allCorrect_shouldReturnAllCorrect() { var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ); question.setDetailedItemFeedback(true); var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); @@ -193,7 +193,23 @@ public final void dropZonesCorrect_allCorrect_shouldReturnAllCorrect() { var response = testValidate(question, answer); assertTrue(response.isCorrect()); assertEquals( - new DropZonesCorrectFactory().setLeg1(true).setLeg2(true).setHypothenuse(true).getMap(), + new DropZonesCorrectFactory().setLeg1(true).setLeg2(true).setHypothenuse(true).build(), + response.getDropZonesCorrect() + ); + } + + @Test + public final void dropZonesCorrect_someIncorrect_shouldReturnWhetherCorrect() { + var question = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + ); + question.setDetailedItemFeedback(true); + var answer = answer(choose(item_6cm, "leg_1"), choose(item_5cm, "hypothenuse")); + + var response = testValidate(question, answer); + assertFalse(response.isCorrect()); + assertEquals( + new DropZonesCorrectFactory().setLeg1(false).setLeg2(false).setHypothenuse(true).build(), response.getDropZonesCorrect() ); } @@ -274,7 +290,7 @@ public DropZonesCorrectFactory setHypothenuse(final boolean value) { return this; } - public Map getMap() { + public Map build() { return map; } } From f9d4f4842665d5b960b3077f823542aad436c231 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Tue, 2 Dec 2025 15:31:45 +0000 Subject: [PATCH 17/65] dropZonesCorrect for multiple correct answers --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 17 ++++++++++----- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 21 ++++++++++++++++++- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 406b13260f..8366e58440 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -25,7 +25,10 @@ import java.util.Comparator; import java.util.Date; +import java.util.List; import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; /** * Validator that only provides functionality to validate Drag and drop questions. @@ -53,22 +56,26 @@ private void validate(final Question question, final Choice answer) { } private DndValidationResponse mark(final IsaacDndQuestion question, final DndItemChoice answer) { - DndItemChoice matchedAnswer = question.getChoices().stream() + List sortedItems = question.getChoices().stream() .sorted(Comparator.comparingInt(c -> c.countPartialMatchesIn(answer))) + .collect(Collectors.toList()); + + DndItemChoice matchedAnswer = sortedItems.stream() .filter(choice -> choice.matches(answer)) .findFirst() .orElse(incorrectAnswer(question)); - DndItemChoice closestCorrectAnswer = question.getChoices().stream() + Optional closestCorrectAnswer = sortedItems.stream() .filter(Choice::isCorrect) - .findFirst() - .orElse(null); + .findFirst(); return new DndValidationResponse( question.getId(), answer, matchedAnswer.isCorrect(), - BooleanUtils.isTrue(question.getDetailedItemFeedback()) ? closestCorrectAnswer.getDropZonesCorrect(answer) : null, + BooleanUtils.isTrue(question.getDetailedItemFeedback()) + ? closestCorrectAnswer.map(c -> c.getDropZonesCorrect(answer)).orElse(null) + : null, (Content) matchedAnswer.getExplanation(), new Date() ); diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 491d1e4916..ed34527a85 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -214,6 +214,23 @@ public final void dropZonesCorrect_someIncorrect_shouldReturnWhetherCorrect() { ); } + @Test + public final void dropZonesCorrect_multipleCorrectAnswers_decidesCorrectnessBasedOnClosestOne() { + var question = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))), + correct(answer(choose(item_5cm, "leg_1"), choose(item_12cm, "leg_2"), choose(item_13cm, "hypothenuse"))) + ); + question.setDetailedItemFeedback(true); + var answer = answer(choose(item_5cm, "leg_1")); + + var response = testValidate(question, answer); + assertFalse(response.isCorrect()); + assertEquals( + new DropZonesCorrectFactory().setLeg1(true).setLeg2(false).setHypothenuse(false).build(), + response.getDropZonesCorrect() + ); + } + private static DndValidationResponse testValidate(final IsaacDndQuestion question, final Choice choice) { return new IsaacDndValidator().validateQuestionResponse(question, choice); } @@ -237,7 +254,7 @@ public static DndItem choose(final Item item, final String dropZoneId) { public static IsaacDndQuestion createQuestion(final DndItemChoice... answers) { var question = new IsaacDndQuestion(); question.setId(UUID.randomUUID().toString()); - question.setItems(List.of(item_3cm, item_4cm, item_5cm, item_6cm)); + question.setItems(List.of(item_3cm, item_4cm, item_5cm, item_6cm, item_12cm, item_13cm)); question.setChoices(List.of(answers)); question.setType("isaacDndQuestion"); return question; @@ -299,5 +316,7 @@ public Map build() { public static final Item item_4cm = item("6d3e", "4 cm"); public static final Item item_5cm = item("6d3f", "5 cm"); public static final Item item_6cm = item("6d3g", "5 cm"); + public static final Item item_12cm = item("6d3h", "12 cm"); + public static final Item item_13cm = item("6d3i", "13 cm"); } From 7e3521a10bb61219f484381b6d8f1315d73ce8d6 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Tue, 2 Dec 2025 15:50:08 +0000 Subject: [PATCH 18/65] ensure default explanation is returned This fixes a bug where we didn't return a default explanation when an incorrect answer without an explanation was matched. --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 36 +++++++------------ .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 16 +++++++++ 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 8366e58440..252f6ced33 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -56,35 +56,23 @@ private void validate(final Question question, final Choice answer) { } private DndValidationResponse mark(final IsaacDndQuestion question, final DndItemChoice answer) { - List sortedItems = question.getChoices().stream() + List sortedAnswers = question.getChoices().stream() .sorted(Comparator.comparingInt(c -> c.countPartialMatchesIn(answer))) .collect(Collectors.toList()); - DndItemChoice matchedAnswer = sortedItems.stream() - .filter(choice -> choice.matches(answer)) - .findFirst() - .orElse(incorrectAnswer(question)); + Optional matchedAnswer = sortedAnswers.stream().filter(lhs -> lhs.matches(answer)).findFirst(); - Optional closestCorrectAnswer = sortedItems.stream() - .filter(Choice::isCorrect) - .findFirst(); + Optional closestCorrectAnswer = sortedAnswers.stream().filter(Choice::isCorrect).findFirst(); - return new DndValidationResponse( - question.getId(), - answer, - matchedAnswer.isCorrect(), - BooleanUtils.isTrue(question.getDetailedItemFeedback()) - ? closestCorrectAnswer.map(c -> c.getDropZonesCorrect(answer)).orElse(null) - : null, - (Content) matchedAnswer.getExplanation(), - new Date() + var id = question.getId(); + var isCorrect = matchedAnswer.map(Choice::isCorrect).orElse(false); + var dropZonesCorrect = BooleanUtils.isTrue(question.getDetailedItemFeedback()) + ? closestCorrectAnswer.map(c -> c.getDropZonesCorrect(answer)).orElse(null) + : null; + var explanation = (Content) matchedAnswer.map(Choice::getExplanation).orElse( + isCorrect ? null : question.getDefaultFeedback() ); - } - - private DndItemChoice incorrectAnswer(final IsaacDndQuestion question) { - var choice = new DndItemChoice(); - choice.setCorrect(false); - choice.setExplanation(question.getDefaultFeedback()); - return choice; + var date = new Date(); + return new DndValidationResponse(id, answer, isCorrect, dropZonesCorrect, explanation, date); } } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index ed34527a85..7e74131d91 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -126,6 +126,22 @@ public final void explanation_exactMatchCorrect_shouldReturnMatching() { assertEquals(response.getExplanation(), correctFeedback); } + @Test + public final void explanation_exactMatchIncorrectDefault_shouldReturnDefault() { + var defaultFeedback = new Content("Isaac can't help you."); + var question = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))), + incorrect(answer(choose(item_4cm, "hypothenuse"))) + ); + question.setDefaultFeedback(defaultFeedback); + var answer = answer(choose(item_4cm, "hypothenuse")); + + var response = testValidate(question, answer); + + assertFalse(response.isCorrect()); + assertEquals(response.getExplanation(), defaultFeedback); + } + @Test public final void explanation_defaultIncorrect_shouldReturnDefault() { var question = createQuestion( From 908c3b8af51290de65781ffaf3f36ad6b4d3b430 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Wed, 3 Dec 2025 14:06:49 +0000 Subject: [PATCH 19/65] add explanations for no answer, missing items --- .../dtg/isaac/dos/content/DndItemChoice.java | 10 +-- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 22 +++++-- .../cl/dtg/isaac/api/QuestionFacadeIT.java | 12 ++++ .../isaac/quiz/IsaacClozeValidatorTest.java | 30 +++++++++ .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 66 +++++++++++++++++-- 5 files changed, 126 insertions(+), 14 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java index c49b4aae1f..59593a3c5b 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java @@ -67,10 +67,12 @@ public int countPartialMatchesIn(final DndItemChoice rhs) { } public Map getDropZonesCorrect(final DndItemChoice rhs) { - return this.items.stream().collect(Collectors.toMap( - DndItem::getDropZoneId, - lhsItem -> dropZoneEql(lhsItem, rhs)) - ); + return this.items.stream() + .filter(lhsItem -> rhs.getItemByDropZone(lhsItem.getDropZoneId()).isPresent()) + .collect(Collectors.toMap( + DndItem::getDropZoneId, + lhsItem -> dropZoneEql(lhsItem, rhs)) + ); } private static boolean dropZoneEql(DndItem lhsItem, DndItemChoice rhs) { diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 252f6ced33..c135b630c9 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -16,6 +16,7 @@ package uk.ac.cam.cl.dtg.isaac.quiz; import org.apache.commons.lang3.BooleanUtils; +import uk.ac.cam.cl.dtg.isaac.api.Constants; import uk.ac.cam.cl.dtg.isaac.dos.DndValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; @@ -62,17 +63,28 @@ private DndValidationResponse mark(final IsaacDndQuestion question, final DndIte Optional matchedAnswer = sortedAnswers.stream().filter(lhs -> lhs.matches(answer)).findFirst(); - Optional closestCorrectAnswer = sortedAnswers.stream().filter(Choice::isCorrect).findFirst(); + DndItemChoice closestCorrectAnswer = sortedAnswers.stream().filter(Choice::isCorrect).findFirst().orElse(null); var id = question.getId(); var isCorrect = matchedAnswer.map(Choice::isCorrect).orElse(false); var dropZonesCorrect = BooleanUtils.isTrue(question.getDetailedItemFeedback()) - ? closestCorrectAnswer.map(c -> c.getDropZonesCorrect(answer)).orElse(null) + ? closestCorrectAnswer.getDropZonesCorrect(answer) : null; - var explanation = (Content) matchedAnswer.map(Choice::getExplanation).orElse( - isCorrect ? null : question.getDefaultFeedback() - ); + var explanation = (Content) matchedAnswer.map(Choice::getExplanation).orElse(explain(isCorrect, closestCorrectAnswer, question, answer)); var date = new Date(); return new DndValidationResponse(id, answer, isCorrect, dropZonesCorrect, explanation, date); } + + private Content explain(final boolean isCorrect, final DndItemChoice closestCorrectAnswer, final IsaacDndQuestion question, final DndItemChoice answer) { + if (isCorrect) { + return null; + } + if (answer.getItems().isEmpty()) { + return new Content(Constants.FEEDBACK_NO_ANSWER_PROVIDED); + } + if (answer.getItems().size() < closestCorrectAnswer.getItems().size()) { + return new Content("You did not provide a valid answer; it does not contain an item for each gap."); + } + return question.getDefaultFeedback(); + } } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java index d0f614e9e0..80c319316b 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -66,6 +66,18 @@ public void rightAnswer() throws Exception { @Nested class DndQuestion { + @Test + public void emptyAnswer() throws Exception { + var dndQuestion = persist(createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + )); + var answer = answer(); + + var response = subject().client().post(url(dndQuestion.getId()), answer).readEntityAsJson(); + + assertFalse(response.getBoolean("correct")); + assertEquals(answer, readEntity(response.getJSONObject("answer"), DndItemChoice.class)); + } @Test public void wrongAnswer() throws Exception { var dndQuestion = persist(createQuestion( diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacClozeValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacClozeValidatorTest.java index 589bd05058..89336dddd9 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacClozeValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacClozeValidatorTest.java @@ -200,6 +200,36 @@ public final void isaacClozeValidator_NotEnoughItems_IncorrectResponseShouldBeRe assertTrue(response.getExplanation().getValue().contains("does not contain an item for each gap")); } + + /* + Test that known incorrect answers can be matched. + */ + @Test + public final void isaacClozeValidator_NotEnoughItemsMatchingIncorrectResponse_NotEnoughResponseShouldBeReturned_() { + // Set up the question object: + IsaacClozeQuestion clozeQuestion = new IsaacClozeQuestion(); + clozeQuestion.setItems(ImmutableList.of(item1, item2)); + + ItemChoice someCorrectAnswer = new ItemChoice(); + someCorrectAnswer.setItems(ImmutableList.of(item1, item3)); + someCorrectAnswer.setCorrect(true); + ItemChoice someIncorrectAnswer = new ItemChoice(); + + someIncorrectAnswer.setItems(ImmutableList.of(item1, NULL_PLACEHOLDER)); + someIncorrectAnswer.setCorrect(false); + someIncorrectAnswer.setExplanation(new Content("This is a very bad choice.")); + clozeQuestion.setChoices(ImmutableList.of(someCorrectAnswer, someIncorrectAnswer)); + + // Set up user answer: + ItemChoice c = new ItemChoice(); + c.setItems(ImmutableList.of(item1)); + + // Test response: + QuestionValidationResponse response = validator.validateQuestionResponse(clozeQuestion, c); + assertFalse(response.isCorrect()); + assertTrue(response.getExplanation().getValue().contains("does not contain an item for each gap")); + } + /* Test that answers with too many items are rejected. */ diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 7e74131d91..27e399b3bb 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -93,7 +93,6 @@ public final void correctness_moreSpecificIncorrectMatchOverridesCorrect_Incorre // - on same level? (or even across levels?) // - should return all? // - should return just one, but predictably? - // TODO: test for empty answer // @Test public final void explanation_exactMatchIncorrect_shouldReturnMatching() { @@ -134,7 +133,7 @@ public final void explanation_exactMatchIncorrectDefault_shouldReturnDefault() { incorrect(answer(choose(item_4cm, "hypothenuse"))) ); question.setDefaultFeedback(defaultFeedback); - var answer = answer(choose(item_4cm, "hypothenuse")); + var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_4cm, "hypothenuse")); var response = testValidate(question, answer); @@ -220,7 +219,7 @@ public final void dropZonesCorrect_someIncorrect_shouldReturnWhetherCorrect() { correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ); question.setDetailedItemFeedback(true); - var answer = answer(choose(item_6cm, "leg_1"), choose(item_5cm, "hypothenuse")); + var answer = answer(choose(item_6cm, "leg_1"), choose(item_5cm, "leg_2"), choose(item_5cm, "hypothenuse")); var response = testValidate(question, answer); assertFalse(response.isCorrect()); @@ -242,7 +241,64 @@ public final void dropZonesCorrect_multipleCorrectAnswers_decidesCorrectnessBase var response = testValidate(question, answer); assertFalse(response.isCorrect()); assertEquals( - new DropZonesCorrectFactory().setLeg1(true).setLeg2(false).setHypothenuse(false).build(), + new DropZonesCorrectFactory().setLeg1(true).build(), + response.getDropZonesCorrect() + ); + } + + @Test + public final void answerValidation_empty_incorrect() { + var question = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + ); + question.setDetailedItemFeedback(true); + var answer = answer(); + + var response = testValidate(question, answer); + assertFalse(response.isCorrect()); + assertEquals(new Content("You did not provide an answer."), response.getExplanation()); + assertEquals( + new DropZonesCorrectFactory().build(), + response.getDropZonesCorrect() + ); + } + + @Test + public final void answerValidation_someMissing_explainsMissingItems() { + var question = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + ); + question.setDetailedItemFeedback(true); + var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2")); + + var response = testValidate(question, answer); + assertFalse(response.isCorrect()); + assertTrue(response.getExplanation().getValue().contains("does not contain an item for each gap")); + assertEquals( + new DropZonesCorrectFactory().setLeg1(true).setLeg2(true).build(), + response.getDropZonesCorrect() + ); + } + + /* + * Test that when the user submits an answer with missing items, we first show any matching feedback + * about the incorrect answer, rather than the more generic feedback about missing items. + */ + @Test + public final void answerValidation_someMissing_providesSpecificExplanationFirst() { + var incorrectFeedback = new Content("Leg 1 should be less than 4 cm"); + var question = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))), + incorrect(answer(choose(item_4cm, "leg_1")), incorrectFeedback) + ); + question.setDetailedItemFeedback(true); + var answer = answer(choose(item_4cm, "leg_1")); + + var response = testValidate(question, answer); + assertFalse(response.isCorrect()); + assertEquals(incorrectFeedback, response.getExplanation()); + assertEquals( + new DropZonesCorrectFactory().setLeg1(false).build(), response.getDropZonesCorrect() ); } @@ -308,7 +364,7 @@ public static Item item(final String id, final String value) { public static class DropZonesCorrectFactory { private final Map map = new HashMap<>(); - public DropZonesCorrectFactory setLeg1(final boolean value) { + public DropZonesCorrectFactory setLeg1(final Boolean value) { map.put("leg_1", value); return this; } From 11a4719b62c794d04aaf898b3fe6c49f7c2f68a3 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Wed, 3 Dec 2025 16:03:51 +0000 Subject: [PATCH 20/65] add error states for unrecognized, too many items --- .../dtg/isaac/dos/content/DndItemChoice.java | 3 +- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 72 +++++++++++------ .../cl/dtg/isaac/api/QuestionFacadeIT.java | 27 ++++++- .../isaac/quiz/IsaacClozeValidatorTest.java | 7 +- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 81 +++++++++++++++---- 5 files changed, 146 insertions(+), 44 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java index 59593a3c5b..77f34d6e7a 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java @@ -56,7 +56,8 @@ public void setAllowSubsetMatch(final boolean allowSubsetMatch) { } public boolean matches(final DndItemChoice rhs) { - return this.items.stream().allMatch(lhsItem -> dropZoneEql(lhsItem, rhs)); + return this.items.stream().allMatch(lhsItem -> dropZoneEql(lhsItem, rhs)) + && this.items.size() == rhs.getItems().size(); } public int countPartialMatchesIn(final DndItemChoice rhs) { diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index c135b630c9..bc54bc6d35 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -21,7 +21,9 @@ import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; import uk.ac.cam.cl.dtg.isaac.dos.content.Content; +import uk.ac.cam.cl.dtg.isaac.dos.content.DndItem; import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; +import uk.ac.cam.cl.dtg.isaac.dos.content.Item; import uk.ac.cam.cl.dtg.isaac.dos.content.Question; import java.util.Comparator; @@ -37,23 +39,11 @@ public class IsaacDndValidator implements IValidator { @Override public final DndValidationResponse validateQuestionResponse(final Question question, final Choice answer) { - validate(question, answer); - return mark((IsaacDndQuestion) question, (DndItemChoice) answer); - } - - private void validate(final Question question, final Choice answer) { - Objects.requireNonNull(question); - Objects.requireNonNull(answer); - - if (!(answer instanceof DndItemChoice)) { - throw new IllegalArgumentException(String.format( - "This validator only works with DndItemChoices (%s is not DndItemChoice)", question.getId())); - } - - if (!(question instanceof IsaacDndQuestion)) { - throw new IllegalArgumentException(String.format( - "This validator only works with IsaacDndQuestions (%s is not IsaacDndQuestion)", question.getId())); + boolean valid = validate(question, answer); + if (valid) { + return mark((IsaacDndQuestion) question, (DndItemChoice) answer); } + return new DndValidationResponse(question.getId(), answer, false, null, new Content(Constants.FEEDBACK_NO_ANSWER_PROVIDED), new Date()); } private DndValidationResponse mark(final IsaacDndQuestion question, final DndItemChoice answer) { @@ -70,21 +60,51 @@ private DndValidationResponse mark(final IsaacDndQuestion question, final DndIte var dropZonesCorrect = BooleanUtils.isTrue(question.getDetailedItemFeedback()) ? closestCorrectAnswer.getDropZonesCorrect(answer) : null; - var explanation = (Content) matchedAnswer.map(Choice::getExplanation).orElse(explain(isCorrect, closestCorrectAnswer, question, answer)); + var explanation = explain(isCorrect, closestCorrectAnswer, question, answer, matchedAnswer); var date = new Date(); return new DndValidationResponse(id, answer, isCorrect, dropZonesCorrect, explanation, date); } - private Content explain(final boolean isCorrect, final DndItemChoice closestCorrectAnswer, final IsaacDndQuestion question, final DndItemChoice answer) { - if (isCorrect) { - return null; - } - if (answer.getItems().isEmpty()) { - return new Content(Constants.FEEDBACK_NO_ANSWER_PROVIDED); + private Content explain( + final boolean isCorrect, final DndItemChoice correctAnswer, final IsaacDndQuestion question, + final DndItemChoice answer, final Optional matchedAnswer + ) { + return (Content) matchedAnswer.map(Choice::getExplanation).orElseGet(() -> { + if (isCorrect) { + return null; + } + if (answer.getItems().isEmpty()) { + return new Content(Constants.FEEDBACK_NO_ANSWER_PROVIDED); + } + if (answer.getItems().size() < correctAnswer.getItems().size()) { + return new Content("You did not provide a valid answer; it does not contain an item for each gap."); + } + if (answer.getItems().stream().anyMatch(answerItem -> !question.getItems().contains(answerItem))) { + return new Content(Constants.FEEDBACK_UNRECOGNISED_ITEMS); + } + if (answer.getItems().size() > correctAnswer.getItems().size()) { + return new Content("You did not provide a valid answer; it contains more items than gaps."); + } + return question.getDefaultFeedback(); + }); + } + + private boolean validate(final Question question, final Choice answer) { + Objects.requireNonNull(question); + Objects.requireNonNull(answer); + + if (!(answer instanceof DndItemChoice)) { + throw new IllegalArgumentException(String.format( + "This validator only works with DndItemChoices (%s is not DndItemChoice)", question.getId())); } - if (answer.getItems().size() < closestCorrectAnswer.getItems().size()) { - return new Content("You did not provide a valid answer; it does not contain an item for each gap."); + + if (!(question instanceof IsaacDndQuestion)) { + throw new IllegalArgumentException(String.format( + "This validator only works with IsaacDndQuestions (%s is not IsaacDndQuestion)", question.getId())); } - return question.getDefaultFeedback(); + + var dndAnswer = (DndItemChoice) answer; + + return dndAnswer.getItems() != null && !dndAnswer.getItems().isEmpty(); } } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java index 80c319316b..925fcd33ff 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -67,7 +67,7 @@ public void rightAnswer() throws Exception { @Nested class DndQuestion { @Test - public void emptyAnswer() throws Exception { + public void emptyAnswerEmptyItems() throws Exception { var dndQuestion = persist(createQuestion( correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) )); @@ -78,6 +78,31 @@ public void emptyAnswer() throws Exception { assertFalse(response.getBoolean("correct")); assertEquals(answer, readEntity(response.getJSONObject("answer"), DndItemChoice.class)); } + + @Test + public void emptyAnswerEmptyNoItems() throws Exception { + var dndQuestion = persist(createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + )); + var answer = new DndItemChoice(); + answer.setType("dndChoice"); + var response = subject().client().post(url(dndQuestion.getId()), answer).readEntityAsJson(); + + assertFalse(response.getBoolean("correct")); + assertEquals(answer, readEntity(response.getJSONObject("answer"), DndItemChoice.class)); + } + +// @Test +// public void invalidAnswer() throws Exception { +// var dndQuestion = persist(createQuestion( +// correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) +// )); +// var answer = "{}"; +// +// var response = subject().client().post(url(dndQuestion.getId()), answer); +// response.assertError("hello", Response.Status.NOT_FOUND); +// } + @Test public void wrongAnswer() throws Exception { var dndQuestion = persist(createQuestion( diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacClozeValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacClozeValidatorTest.java index 89336dddd9..05fabe8ec5 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacClozeValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacClozeValidatorTest.java @@ -202,7 +202,12 @@ public final void isaacClozeValidator_NotEnoughItems_IncorrectResponseShouldBeRe /* - Test that known incorrect answers can be matched. + * Test that when the user submits an answer with missing items, we show the generic + * feedback about missing items, even though we have more specific feedback about + * some of the submitted answers being wrong. + * + * I think it'd be better to show specific feedback. This test is here to prove that + * this is not how the current implementation works. */ @Test public final void isaacClozeValidator_NotEnoughItemsMatchingIncorrectResponse_NotEnoughResponseShouldBeReturned_() { diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 27e399b3bb..08d22bb2a2 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -16,6 +16,7 @@ package uk.ac.cam.cl.dtg.isaac.quiz; import org.junit.Test; +import uk.ac.cam.cl.dtg.isaac.api.Constants; import uk.ac.cam.cl.dtg.isaac.dos.DndValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; @@ -240,10 +241,7 @@ public final void dropZonesCorrect_multipleCorrectAnswers_decidesCorrectnessBase var response = testValidate(question, answer); assertFalse(response.isCorrect()); - assertEquals( - new DropZonesCorrectFactory().setLeg1(true).build(), - response.getDropZonesCorrect() - ); + assertEquals(new DropZonesCorrectFactory().setLeg1(true).build(), response.getDropZonesCorrect()); } @Test @@ -256,11 +254,22 @@ public final void answerValidation_empty_incorrect() { var response = testValidate(question, answer); assertFalse(response.isCorrect()); - assertEquals(new Content("You did not provide an answer."), response.getExplanation()); - assertEquals( - new DropZonesCorrectFactory().build(), - response.getDropZonesCorrect() + assertEquals(new Content(Constants.FEEDBACK_NO_ANSWER_PROVIDED), response.getExplanation()); + assertNull(response.getDropZonesCorrect()); + } + + @Test + public final void answerValidation_emptyNoItems_incorrect() { + var question = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ); + question.setDetailedItemFeedback(true); + var answer = new DndItemChoice(); + + var response = testValidate(question, answer); + assertFalse(response.isCorrect()); + assertEquals(new Content(Constants.FEEDBACK_NO_ANSWER_PROVIDED), response.getExplanation()); + assertNull(response.getDropZonesCorrect()); } @Test @@ -274,15 +283,14 @@ public final void answerValidation_someMissing_explainsMissingItems() { var response = testValidate(question, answer); assertFalse(response.isCorrect()); assertTrue(response.getExplanation().getValue().contains("does not contain an item for each gap")); - assertEquals( - new DropZonesCorrectFactory().setLeg1(true).setLeg2(true).build(), - response.getDropZonesCorrect() - ); + assertEquals(new DropZonesCorrectFactory().setLeg1(true).setLeg2(true).build(), response.getDropZonesCorrect()); } /* * Test that when the user submits an answer with missing items, we first show any matching feedback * about the incorrect answer, rather than the more generic feedback about missing items. + * + * Cloze questions don't even look at matches in that case, but I think this is better UX. */ @Test public final void answerValidation_someMissing_providesSpecificExplanationFirst() { @@ -297,12 +305,55 @@ public final void answerValidation_someMissing_providesSpecificExplanationFirst( var response = testValidate(question, answer); assertFalse(response.isCorrect()); assertEquals(incorrectFeedback, response.getExplanation()); - assertEquals( - new DropZonesCorrectFactory().setLeg1(false).build(), - response.getDropZonesCorrect() + assertEquals(new DropZonesCorrectFactory().setLeg1(false).build(), response.getDropZonesCorrect()); + } + + // TODO: when a partial match contains incorrect items, show feedback about this, + // rather than telling the user they needed to submit more items. + + // TODO: invalid questions that are not producible on the UI should never be marked + @Test + public final void answerValidation_tooMany_explainsTooManyItems() { + var question = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"))) ); + question.setDetailedItemFeedback(true); + var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); + + var response = testValidate(question, answer); + assertFalse(response.isCorrect()); + assertTrue(response.getExplanation().getValue().contains("it contains more items than gaps")); + assertEquals(new DropZonesCorrectFactory().setLeg1(true).setLeg2(true).build(), response.getDropZonesCorrect()); } + @Test + public final void answerValidation_unknownItems_explainsUnknownItems() { + var question = createQuestion(correct(answer(choose(item_3cm, "leg_1")))); + question.setDetailedItemFeedback(true); + var answer = answer(choose(new Item("unknown", null), "leg_1")); + + var response = testValidate(question, answer); + assertFalse(response.isCorrect()); + assertEquals(new Content(Constants.FEEDBACK_UNRECOGNISED_ITEMS), response.getExplanation()); + assertEquals(new DropZonesCorrectFactory().setLeg1(false).build(), response.getDropZonesCorrect()); + } + +// @Test +// public final void answerValidation_wrongItems_explainsIncorrectItems() { +// var question = createQuestion(correct(answer(choose(item_3cm, "leg_1")))); +// question.setDetailedItemFeedback(true); +// var item = new ParsonsItem(item_3cm.getId(), null, null); +// var answer = new ItemChoice(); +// answer.setItems(List.of(item)); +// +// var response = testValidate(question, answer); +// assertFalse(response.isCorrect()); +// assertEquals(new Content(Constants.FEEDBACK_UNRECOGNISED_FORMAT), response.getExplanation()); +// assertEquals(new DropZonesCorrectFactory().setLeg1(false).build(), response.getDropZonesCorrect()); +// } + + // TODO: check when a non-existing drop zone was used? (and anything that doesn't exist in a correct answer is invalid?) + private static DndValidationResponse testValidate(final IsaacDndQuestion question, final Choice choice) { return new IsaacDndValidator().validateQuestionResponse(question, choice); } From ab39d23dadfec12eff8d92bd2ff1aa0765b43d66 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Wed, 3 Dec 2025 17:35:44 +0000 Subject: [PATCH 21/65] test invalid answers --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 14 ++- .../api/IsaacIntegrationTestWithREST.java | 12 ++- .../cl/dtg/isaac/api/QuestionFacadeIT.java | 85 ++++++++++--------- 3 files changed, 66 insertions(+), 45 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index bc54bc6d35..63adcf4ca5 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -21,9 +21,7 @@ import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; import uk.ac.cam.cl.dtg.isaac.dos.content.Content; -import uk.ac.cam.cl.dtg.isaac.dos.content.DndItem; import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; -import uk.ac.cam.cl.dtg.isaac.dos.content.Item; import uk.ac.cam.cl.dtg.isaac.dos.content.Question; import java.util.Comparator; @@ -31,6 +29,7 @@ import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.function.Supplier; import java.util.stream.Collectors; /** @@ -105,6 +104,15 @@ private boolean validate(final Question question, final Choice answer) { var dndAnswer = (DndItemChoice) answer; - return dndAnswer.getItems() != null && !dndAnswer.getItems().isEmpty(); + Supplier hasItems = () -> dndAnswer.getItems() != null && !dndAnswer.getItems().isEmpty(); + Supplier itemsHaveId = () -> dndAnswer.getItems().stream().allMatch(i -> i.getId() != null); + Supplier itemsHaveDropZoneId = () -> dndAnswer.getItems().stream().allMatch(i -> i.getDropZoneId() != null); + if (hasItems.get() && !itemsHaveId.get()) { + throw new IllegalArgumentException("Cannot validate answer with missing ids"); + } + if (hasItems.get() && !itemsHaveDropZoneId.get()) { + throw new IllegalArgumentException("Cannot validate answer with missing dropZoneIds"); + } + return hasItems.get(); } } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/IsaacIntegrationTestWithREST.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/IsaacIntegrationTestWithREST.java index 7ec37134a1..f9e408bdb1 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/IsaacIntegrationTestWithREST.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/IsaacIntegrationTestWithREST.java @@ -27,6 +27,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Abstract superclass for integration test. Use when testing in the context of a REST application. This lets you @@ -158,8 +159,13 @@ static class TestResponse { } void assertError(final String message, final Response.Status status) { - assertEquals(message, response.readEntity(Map.class).get("errorMessage")); assertEquals(status.getStatusCode(), response.getStatus()); + assertTrue(this.readEntityAsJsonUnchecked().getString("errorMessage").contains(message)); + } + + void assertError(final String message, final String status) { + assertEquals(Integer.parseInt(status), response.getStatus()); + assertTrue(this.readEntityAsJsonUnchecked().getString("errorMessage").contains(message)); } void assertNoUserLoggedIn() { @@ -185,6 +191,10 @@ T readEntity(final Class klass) { JSONObject readEntityAsJson() { assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + return this.readEntityAsJsonUnchecked(); + } + + private JSONObject readEntityAsJsonUnchecked() { String body = response.readEntity(String.class); return new JSONObject(body); } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java index 925fcd33ff..2bc1a864a5 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -3,6 +3,9 @@ import org.json.JSONObject; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; import uk.ac.cam.cl.dtg.isaac.dos.content.Content; import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; @@ -66,43 +69,54 @@ public void rightAnswer() throws Exception { @Nested class DndQuestion { - @Test - public void emptyAnswerEmptyItems() throws Exception { - var dndQuestion = persist(createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) - )); - var answer = answer(); - - var response = subject().client().post(url(dndQuestion.getId()), answer).readEntityAsJson(); + @ParameterizedTest + @CsvSource(value = { + "{};Unable to map response to a Choice;404", + "{\"type\": \"unknown\"};This validator only works with DndItemChoices;400", + "{\"type\": \"dndChoice\", \"items\": [{\"dropZoneId\": \"leg_1\"}]};Cannot validate answer with missing ids;400", + "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\"}]};Cannot validate answer with missing dropZoneIds;400", + "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\", \"a\": \"a\"}]};Unable to map response to a Choice;404", + "{\"type\": \"dndChoice\", \"items\": \"some_string\"};Unable to map response to a Choice;404", + "{\"type\": \"dndChoice\", \"items\": [{\"id\": [{}], \"dropZoneId\": \"leg_1\"}]};Unable to map response to a Choice;404" + }, delimiter = ';') + public void invalidAnswer(final String answerStr, final String emsg, final String estate) throws Exception { + var dndQuestion = persist(createQuestion(correct(answer(choose(item_3cm, "leg_1"))))); + var response = subject().client().post(url(dndQuestion.getId()), answerStr); + response.assertError(emsg, estate); + } + @ParameterizedTest + @CsvSource(value = { + "{\"type\": \"dndChoice\"}", + "{\"type\": \"dndChoice\", \"items\": []}" + }, delimiter = ';') + public void validEmptyAnswer(final String answerStr) throws Exception { + var dndQuestion = persist(createQuestion(correct(answer(choose(item_3cm, "leg_1"))))); + var response = subject().client().post(url(dndQuestion.getId()), answerStr).readEntityAsJson(); assertFalse(response.getBoolean("correct")); - assertEquals(answer, readEntity(response.getJSONObject("answer"), DndItemChoice.class)); + assertEquals( + readEntity(new JSONObject(answerStr), DndItemChoice.class), + readEntity(response.getJSONObject("answer"), DndItemChoice.class) + ); } - @Test - public void emptyAnswerEmptyNoItems() throws Exception { - var dndQuestion = persist(createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) - )); - var answer = new DndItemChoice(); - answer.setType("dndChoice"); - var response = subject().client().post(url(dndQuestion.getId()), answer).readEntityAsJson(); + @ParameterizedTest + @CsvSource(value = { + "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\"}]}", + "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\", \"type\": \"dndItem\"}]}", + "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\", \"type\": \"unknown\"}]}" + }, delimiter = ';') + public void validCorrectAnswer(final String answerStr) throws Exception { + var dndQuestion = persist(createQuestion(correct(answer(choose(item_3cm, "leg_1"))))); - assertFalse(response.getBoolean("correct")); - assertEquals(answer, readEntity(response.getJSONObject("answer"), DndItemChoice.class)); + var response = subject().client().post(url(dndQuestion.getId()), answerStr).readEntityAsJson(); + assertTrue(response.getBoolean("correct")); + assertEquals( + readEntity(new JSONObject(answerStr), DndItemChoice.class), + readEntity(response.getJSONObject("answer"), DndItemChoice.class) + ); } -// @Test -// public void invalidAnswer() throws Exception { -// var dndQuestion = persist(createQuestion( -// correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) -// )); -// var answer = "{}"; -// -// var response = subject().client().post(url(dndQuestion.getId()), answer); -// response.assertError("hello", Response.Status.NOT_FOUND); -// } - @Test public void wrongAnswer() throws Exception { var dndQuestion = persist(createQuestion( @@ -116,18 +130,7 @@ public void wrongAnswer() throws Exception { assertEquals(answer, readEntity(response.getJSONObject("answer"), DndItemChoice.class)); } - @Test - public void rightAnswer() throws Exception { - var dndQuestion = persist(createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) - )); - var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); - - var response = subject().client().post(url(dndQuestion.getId()), answer).readEntityAsJson(); - assertTrue(response.getBoolean("correct")); - assertEquals(answer, readEntity(response.getJSONObject("answer"), DndItemChoice.class)); - } @Test public void explanation() throws Exception { From 86e7c11e50e586520a3bf6e455a5d3f83e31c6d2 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Thu, 4 Dec 2025 15:17:13 +0000 Subject: [PATCH 22/65] invalid answers message returned as explanation --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 53 +++++++++---------- .../cl/dtg/isaac/api/QuestionFacadeIT.java | 26 ++++++--- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 32 +++++++++-- 3 files changed, 72 insertions(+), 39 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 63adcf4ca5..79b2bdbfb2 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -26,10 +26,11 @@ import java.util.Comparator; import java.util.Date; +import java.util.LinkedHashMap; import java.util.List; import java.util.Objects; import java.util.Optional; -import java.util.function.Supplier; +import java.util.function.BiPredicate; import java.util.stream.Collectors; /** @@ -38,14 +39,12 @@ public class IsaacDndValidator implements IValidator { @Override public final DndValidationResponse validateQuestionResponse(final Question question, final Choice answer) { - boolean valid = validate(question, answer); - if (valid) { - return mark((IsaacDndQuestion) question, (DndItemChoice) answer); - } - return new DndValidationResponse(question.getId(), answer, false, null, new Content(Constants.FEEDBACK_NO_ANSWER_PROVIDED), new Date()); + return validateSyntax(question, answer).orElseGet( + () -> validateMarks((IsaacDndQuestion) question, (DndItemChoice) answer) + ); } - private DndValidationResponse mark(final IsaacDndQuestion question, final DndItemChoice answer) { + private DndValidationResponse validateMarks(final IsaacDndQuestion question, final DndItemChoice answer) { List sortedAnswers = question.getChoices().stream() .sorted(Comparator.comparingInt(c -> c.countPartialMatchesIn(answer))) .collect(Collectors.toList()); @@ -72,15 +71,9 @@ private Content explain( if (isCorrect) { return null; } - if (answer.getItems().isEmpty()) { - return new Content(Constants.FEEDBACK_NO_ANSWER_PROVIDED); - } if (answer.getItems().size() < correctAnswer.getItems().size()) { return new Content("You did not provide a valid answer; it does not contain an item for each gap."); } - if (answer.getItems().stream().anyMatch(answerItem -> !question.getItems().contains(answerItem))) { - return new Content(Constants.FEEDBACK_UNRECOGNISED_ITEMS); - } if (answer.getItems().size() > correctAnswer.getItems().size()) { return new Content("You did not provide a valid answer; it contains more items than gaps."); } @@ -88,31 +81,37 @@ private Content explain( }); } - private boolean validate(final Question question, final Choice answer) { + private Optional validateSyntax(final Question question, final Choice answer) { Objects.requireNonNull(question); Objects.requireNonNull(answer); if (!(answer instanceof DndItemChoice)) { throw new IllegalArgumentException(String.format( - "This validator only works with DndItemChoices (%s is not DndItemChoice)", question.getId())); + "This validator only works with DndItemChoices (%s is not DndItemChoice)", question.getId())); } if (!(question instanceof IsaacDndQuestion)) { throw new IllegalArgumentException(String.format( - "This validator only works with IsaacDndQuestions (%s is not IsaacDndQuestion)", question.getId())); + "This validator only works with IsaacDndQuestions (%s is not IsaacDndQuestion)", question.getId())); } - var dndAnswer = (DndItemChoice) answer; + Rules rules = new Rules(); + rules.put(Constants.FEEDBACK_NO_ANSWER_PROVIDED, (q, a) -> a.getItems() == null || a.getItems().isEmpty()); + rules.put(Constants.FEEDBACK_UNRECOGNISED_ITEMS, + (q, a) -> a.getItems().stream().anyMatch(answerItem -> !q.getItems().contains(answerItem))); + rules.put(Constants.FEEDBACK_UNRECOGNISED_FORMAT, + (q, a) -> a.getItems().stream().anyMatch(i -> i.getId() == null) + || a.getItems().stream().anyMatch(i -> i.getDropZoneId() == null) + ); + return check(rules, (IsaacDndQuestion) question, (DndItemChoice) answer); + } - Supplier hasItems = () -> dndAnswer.getItems() != null && !dndAnswer.getItems().isEmpty(); - Supplier itemsHaveId = () -> dndAnswer.getItems().stream().allMatch(i -> i.getId() != null); - Supplier itemsHaveDropZoneId = () -> dndAnswer.getItems().stream().allMatch(i -> i.getDropZoneId() != null); - if (hasItems.get() && !itemsHaveId.get()) { - throw new IllegalArgumentException("Cannot validate answer with missing ids"); - } - if (hasItems.get() && !itemsHaveDropZoneId.get()) { - throw new IllegalArgumentException("Cannot validate answer with missing dropZoneIds"); - } - return hasItems.get(); + private Optional check(final Rules r, final IsaacDndQuestion q, final DndItemChoice a) { + return r.entrySet().stream() + .filter(e -> e.getValue().test(q, a)) + .map(e -> new DndValidationResponse(q.getId(), a, false, null, new Content(e.getKey()), new Date())) + .findFirst(); } + + private static class Rules extends LinkedHashMap> {} } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java index 2bc1a864a5..a9676b380c 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -73,24 +73,34 @@ class DndQuestion { @CsvSource(value = { "{};Unable to map response to a Choice;404", "{\"type\": \"unknown\"};This validator only works with DndItemChoices;400", - "{\"type\": \"dndChoice\", \"items\": [{\"dropZoneId\": \"leg_1\"}]};Cannot validate answer with missing ids;400", - "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\"}]};Cannot validate answer with missing dropZoneIds;400", "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\", \"a\": \"a\"}]};Unable to map response to a Choice;404", "{\"type\": \"dndChoice\", \"items\": \"some_string\"};Unable to map response to a Choice;404", "{\"type\": \"dndChoice\", \"items\": [{\"id\": [{}], \"dropZoneId\": \"leg_1\"}]};Unable to map response to a Choice;404" }, delimiter = ';') - public void invalidAnswer(final String answerStr, final String emsg, final String estate) throws Exception { + public void badRequest_ErrorReturned(final String answerStr, final String emsg, final String estate) throws Exception { var dndQuestion = persist(createQuestion(correct(answer(choose(item_3cm, "leg_1"))))); var response = subject().client().post(url(dndQuestion.getId()), answerStr); response.assertError(emsg, estate); } + @ParameterizedTest + @CsvSource(value = { + "{\"type\": \"dndChoice\", \"items\": [{\"dropZoneId\": \"leg_1\"}]};Your answer is not in a recognised format.", + "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\"}]};Your answer is not in a recognised format." + }, delimiter = ';') + public void badRequest_IncorrectReturnedWithExplanation(final String answerStr, final String emsg) throws Exception { + var dndQuestion = persist(createQuestion(correct(answer(choose(item_3cm, "leg_1"))))); + var response = subject().client().post(url(dndQuestion.getId()), answerStr).readEntityAsJson(); + assertFalse(response.getBoolean("correct")); + assertEquals(emsg, response.getJSONObject("explanation").getString("value")); + } + @ParameterizedTest @CsvSource(value = { "{\"type\": \"dndChoice\"}", "{\"type\": \"dndChoice\", \"items\": []}" }, delimiter = ';') - public void validEmptyAnswer(final String answerStr) throws Exception { + public void emptyAnswer_IncorrectReturned(final String answerStr) throws Exception { var dndQuestion = persist(createQuestion(correct(answer(choose(item_3cm, "leg_1"))))); var response = subject().client().post(url(dndQuestion.getId()), answerStr).readEntityAsJson(); assertFalse(response.getBoolean("correct")); @@ -106,7 +116,7 @@ public void validEmptyAnswer(final String answerStr) throws Exception { "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\", \"type\": \"dndItem\"}]}", "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\", \"type\": \"unknown\"}]}" }, delimiter = ';') - public void validCorrectAnswer(final String answerStr) throws Exception { + public void correctAnswer_CorrectReturned(final String answerStr) throws Exception { var dndQuestion = persist(createQuestion(correct(answer(choose(item_3cm, "leg_1"))))); var response = subject().client().post(url(dndQuestion.getId()), answerStr).readEntityAsJson(); @@ -118,7 +128,7 @@ public void validCorrectAnswer(final String answerStr) throws Exception { } @Test - public void wrongAnswer() throws Exception { + public void wrongAnswer_IncorrectReturned() throws Exception { var dndQuestion = persist(createQuestion( correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) )); @@ -133,7 +143,7 @@ public void wrongAnswer() throws Exception { @Test - public void explanation() throws Exception { + public void answerWithMatchingExplanation_ExplanationReturned() throws Exception { var explanation = new Content("That's right!"); var dndQuestion = persist(createQuestion(correct( answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")), @@ -148,7 +158,7 @@ public void explanation() throws Exception { } @Test - public void dropZonesCorrect() throws Exception { + public void detailedItemFeedbackRequested_DropZonesCorrectReturned() throws Exception { var dndQuestion = createQuestion(correct( answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) )); diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 08d22bb2a2..34809e4cc5 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -261,7 +261,7 @@ public final void answerValidation_empty_incorrect() { @Test public final void answerValidation_emptyNoItems_incorrect() { var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ); question.setDetailedItemFeedback(true); var answer = new DndItemChoice(); @@ -311,7 +311,7 @@ public final void answerValidation_someMissing_providesSpecificExplanationFirst( // TODO: when a partial match contains incorrect items, show feedback about this, // rather than telling the user they needed to submit more items. - // TODO: invalid questions that are not producible on the UI should never be marked + // TODO: invalid questions that are not producible on the UI should never be marked (still return explanation) @Test public final void answerValidation_tooMany_explainsTooManyItems() { var question = createQuestion( @@ -330,12 +330,36 @@ public final void answerValidation_tooMany_explainsTooManyItems() { public final void answerValidation_unknownItems_explainsUnknownItems() { var question = createQuestion(correct(answer(choose(item_3cm, "leg_1")))); question.setDetailedItemFeedback(true); - var answer = answer(choose(new Item("unknown", null), "leg_1")); + var answer = answer(choose(new Item("bad_id", "some_value"), "leg_1")); var response = testValidate(question, answer); assertFalse(response.isCorrect()); assertEquals(new Content(Constants.FEEDBACK_UNRECOGNISED_ITEMS), response.getExplanation()); - assertEquals(new DropZonesCorrectFactory().setLeg1(false).build(), response.getDropZonesCorrect()); + assertEquals(null, response.getDropZonesCorrect()); + } + + @Test + public final void answerValidation_missingId_explainsUnrecognisedFormat() { + var question = createQuestion(correct(answer(choose(item_3cm, "leg_1")))); + question.setDetailedItemFeedback(true); + var answer = answer(choose(new Item(null, null), "leg_1")); + + var response = testValidate(question, answer); + assertFalse(response.isCorrect()); + assertEquals(new Content(Constants.FEEDBACK_UNRECOGNISED_FORMAT), response.getExplanation()); + assertNull(response.getDropZonesCorrect()); + } + + @Test + public final void answerValidation_missingDropZoneId_explainsUnrecognisedFormat() { + var question = createQuestion(correct(answer(choose(item_3cm, "leg_1")))); + question.setDetailedItemFeedback(true); + var answer = answer(choose(item_3cm, null)); + + var response = testValidate(question, answer); + assertFalse(response.isCorrect()); + assertEquals(new Content(Constants.FEEDBACK_UNRECOGNISED_FORMAT), response.getExplanation()); + assertNull(response.getDropZonesCorrect()); } // @Test From ec2b36fcd675d22ef56e282eaf6ffe07db1101fe Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Thu, 4 Dec 2025 17:33:30 +0000 Subject: [PATCH 23/65] validate and log question having choices --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 76 +++++++++++++++---- .../cl/dtg/isaac/api/QuestionFacadeIT.java | 1 - .../ac/cam/cl/dtg/isaac/api/TestAppender.java | 36 +++++++++ .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 60 +++++++++++---- 4 files changed, 143 insertions(+), 30 deletions(-) create mode 100644 src/test/java/uk/ac/cam/cl/dtg/isaac/api/TestAppender.java diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 79b2bdbfb2..d1dd83d932 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -16,6 +16,8 @@ package uk.ac.cam.cl.dtg.isaac.quiz; import org.apache.commons.lang3.BooleanUtils; +import org.slf4j.LoggerFactory; +import org.slf4j.Logger; import uk.ac.cam.cl.dtg.isaac.api.Constants; import uk.ac.cam.cl.dtg.isaac.dos.DndValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; @@ -31,6 +33,7 @@ import java.util.Objects; import java.util.Optional; import java.util.function.BiPredicate; +import java.util.function.Consumer; import java.util.stream.Collectors; /** @@ -39,7 +42,10 @@ public class IsaacDndValidator implements IValidator { @Override public final DndValidationResponse validateQuestionResponse(final Question question, final Choice answer) { - return validateSyntax(question, answer).orElseGet( + var errorResponse = validateSyntax(question, answer); + errorResponse.ifPresent(r -> getConfiguredLogger((IsaacDndQuestion) question).log(r.getExplanation().getValue())); + + return errorResponse.orElseGet( () -> validateMarks((IsaacDndQuestion) question, (DndItemChoice) answer) ); } @@ -95,23 +101,63 @@ private Optional validateSyntax(final Question question, "This validator only works with IsaacDndQuestions (%s is not IsaacDndQuestion)", question.getId())); } - Rules rules = new Rules(); - rules.put(Constants.FEEDBACK_NO_ANSWER_PROVIDED, (q, a) -> a.getItems() == null || a.getItems().isEmpty()); - rules.put(Constants.FEEDBACK_UNRECOGNISED_ITEMS, - (q, a) -> a.getItems().stream().anyMatch(answerItem -> !q.getItems().contains(answerItem))); - rules.put(Constants.FEEDBACK_UNRECOGNISED_FORMAT, + return new ValidatorRules() + .add(Constants.FEEDBACK_NO_CORRECT_ANSWERS, (q, a) -> q.getChoices() == null || q.getChoices().isEmpty()) + .add(Constants.FEEDBACK_NO_ANSWER_PROVIDED, (q, a) -> a.getItems() == null || a.getItems().isEmpty()) + .add(Constants.FEEDBACK_UNRECOGNISED_ITEMS, + (q, a) -> a.getItems().stream().anyMatch(answerItem -> !q.getItems().contains(answerItem))) + .add(Constants.FEEDBACK_UNRECOGNISED_FORMAT, (q, a) -> a.getItems().stream().anyMatch(i -> i.getId() == null) - || a.getItems().stream().anyMatch(i -> i.getDropZoneId() == null) - ); - return check(rules, (IsaacDndQuestion) question, (DndItemChoice) answer); + || a.getItems().stream().anyMatch(i -> i.getDropZoneId() == null)) + .check((IsaacDndQuestion) question, (DndItemChoice) answer); + } + + private LoggerRules getConfiguredLogger(IsaacDndQuestion question) { + return new LoggerRules(question) + .add(Constants.FEEDBACK_NO_CORRECT_ANSWERS, (q) -> { + var file = question.getCanonicalSourceFile(); + LoggerRules.log.error("Question does not have any answers. " + q.getId() + " src: " + file); + }); } - private Optional check(final Rules r, final IsaacDndQuestion q, final DndItemChoice a) { - return r.entrySet().stream() - .filter(e -> e.getValue().test(q, a)) - .map(e -> new DndValidationResponse(q.getId(), a, false, null, new Content(e.getKey()), new Date())) - .findFirst(); + private static class ValidatorRules { + private final LinkedHashMap> rules = new LinkedHashMap<>(); + + public ValidatorRules add(final String key, final BiPredicate rule) { + rules.put(key, rule); + return this; + } + + public Optional check(final IsaacDndQuestion q, final DndItemChoice a) { + return rules.entrySet().stream() + .filter(e -> e.getValue().test(q, a)) + .map(e -> new DndValidationResponse(q.getId(), a, false, null, new Content(e.getKey()), new Date())) + .findFirst(); + } } - private static class Rules extends LinkedHashMap> {} + private static class LoggerRules { + private static final Logger log = LoggerFactory.getLogger(IsaacDndValidator.class); + private final LinkedHashMap> rules = new LinkedHashMap<>(); + private final IsaacDndQuestion question; + public LoggerRules(IsaacDndQuestion question) { + this.question = question; + } + + + public LoggerRules add(final String key, final Consumer rule) { + rules.put(key, rule); + return this; + } + + public void log(final String event) { + rules.entrySet().stream() + .filter(e -> e.getKey().equals(event)) + .findFirst() + .map(e -> { + e.getValue().accept(question); + return true; + }); + } + } } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java index a9676b380c..39851f5b57 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -4,7 +4,6 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.CsvSource; import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; import uk.ac.cam.cl.dtg.isaac.dos.content.Content; diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/TestAppender.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/TestAppender.java new file mode 100644 index 0000000000..c23e1c018d --- /dev/null +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/TestAppender.java @@ -0,0 +1,36 @@ +package uk.ac.cam.cl.dtg.isaac.api; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.Property; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + + +public class TestAppender extends AbstractAppender { + private final List events = new ArrayList<>(); + + public TestAppender() { + super("TestAppender", null, null, true, Property.EMPTY_ARRAY); + start(); + } + + @Override + public void append(final LogEvent event) { + events.add(event.toImmutable()); + } + + public void assertLevel(final Level level) { + assertEquals(1, this.events.size()); + assertEquals(level, this.events.get(0).getLevel()); + } + + public void assertMessage(final String message) { + assertEquals(1, this.events.size()); + assertEquals(message, this.events.get(0).getMessage().getFormattedMessage()); + } +} \ No newline at end of file diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 34809e4cc5..947b85f76b 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -15,8 +15,11 @@ */ package uk.ac.cam.cl.dtg.isaac.quiz; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; import org.junit.Test; import uk.ac.cam.cl.dtg.isaac.api.Constants; +import uk.ac.cam.cl.dtg.isaac.api.TestAppender; import uk.ac.cam.cl.dtg.isaac.dos.DndValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; @@ -36,6 +39,8 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import org.apache.logging.log4j.core.Logger; + @SuppressWarnings("checkstyle:MissingJavadocType") public class IsaacDndValidatorTest { @Test @@ -362,24 +367,51 @@ public final void answerValidation_missingDropZoneId_explainsUnrecognisedFormat( assertNull(response.getDropZonesCorrect()); } -// @Test -// public final void answerValidation_wrongItems_explainsIncorrectItems() { -// var question = createQuestion(correct(answer(choose(item_3cm, "leg_1")))); -// question.setDetailedItemFeedback(true); -// var item = new ParsonsItem(item_3cm.getId(), null, null); -// var answer = new ItemChoice(); -// answer.setItems(List.of(item)); -// -// var response = testValidate(question, answer); -// assertFalse(response.isCorrect()); -// assertEquals(new Content(Constants.FEEDBACK_UNRECOGNISED_FORMAT), response.getExplanation()); -// assertEquals(new DropZonesCorrectFactory().setLeg1(false).build(), response.getDropZonesCorrect()); -// } // TODO: check when a non-existing drop zone was used? (and anything that doesn't exist in a correct answer is invalid?) + @Test + public final void questionValidation_NoChoices_ExplainsNoChoices() { + var question = createQuestion(); + question.setDetailedItemFeedback(true); + var answer = answer(choose(item_3cm, "leg_1")); + + var response = testValidate(question, answer); + assertFalse(response.isCorrect()); + assertEquals(new Content(Constants.FEEDBACK_NO_CORRECT_ANSWERS), response.getExplanation()); + assertNull(response.getDropZonesCorrect()); + } + + @Test + public final void questionValidation_NoChoices_LogsThisProblem() { + var question = createQuestion(); + question.setDetailedItemFeedback(true); + question.setId("id1"); + question.setCanonicalSourceFile("file1"); + var answer = answer(choose(item_3cm, "leg_1")); + + var appender = testValidateWithLogs(question, answer); + + appender.assertLevel(Level.ERROR); + appender.assertMessage("Question does not have any answers. id1 src: file1"); + } + private static DndValidationResponse testValidate(final IsaacDndQuestion question, final Choice choice) { - return new IsaacDndValidator().validateQuestionResponse(question, choice); + return new IsaacDndValidator().validateQuestionResponse(question, choice); + } + + private static TestAppender testValidateWithLogs(final IsaacDndQuestion question, final Choice choice) { + var appender = new TestAppender(); + Logger logger = (Logger) LogManager.getLogger(IsaacDndValidator.class); + logger.addAppender(appender); + logger.setLevel(Level.WARN); + + try { + testValidate(question, choice); + return appender; + } finally { + logger.removeAppender(new TestAppender()); + } } @SuppressWarnings("checkstyle:MissingJavadocType") From be59245d21168e6e444f8f97910c5cd0f66bbcd3 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 8 Dec 2025 10:41:28 +0000 Subject: [PATCH 24/65] test question validation for null, empty choices --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 6 ++--- .../cl/dtg/isaac/api/QuestionFacadeIT.java | 24 ++++++++++++------- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 15 +++++++++++- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index d1dd83d932..85df826b0b 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -112,7 +112,7 @@ private Optional validateSyntax(final Question question, .check((IsaacDndQuestion) question, (DndItemChoice) answer); } - private LoggerRules getConfiguredLogger(IsaacDndQuestion question) { + private LoggerRules getConfiguredLogger(final IsaacDndQuestion question) { return new LoggerRules(question) .add(Constants.FEEDBACK_NO_CORRECT_ANSWERS, (q) -> { var file = question.getCanonicalSourceFile(); @@ -140,11 +140,11 @@ private static class LoggerRules { private static final Logger log = LoggerFactory.getLogger(IsaacDndValidator.class); private final LinkedHashMap> rules = new LinkedHashMap<>(); private final IsaacDndQuestion question; - public LoggerRules(IsaacDndQuestion question) { + + public LoggerRules(final IsaacDndQuestion question) { this.question = question; } - public LoggerRules add(final String key, final Consumer rule) { rules.put(key, rule); return this; diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java index 39851f5b57..5ec63453ca 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -77,7 +77,9 @@ class DndQuestion { "{\"type\": \"dndChoice\", \"items\": [{\"id\": [{}], \"dropZoneId\": \"leg_1\"}]};Unable to map response to a Choice;404" }, delimiter = ';') public void badRequest_ErrorReturned(final String answerStr, final String emsg, final String estate) throws Exception { - var dndQuestion = persist(createQuestion(correct(answer(choose(item_3cm, "leg_1"))))); + var dndQuestion = persist(createQuestion( + correct(answer(choose(item_3cm, "leg_1"))) + )); var response = subject().client().post(url(dndQuestion.getId()), answerStr); response.assertError(emsg, estate); } @@ -88,7 +90,9 @@ public void badRequest_ErrorReturned(final String answerStr, final String emsg, "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\"}]};Your answer is not in a recognised format." }, delimiter = ';') public void badRequest_IncorrectReturnedWithExplanation(final String answerStr, final String emsg) throws Exception { - var dndQuestion = persist(createQuestion(correct(answer(choose(item_3cm, "leg_1"))))); + var dndQuestion = persist(createQuestion( + correct(answer(choose(item_3cm, "leg_1"))) + )); var response = subject().client().post(url(dndQuestion.getId()), answerStr).readEntityAsJson(); assertFalse(response.getBoolean("correct")); assertEquals(emsg, response.getJSONObject("explanation").getString("value")); @@ -100,7 +104,9 @@ public void badRequest_IncorrectReturnedWithExplanation(final String answerStr, "{\"type\": \"dndChoice\", \"items\": []}" }, delimiter = ';') public void emptyAnswer_IncorrectReturned(final String answerStr) throws Exception { - var dndQuestion = persist(createQuestion(correct(answer(choose(item_3cm, "leg_1"))))); + var dndQuestion = persist(createQuestion( + correct(answer(choose(item_3cm, "leg_1"))) + )); var response = subject().client().post(url(dndQuestion.getId()), answerStr).readEntityAsJson(); assertFalse(response.getBoolean("correct")); assertEquals( @@ -116,7 +122,9 @@ public void emptyAnswer_IncorrectReturned(final String answerStr) throws Excepti "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\", \"type\": \"unknown\"}]}" }, delimiter = ';') public void correctAnswer_CorrectReturned(final String answerStr) throws Exception { - var dndQuestion = persist(createQuestion(correct(answer(choose(item_3cm, "leg_1"))))); + var dndQuestion = persist(createQuestion( + correct(answer(choose(item_3cm, "leg_1"))) + )); var response = subject().client().post(url(dndQuestion.getId()), answerStr).readEntityAsJson(); assertTrue(response.getBoolean("correct")); @@ -139,8 +147,6 @@ public void wrongAnswer_IncorrectReturned() throws Exception { assertEquals(answer, readEntity(response.getJSONObject("answer"), DndItemChoice.class)); } - - @Test public void answerWithMatchingExplanation_ExplanationReturned() throws Exception { var explanation = new Content("That's right!"); @@ -158,9 +164,9 @@ public void answerWithMatchingExplanation_ExplanationReturned() throws Exception @Test public void detailedItemFeedbackRequested_DropZonesCorrectReturned() throws Exception { - var dndQuestion = createQuestion(correct( - answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) - )); + var dndQuestion = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + ); dndQuestion.setDetailedItemFeedback(true); persist(dndQuestion); var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 947b85f76b..93813c10e3 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -371,7 +371,7 @@ public final void answerValidation_missingDropZoneId_explainsUnrecognisedFormat( // TODO: check when a non-existing drop zone was used? (and anything that doesn't exist in a correct answer is invalid?) @Test - public final void questionValidation_NoChoices_ExplainsNoChoices() { + public final void questionValidation_NoChoicesEmpty_ExplainsNoChoices() { var question = createQuestion(); question.setDetailedItemFeedback(true); var answer = answer(choose(item_3cm, "leg_1")); @@ -382,6 +382,19 @@ public final void questionValidation_NoChoices_ExplainsNoChoices() { assertNull(response.getDropZonesCorrect()); } + @Test + public final void questionValidation_NoChoicesNull_ExplainsNoChoices() { + var question = createQuestion(); + question.setChoices(null); + question.setDetailedItemFeedback(true); + var answer = answer(choose(item_3cm, "leg_1")); + + var response = testValidate(question, answer); + assertFalse(response.isCorrect()); + assertEquals(new Content(Constants.FEEDBACK_NO_CORRECT_ANSWERS), response.getExplanation()); + assertNull(response.getDropZonesCorrect()); + } + @Test public final void questionValidation_NoChoices_LogsThisProblem() { var question = createQuestion(); From 45a024633143f9a3bee9e33d41de370b6457adf6 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 8 Dec 2025 15:43:01 +0000 Subject: [PATCH 25/65] use data provider for answer validation test cases --- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 187 +++++++++--------- 1 file changed, 93 insertions(+), 94 deletions(-) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 93813c10e3..13a3e4fcf1 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -18,6 +18,10 @@ import org.apache.logging.log4j.Level; import org.apache.logging.log4j.LogManager; import org.junit.Test; +import org.junit.experimental.theories.DataPoints; +import org.junit.experimental.theories.Theories; +import org.junit.experimental.theories.Theory; +import org.junit.runner.RunWith; import uk.ac.cam.cl.dtg.isaac.api.Constants; import uk.ac.cam.cl.dtg.isaac.api.TestAppender; import uk.ac.cam.cl.dtg.isaac.dos.DndValidationResponse; @@ -33,6 +37,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.function.UnaryOperator; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -41,8 +46,18 @@ import org.apache.logging.log4j.core.Logger; +@RunWith(Theories.class) @SuppressWarnings("checkstyle:MissingJavadocType") public class IsaacDndValidatorTest { + + + public static final Item item_3cm = item("6d3d", "3 cm"); + public static final Item item_4cm = item("6d3e", "4 cm"); + public static final Item item_5cm = item("6d3f", "5 cm"); + public static final Item item_6cm = item("6d3g", "5 cm"); + public static final Item item_12cm = item("6d3h", "12 cm"); + public static final Item item_13cm = item("6d3i", "13 cm"); + @Test public final void correctness_singleCorrectMatch_CorrectResponseShouldBeReturned() { var question = createQuestion( @@ -249,46 +264,47 @@ public final void dropZonesCorrect_multipleCorrectAnswers_decidesCorrectnessBase assertEquals(new DropZonesCorrectFactory().setLeg1(true).build(), response.getDropZonesCorrect()); } - @Test - public final void answerValidation_empty_incorrect() { - var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) - ); - question.setDetailedItemFeedback(true); - var answer = answer(); - - var response = testValidate(question, answer); - assertFalse(response.isCorrect()); - assertEquals(new Content(Constants.FEEDBACK_NO_ANSWER_PROVIDED), response.getExplanation()); - assertNull(response.getDropZonesCorrect()); - } - - @Test - public final void answerValidation_emptyNoItems_incorrect() { - var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) - ); - question.setDetailedItemFeedback(true); - var answer = new DndItemChoice(); - - var response = testValidate(question, answer); - assertFalse(response.isCorrect()); - assertEquals(new Content(Constants.FEEDBACK_NO_ANSWER_PROVIDED), response.getExplanation()); - assertNull(response.getDropZonesCorrect()); - } - - @Test - public final void answerValidation_someMissing_explainsMissingItems() { - var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) - ); + @DataPoints + public static AnswerValidationTestCase[] answerValidationTestCases = { + new AnswerValidationTestCase().setTitle("itemsNull") + .setAnswer(answer()) + .expectExplanation(Constants.FEEDBACK_NO_ANSWER_PROVIDED), + new AnswerValidationTestCase().setTitle("itemsEmpty") + .setAnswer(new DndItemChoice()) + .expectExplanation(Constants.FEEDBACK_NO_ANSWER_PROVIDED), + new AnswerValidationTestCase().setTitle("itemsNotEnough") + .setQuestion(correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2")))) + .setAnswer(answer(choose(item_3cm, "leg_1"))) + .expectExplanation("You did not provide a valid answer; it does not contain an item for each gap.") + .expectDropZonesCorrect(feedback -> feedback.setLeg1(true)), + new AnswerValidationTestCase().setTitle("itemsTooMany") + .setQuestion(correct(answer(choose(item_3cm, "leg_1")))) + .setAnswer(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"))) + .expectExplanation("You did not provide a valid answer; it contains more items than gaps.") + .expectDropZonesCorrect(feedback -> feedback.setLeg1(true)), + new AnswerValidationTestCase().setTitle("itemUnknown") + .setQuestion(correct(answer(choose(item_3cm, "leg_1")))) + .setAnswer(answer(choose(new Item("bad_id", "some_value"), "leg_1"))) + .expectExplanation(Constants.FEEDBACK_UNRECOGNISED_ITEMS), + new AnswerValidationTestCase().setTitle("itemMissingId") + .setQuestion(correct(answer(choose(item_3cm, "leg_1")))) + .setAnswer(answer(choose(new Item(null, null), "leg_1"))) + .expectExplanation(Constants.FEEDBACK_UNRECOGNISED_FORMAT), + new AnswerValidationTestCase().setTitle("itemMissingDropZoneId") + .setQuestion(correct(answer(choose(item_3cm, "leg_1")))) + .setAnswer(answer(choose(item_3cm, null))) + .expectExplanation(Constants.FEEDBACK_UNRECOGNISED_FORMAT) + }; + + @Theory + public final void testAnswerValidation(final AnswerValidationTestCase testCase) { + var question = createQuestion(testCase.question); question.setDetailedItemFeedback(true); - var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2")); - var response = testValidate(question, answer); + var response = testValidate(question, testCase.answer); assertFalse(response.isCorrect()); - assertTrue(response.getExplanation().getValue().contains("does not contain an item for each gap")); - assertEquals(new DropZonesCorrectFactory().setLeg1(true).setLeg2(true).build(), response.getDropZonesCorrect()); + assertEquals(testCase.feedback, response.getExplanation()); + assertEquals(testCase.dropZonesCorrect, response.getDropZonesCorrect()); } /* @@ -317,59 +333,8 @@ public final void answerValidation_someMissing_providesSpecificExplanationFirst( // rather than telling the user they needed to submit more items. // TODO: invalid questions that are not producible on the UI should never be marked (still return explanation) - @Test - public final void answerValidation_tooMany_explainsTooManyItems() { - var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"))) - ); - question.setDetailedItemFeedback(true); - var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); - - var response = testValidate(question, answer); - assertFalse(response.isCorrect()); - assertTrue(response.getExplanation().getValue().contains("it contains more items than gaps")); - assertEquals(new DropZonesCorrectFactory().setLeg1(true).setLeg2(true).build(), response.getDropZonesCorrect()); - } - - @Test - public final void answerValidation_unknownItems_explainsUnknownItems() { - var question = createQuestion(correct(answer(choose(item_3cm, "leg_1")))); - question.setDetailedItemFeedback(true); - var answer = answer(choose(new Item("bad_id", "some_value"), "leg_1")); - - var response = testValidate(question, answer); - assertFalse(response.isCorrect()); - assertEquals(new Content(Constants.FEEDBACK_UNRECOGNISED_ITEMS), response.getExplanation()); - assertEquals(null, response.getDropZonesCorrect()); - } - - @Test - public final void answerValidation_missingId_explainsUnrecognisedFormat() { - var question = createQuestion(correct(answer(choose(item_3cm, "leg_1")))); - question.setDetailedItemFeedback(true); - var answer = answer(choose(new Item(null, null), "leg_1")); - - var response = testValidate(question, answer); - assertFalse(response.isCorrect()); - assertEquals(new Content(Constants.FEEDBACK_UNRECOGNISED_FORMAT), response.getExplanation()); - assertNull(response.getDropZonesCorrect()); - } - - @Test - public final void answerValidation_missingDropZoneId_explainsUnrecognisedFormat() { - var question = createQuestion(correct(answer(choose(item_3cm, "leg_1")))); - question.setDetailedItemFeedback(true); - var answer = answer(choose(item_3cm, null)); - - var response = testValidate(question, answer); - assertFalse(response.isCorrect()); - assertEquals(new Content(Constants.FEEDBACK_UNRECOGNISED_FORMAT), response.getExplanation()); - assertNull(response.getDropZonesCorrect()); - } - // TODO: check when a non-existing drop zone was used? (and anything that doesn't exist in a correct answer is invalid?) - @Test public final void questionValidation_NoChoicesEmpty_ExplainsNoChoices() { var question = createQuestion(); @@ -409,6 +374,14 @@ public final void questionValidation_NoChoices_LogsThisProblem() { appender.assertMessage("Question does not have any answers. id1 src: file1"); } + // TODO: instead of wrongTypeChoices, assert that each choice has a drop zone id and id + + // TODO: exclude invalid choices from question + // - not DndItemChoice + // - choice without items + // - choice with items other than Item (maybe here: DnDItem) + // - no correct answer + private static DndValidationResponse testValidate(final IsaacDndQuestion question, final Choice choice) { return new IsaacDndValidator().validateQuestionResponse(question, choice); } @@ -504,11 +477,37 @@ public Map build() { } } - public static final Item item_3cm = item("6d3d", "3 cm"); - public static final Item item_4cm = item("6d3e", "4 cm"); - public static final Item item_5cm = item("6d3f", "5 cm"); - public static final Item item_6cm = item("6d3g", "5 cm"); - public static final Item item_12cm = item("6d3h", "12 cm"); - public static final Item item_13cm = item("6d3i", "13 cm"); + static class AnswerValidationTestCase { + public DndItemChoice question = correct( + answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) + ); + public DndItemChoice answer; + public Content feedback; + public Map dropZonesCorrect; + + public AnswerValidationTestCase setTitle(final String title) { + return this; + } + + public AnswerValidationTestCase setQuestion(final DndItemChoice question) { + this.question = question; + return this; + } + + public AnswerValidationTestCase setAnswer(final DndItemChoice answer) { + this.answer = answer; + return this; + } + + public AnswerValidationTestCase expectExplanation(final String feedback) { + this.feedback = new Content(feedback); + return this; + } + + public AnswerValidationTestCase expectDropZonesCorrect(UnaryOperator op) { + this.dropZonesCorrect = op.apply(new DropZonesCorrectFactory()).build(); + return this; + } + } } From 75959a9487b1d0cc76fffc9c920c7eb6644700c5 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 8 Dec 2025 16:32:18 +0000 Subject: [PATCH 26/65] use data provider for question validation --- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 138 +++++++++--------- 1 file changed, 68 insertions(+), 70 deletions(-) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 13a3e4fcf1..ee578c8b4e 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -37,6 +37,8 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Function; import java.util.function.UnaryOperator; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -293,85 +295,60 @@ public final void dropZonesCorrect_multipleCorrectAnswers_decidesCorrectnessBase new AnswerValidationTestCase().setTitle("itemMissingDropZoneId") .setQuestion(correct(answer(choose(item_3cm, "leg_1")))) .setAnswer(answer(choose(item_3cm, null))) - .expectExplanation(Constants.FEEDBACK_UNRECOGNISED_FORMAT) + .expectExplanation(Constants.FEEDBACK_UNRECOGNISED_FORMAT), + new AnswerValidationTestCase().setTitle("itemsNotEnough_providesSpecificExplanationFirst") + .setQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))), + incorrect(answer(choose(item_4cm, "leg_1")), new Content("Leg 1 should be less than 4 cm")) + ).setAnswer(answer(choose(item_4cm, "leg_1"))) + .expectExplanation("Leg 1 should be less than 4 cm") + .expectDropZonesCorrect(feedback -> feedback.setLeg1(false)) }; @Theory public final void testAnswerValidation(final AnswerValidationTestCase testCase) { - var question = createQuestion(testCase.question); - question.setDetailedItemFeedback(true); + testCase.question.setDetailedItemFeedback(true); + + var response = testValidate(testCase.question, testCase.answer); - var response = testValidate(question, testCase.answer); assertFalse(response.isCorrect()); assertEquals(testCase.feedback, response.getExplanation()); assertEquals(testCase.dropZonesCorrect, response.getDropZonesCorrect()); } - /* - * Test that when the user submits an answer with missing items, we first show any matching feedback - * about the incorrect answer, rather than the more generic feedback about missing items. - * - * Cloze questions don't even look at matches in that case, but I think this is better UX. - */ - @Test - public final void answerValidation_someMissing_providesSpecificExplanationFirst() { - var incorrectFeedback = new Content("Leg 1 should be less than 4 cm"); - var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))), - incorrect(answer(choose(item_4cm, "leg_1")), incorrectFeedback) - ); - question.setDetailedItemFeedback(true); - var answer = answer(choose(item_4cm, "leg_1")); - - var response = testValidate(question, answer); - assertFalse(response.isCorrect()); - assertEquals(incorrectFeedback, response.getExplanation()); - assertEquals(new DropZonesCorrectFactory().setLeg1(false).build(), response.getDropZonesCorrect()); - } - // TODO: when a partial match contains incorrect items, show feedback about this, // rather than telling the user they needed to submit more items. // TODO: invalid questions that are not producible on the UI should never be marked (still return explanation) // TODO: check when a non-existing drop zone was used? (and anything that doesn't exist in a correct answer is invalid?) - @Test - public final void questionValidation_NoChoicesEmpty_ExplainsNoChoices() { - var question = createQuestion(); - question.setDetailedItemFeedback(true); - var answer = answer(choose(item_3cm, "leg_1")); - var response = testValidate(question, answer); - assertFalse(response.isCorrect()); - assertEquals(new Content(Constants.FEEDBACK_NO_CORRECT_ANSWERS), response.getExplanation()); - assertNull(response.getDropZonesCorrect()); - } + @DataPoints + public static QuestionValidationTestCase[] questionValidationTestCases = { + new QuestionValidationTestCase().setTitle("choicesEmpty") + .setQuestion(q -> q.setChoices(List.of())) + .setAnswer(answer(choose(item_3cm, "leg_1"))) + .expectExplanation(Constants.FEEDBACK_NO_CORRECT_ANSWERS) + .expectLogMessage(q -> String.format("Question does not have any answers. %s src: %s", q.getId(), q.getCanonicalSourceFile())), + new QuestionValidationTestCase().setTitle("choicesNull") + .setQuestion(q -> q.setChoices(null)) + .setAnswer(answer(choose(item_3cm, "leg_1"))) + .expectExplanation(Constants.FEEDBACK_NO_CORRECT_ANSWERS) + .expectLogMessage(q -> String.format("Question does not have any answers. %s src: %s", q.getId(), q.getCanonicalSourceFile())), + }; - @Test - public final void questionValidation_NoChoicesNull_ExplainsNoChoices() { - var question = createQuestion(); - question.setChoices(null); - question.setDetailedItemFeedback(true); - var answer = answer(choose(item_3cm, "leg_1")); + @Theory + public final void testQuestionValidation(final QuestionValidationTestCase testCase) { + testCase.question.setDetailedItemFeedback(true); - var response = testValidate(question, answer); + var response = testValidate(testCase.question, testCase.answer); assertFalse(response.isCorrect()); - assertEquals(new Content(Constants.FEEDBACK_NO_CORRECT_ANSWERS), response.getExplanation()); - assertNull(response.getDropZonesCorrect()); - } - - @Test - public final void questionValidation_NoChoices_LogsThisProblem() { - var question = createQuestion(); - question.setDetailedItemFeedback(true); - question.setId("id1"); - question.setCanonicalSourceFile("file1"); - var answer = answer(choose(item_3cm, "leg_1")); - - var appender = testValidateWithLogs(question, answer); + assertEquals(testCase.feedback, response.getExplanation()); + assertEquals(testCase.dropZonesCorrect, response.getDropZonesCorrect()); + var appender = testValidateWithLogs(testCase.question, testCase.answer); appender.assertLevel(Level.ERROR); - appender.assertMessage("Question does not have any answers. id1 src: file1"); + appender.assertMessage(testCase.loggedMessage); } // TODO: instead of wrongTypeChoices, assert that each choice has a drop zone id and id @@ -477,37 +454,58 @@ public Map build() { } } - static class AnswerValidationTestCase { - public DndItemChoice question = correct( - answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) + static class TestCase> { + public IsaacDndQuestion question = createQuestion( + correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ); public DndItemChoice answer; public Content feedback; public Map dropZonesCorrect; + public String loggedMessage; - public AnswerValidationTestCase setTitle(final String title) { - return this; + public T setTitle(final String title) { + return self(); } - public AnswerValidationTestCase setQuestion(final DndItemChoice question) { + public T setQuestion(final DndItemChoice... choices) { + this.question = createQuestion(choices); + return self(); + } + + public T setQuestion(final Consumer op) { + var question = createQuestion(); + op.accept(question); this.question = question; - return this; + return self(); } - public AnswerValidationTestCase setAnswer(final DndItemChoice answer) { + public T setAnswer(final DndItemChoice answer) { this.answer = answer; - return this; + return self(); } - public AnswerValidationTestCase expectExplanation(final String feedback) { + public T expectExplanation(final String feedback) { this.feedback = new Content(feedback); - return this; + return self(); } - public AnswerValidationTestCase expectDropZonesCorrect(UnaryOperator op) { + public T expectDropZonesCorrect(UnaryOperator op) { this.dropZonesCorrect = op.apply(new DropZonesCorrectFactory()).build(); - return this; + return self(); + } + + public T expectLogMessage(Function op) { + this.loggedMessage = op.apply(question); + return self(); + } + + private T self() { + return (T) this; } } + + public static class AnswerValidationTestCase extends TestCase {} + + public static class QuestionValidationTestCase extends TestCase {} } From 0fd1b57471bb2193f5be417cb066497320db0849 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 8 Dec 2025 17:18:28 +0000 Subject: [PATCH 27/65] refactor, remove unnecessary answer calls --- .../cl/dtg/isaac/api/QuestionFacadeIT.java | 16 ++-- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 79 ++++++++++--------- 2 files changed, 50 insertions(+), 45 deletions(-) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java index 5ec63453ca..f728fe2602 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -78,7 +78,7 @@ class DndQuestion { }, delimiter = ';') public void badRequest_ErrorReturned(final String answerStr, final String emsg, final String estate) throws Exception { var dndQuestion = persist(createQuestion( - correct(answer(choose(item_3cm, "leg_1"))) + correct(choose(item_3cm, "leg_1")) )); var response = subject().client().post(url(dndQuestion.getId()), answerStr); response.assertError(emsg, estate); @@ -91,7 +91,7 @@ public void badRequest_ErrorReturned(final String answerStr, final String emsg, }, delimiter = ';') public void badRequest_IncorrectReturnedWithExplanation(final String answerStr, final String emsg) throws Exception { var dndQuestion = persist(createQuestion( - correct(answer(choose(item_3cm, "leg_1"))) + correct(choose(item_3cm, "leg_1")) )); var response = subject().client().post(url(dndQuestion.getId()), answerStr).readEntityAsJson(); assertFalse(response.getBoolean("correct")); @@ -105,7 +105,7 @@ public void badRequest_IncorrectReturnedWithExplanation(final String answerStr, }, delimiter = ';') public void emptyAnswer_IncorrectReturned(final String answerStr) throws Exception { var dndQuestion = persist(createQuestion( - correct(answer(choose(item_3cm, "leg_1"))) + correct(choose(item_3cm, "leg_1")) )); var response = subject().client().post(url(dndQuestion.getId()), answerStr).readEntityAsJson(); assertFalse(response.getBoolean("correct")); @@ -123,7 +123,7 @@ public void emptyAnswer_IncorrectReturned(final String answerStr) throws Excepti }, delimiter = ';') public void correctAnswer_CorrectReturned(final String answerStr) throws Exception { var dndQuestion = persist(createQuestion( - correct(answer(choose(item_3cm, "leg_1"))) + correct(choose(item_3cm, "leg_1")) )); var response = subject().client().post(url(dndQuestion.getId()), answerStr).readEntityAsJson(); @@ -137,7 +137,7 @@ public void correctAnswer_CorrectReturned(final String answerStr) throws Excepti @Test public void wrongAnswer_IncorrectReturned() throws Exception { var dndQuestion = persist(createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) )); var answer = answer(choose(item_3cm, "leg_2"), choose(item_4cm, "hypothenuse"), choose(item_5cm, "leg_1")); @@ -151,8 +151,8 @@ public void wrongAnswer_IncorrectReturned() throws Exception { public void answerWithMatchingExplanation_ExplanationReturned() throws Exception { var explanation = new Content("That's right!"); var dndQuestion = persist(createQuestion(correct( - answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")), - explanation + explanation, + choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse") ))); var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); @@ -165,7 +165,7 @@ public void answerWithMatchingExplanation_ExplanationReturned() throws Exception @Test public void detailedItemFeedbackRequested_DropZonesCorrectReturned() throws Exception { var dndQuestion = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) ); dndQuestion.setDetailedItemFeedback(true); persist(dndQuestion); diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index ee578c8b4e..c4377714dd 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -63,7 +63,7 @@ public class IsaacDndValidatorTest { @Test public final void correctness_singleCorrectMatch_CorrectResponseShouldBeReturned() { var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) ); var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); @@ -75,7 +75,7 @@ public final void correctness_singleCorrectMatch_CorrectResponseShouldBeReturned @Test public final void correctness_singleIncorrectMatch_IncorrectResponseShouldBeReturned() { var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) ); var answer = answer(choose(item_4cm, "leg_1"), choose(item_5cm, "leg_2"), choose(item_3cm, "hypothenuse")); @@ -87,7 +87,7 @@ public final void correctness_singleIncorrectMatch_IncorrectResponseShouldBeRetu @Test public final void correctness_partialMatchForCorrect_IncorrectResponseShouldBeReturned() { var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) ); var answer = answer(choose(item_4cm, "leg_2"), choose(item_5cm, "leg_1"), choose(item_3cm, "hypothenuse")); @@ -99,7 +99,7 @@ public final void correctness_partialMatchForCorrect_IncorrectResponseShouldBeRe @Test public final void correctness_moreSpecificIncorrectMatchOverridesCorrect_IncorrectResponseShouldBeReturned() { var question = createQuestion( - correct(answer(choose(item_5cm, "hypothenuse"))), + correct(choose(item_5cm, "hypothenuse")), incorrect(answer(choose(item_3cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ); var answer = answer(choose(item_3cm, "leg_2"), choose(item_5cm, "hypothenuse")); @@ -121,8 +121,8 @@ public final void correctness_moreSpecificIncorrectMatchOverridesCorrect_Incorre public final void explanation_exactMatchIncorrect_shouldReturnMatching() { var hypothenuseMustBeLargest = new Content("The hypothenuse must be the longest side of a right triangle"); var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))), - incorrect(answer(choose(item_3cm, "hypothenuse")), hypothenuseMustBeLargest) + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")), + incorrect(hypothenuseMustBeLargest, choose(item_3cm, "hypothenuse")) ); var answer = answer(choose(item_3cm, "hypothenuse")); @@ -136,10 +136,10 @@ public final void explanation_exactMatchIncorrect_shouldReturnMatching() { public final void explanation_exactMatchCorrect_shouldReturnMatching() { var correctFeedback = new Content("That's how it's done! Observe that the hypothenuse is always the longest" + " side of a right triangle"); - var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")), - correctFeedback) - ); + var question = createQuestion(correct( + correctFeedback, + choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse") + )); var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); var response = testValidate(question, answer); @@ -152,8 +152,8 @@ public final void explanation_exactMatchCorrect_shouldReturnMatching() { public final void explanation_exactMatchIncorrectDefault_shouldReturnDefault() { var defaultFeedback = new Content("Isaac can't help you."); var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))), - incorrect(answer(choose(item_4cm, "hypothenuse"))) + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")), + incorrect(choose(item_4cm, "hypothenuse")) ); question.setDefaultFeedback(defaultFeedback); var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_4cm, "hypothenuse")); @@ -167,7 +167,7 @@ public final void explanation_exactMatchIncorrectDefault_shouldReturnDefault() { @Test public final void explanation_defaultIncorrect_shouldReturnDefault() { var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) ); var defaultFeedback = new Content("Isaac cannot help you."); question.setDefaultFeedback(defaultFeedback); @@ -182,7 +182,7 @@ public final void explanation_defaultIncorrect_shouldReturnDefault() { @Test public final void explanation_defaultCorrect_shouldReturnNone() { var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) ); var defaultFeedback = new Content("Isaac cannot help you."); question.setDefaultFeedback(defaultFeedback); @@ -197,7 +197,7 @@ public final void explanation_defaultCorrect_shouldReturnNone() { @Test public final void dropZonesCorrect_incorrectNotRequested_shouldReturnNull() { var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) ); question.setDetailedItemFeedback(false); var answer = answer(choose(item_3cm, "leg_2"), choose(item_4cm, "leg_1"), choose(item_5cm, "hypothenuse")); @@ -210,7 +210,7 @@ public final void dropZonesCorrect_incorrectNotRequested_shouldReturnNull() { @Test public final void dropZonesCorrect_correctNotRequested_shouldReturnNull() { var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) ); question.setDetailedItemFeedback(false); var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); @@ -223,7 +223,7 @@ public final void dropZonesCorrect_correctNotRequested_shouldReturnNull() { @Test public final void dropZonesCorrect_allCorrect_shouldReturnAllCorrect() { var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) ); question.setDetailedItemFeedback(true); var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); @@ -239,7 +239,7 @@ public final void dropZonesCorrect_allCorrect_shouldReturnAllCorrect() { @Test public final void dropZonesCorrect_someIncorrect_shouldReturnWhetherCorrect() { var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) ); question.setDetailedItemFeedback(true); var answer = answer(choose(item_6cm, "leg_1"), choose(item_5cm, "leg_2"), choose(item_5cm, "hypothenuse")); @@ -255,8 +255,8 @@ public final void dropZonesCorrect_someIncorrect_shouldReturnWhetherCorrect() { @Test public final void dropZonesCorrect_multipleCorrectAnswers_decidesCorrectnessBasedOnClosestOne() { var question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))), - correct(answer(choose(item_5cm, "leg_1"), choose(item_12cm, "leg_2"), choose(item_13cm, "hypothenuse"))) + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")), + correct(choose(item_5cm, "leg_1"), choose(item_12cm, "leg_2"), choose(item_13cm, "hypothenuse")) ); question.setDetailedItemFeedback(true); var answer = answer(choose(item_5cm, "leg_1")); @@ -275,34 +275,35 @@ public final void dropZonesCorrect_multipleCorrectAnswers_decidesCorrectnessBase .setAnswer(new DndItemChoice()) .expectExplanation(Constants.FEEDBACK_NO_ANSWER_PROVIDED), new AnswerValidationTestCase().setTitle("itemsNotEnough") - .setQuestion(correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2")))) + .setQuestion(correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"))) .setAnswer(answer(choose(item_3cm, "leg_1"))) .expectExplanation("You did not provide a valid answer; it does not contain an item for each gap.") .expectDropZonesCorrect(feedback -> feedback.setLeg1(true)), new AnswerValidationTestCase().setTitle("itemsTooMany") - .setQuestion(correct(answer(choose(item_3cm, "leg_1")))) + .setQuestion(correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"))) .expectExplanation("You did not provide a valid answer; it contains more items than gaps.") .expectDropZonesCorrect(feedback -> feedback.setLeg1(true)), - new AnswerValidationTestCase().setTitle("itemUnknown") - .setQuestion(correct(answer(choose(item_3cm, "leg_1")))) + new AnswerValidationTestCase().setTitle("itemNotOnQuestion") + .setQuestion(correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(new Item("bad_id", "some_value"), "leg_1"))) .expectExplanation(Constants.FEEDBACK_UNRECOGNISED_ITEMS), new AnswerValidationTestCase().setTitle("itemMissingId") - .setQuestion(correct(answer(choose(item_3cm, "leg_1")))) + .setQuestion(correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(new Item(null, null), "leg_1"))) .expectExplanation(Constants.FEEDBACK_UNRECOGNISED_FORMAT), new AnswerValidationTestCase().setTitle("itemMissingDropZoneId") - .setQuestion(correct(answer(choose(item_3cm, "leg_1")))) + .setQuestion(correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(item_3cm, null))) .expectExplanation(Constants.FEEDBACK_UNRECOGNISED_FORMAT), new AnswerValidationTestCase().setTitle("itemsNotEnough_providesSpecificExplanationFirst") .setQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))), - incorrect(answer(choose(item_4cm, "leg_1")), new Content("Leg 1 should be less than 4 cm")) + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")), + incorrect(new Content("Leg 1 should be less than 4 cm"), choose(item_4cm, "leg_1")) ).setAnswer(answer(choose(item_4cm, "leg_1"))) .expectExplanation("Leg 1 should be less than 4 cm") .expectDropZonesCorrect(feedback -> feedback.setLeg1(false)) + // TODO: if drop zone does not exist in question }; @Theory @@ -327,14 +328,16 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) public static QuestionValidationTestCase[] questionValidationTestCases = { new QuestionValidationTestCase().setTitle("choicesEmpty") .setQuestion(q -> q.setChoices(List.of())) - .setAnswer(answer(choose(item_3cm, "leg_1"))) .expectExplanation(Constants.FEEDBACK_NO_CORRECT_ANSWERS) .expectLogMessage(q -> String.format("Question does not have any answers. %s src: %s", q.getId(), q.getCanonicalSourceFile())), new QuestionValidationTestCase().setTitle("choicesNull") .setQuestion(q -> q.setChoices(null)) - .setAnswer(answer(choose(item_3cm, "leg_1"))) .expectExplanation(Constants.FEEDBACK_NO_CORRECT_ANSWERS) .expectLogMessage(q -> String.format("Question does not have any answers. %s src: %s", q.getId(), q.getCanonicalSourceFile())), +// new QuestionValidationTestCase().setTitle("answer not dndItem") +// .setQuestion() +// .expectExplanation("This question contains invalid answers.") +// .expectLogMessage(q -> String.format("Expected DndItem in question (%s), instead found Item!", q.getId())), }; @Theory @@ -402,24 +405,26 @@ public static IsaacDndQuestion createQuestion(final DndItemChoice... answers) { return question; } - public static DndItemChoice correct(final DndItemChoice choice) { + public static DndItemChoice correct(final DndItem... list) { + var choice = answer(list); choice.setCorrect(true); return choice; } - public static DndItemChoice correct(final DndItemChoice choice, ContentBase explanation) { - choice.setCorrect(true); + public static DndItemChoice correct(final ContentBase explanation, final DndItem... list) { + var choice = correct(list); choice.setExplanation(explanation); return choice; } - public static DndItemChoice incorrect(final DndItemChoice choice) { + public static DndItemChoice incorrect(final DndItem... list) { + var choice = answer(list); choice.setCorrect(false); return choice; } - public static DndItemChoice incorrect(final DndItemChoice choice, ContentBase explanation) { - choice.setCorrect(false); + public static DndItemChoice incorrect(final ContentBase explanation, final DndItem... list) { + var choice = incorrect(list); choice.setExplanation(explanation); return choice; } @@ -458,7 +463,7 @@ static class TestCase> { public IsaacDndQuestion question = createQuestion( correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ); - public DndItemChoice answer; + public DndItemChoice answer= answer(); public Content feedback; public Map dropZonesCorrect; public String loggedMessage; From d72c7bfd74013b5d4b1bf0a13c1ed30b4155ac44 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 8 Dec 2025 17:42:29 +0000 Subject: [PATCH 28/65] check when a question contains invalid items --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 55 +++++++------------ .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 16 +++--- 2 files changed, 28 insertions(+), 43 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 85df826b0b..2736e2ac23 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -40,11 +40,11 @@ * Validator that only provides functionality to validate Drag and drop questions. */ public class IsaacDndValidator implements IValidator { + private static final Logger log = LoggerFactory.getLogger(IsaacDndValidator.class); + @Override public final DndValidationResponse validateQuestionResponse(final Question question, final Choice answer) { var errorResponse = validateSyntax(question, answer); - errorResponse.ifPresent(r -> getConfiguredLogger((IsaacDndQuestion) question).log(r.getExplanation().getValue())); - return errorResponse.orElseGet( () -> validateMarks((IsaacDndQuestion) question, (DndItemChoice) answer) ); @@ -102,7 +102,23 @@ private Optional validateSyntax(final Question question, } return new ValidatorRules() - .add(Constants.FEEDBACK_NO_CORRECT_ANSWERS, (q, a) -> q.getChoices() == null || q.getChoices().isEmpty()) + // question + .add(Constants.FEEDBACK_NO_CORRECT_ANSWERS, (q, a) -> { + var res = q.getChoices() == null || q.getChoices().isEmpty(); + if (res) { + log.error(String.format("Question does not have any answers. %s src: %s", question.getId(), q.getCanonicalSourceFile())); + } + return res; + }) + .add("This question contains invalid answers.", (q, a) -> !q.getChoices().stream().allMatch(c -> { + var res = DndItemChoice.class.equals(c.getClass()); + if (!res) { + log.error(String.format("Expected DndItem in question (%s), instead found %s!", question.getId(), c.getClass())); + } + return res; + })) + + // answer .add(Constants.FEEDBACK_NO_ANSWER_PROVIDED, (q, a) -> a.getItems() == null || a.getItems().isEmpty()) .add(Constants.FEEDBACK_UNRECOGNISED_ITEMS, (q, a) -> a.getItems().stream().anyMatch(answerItem -> !q.getItems().contains(answerItem))) @@ -112,14 +128,6 @@ private Optional validateSyntax(final Question question, .check((IsaacDndQuestion) question, (DndItemChoice) answer); } - private LoggerRules getConfiguredLogger(final IsaacDndQuestion question) { - return new LoggerRules(question) - .add(Constants.FEEDBACK_NO_CORRECT_ANSWERS, (q) -> { - var file = question.getCanonicalSourceFile(); - LoggerRules.log.error("Question does not have any answers. " + q.getId() + " src: " + file); - }); - } - private static class ValidatorRules { private final LinkedHashMap> rules = new LinkedHashMap<>(); @@ -135,29 +143,4 @@ public Optional check(final IsaacDndQuestion q, final Dnd .findFirst(); } } - - private static class LoggerRules { - private static final Logger log = LoggerFactory.getLogger(IsaacDndValidator.class); - private final LinkedHashMap> rules = new LinkedHashMap<>(); - private final IsaacDndQuestion question; - - public LoggerRules(final IsaacDndQuestion question) { - this.question = question; - } - - public LoggerRules add(final String key, final Consumer rule) { - rules.put(key, rule); - return this; - } - - public void log(final String event) { - rules.entrySet().stream() - .filter(e -> e.getKey().equals(event)) - .findFirst() - .map(e -> { - e.getValue().accept(question); - return true; - }); - } - } } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index c4377714dd..b097b10734 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -326,18 +326,18 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) @DataPoints public static QuestionValidationTestCase[] questionValidationTestCases = { - new QuestionValidationTestCase().setTitle("choicesEmpty") + new QuestionValidationTestCase().setTitle("answers empty") .setQuestion(q -> q.setChoices(List.of())) .expectExplanation(Constants.FEEDBACK_NO_CORRECT_ANSWERS) .expectLogMessage(q -> String.format("Question does not have any answers. %s src: %s", q.getId(), q.getCanonicalSourceFile())), - new QuestionValidationTestCase().setTitle("choicesNull") + new QuestionValidationTestCase().setTitle("answers null") .setQuestion(q -> q.setChoices(null)) .expectExplanation(Constants.FEEDBACK_NO_CORRECT_ANSWERS) .expectLogMessage(q -> String.format("Question does not have any answers. %s src: %s", q.getId(), q.getCanonicalSourceFile())), -// new QuestionValidationTestCase().setTitle("answer not dndItem") -// .setQuestion() -// .expectExplanation("This question contains invalid answers.") -// .expectLogMessage(q -> String.format("Expected DndItem in question (%s), instead found Item!", q.getId())), + new QuestionValidationTestCase().setTitle("answer not for a DnD question") + .setQuestion(q -> q.setChoices(List.of(new DndItemChoiceEx()))) + .expectExplanation("This question contains invalid answers.") + .expectLogMessage(q -> String.format("Expected DndItem in question (%s), instead found class uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidatorTest$DndItemChoiceEx!", q.getId())), }; @Theory @@ -346,7 +346,7 @@ public final void testQuestionValidation(final QuestionValidationTestCase testCa var response = testValidate(testCase.question, testCase.answer); assertFalse(response.isCorrect()); - assertEquals(testCase.feedback, response.getExplanation()); + assertEquals(testCase.feedback.getValue(), response.getExplanation().getValue()); assertEquals(testCase.dropZonesCorrect, response.getDropZonesCorrect()); var appender = testValidateWithLogs(testCase.question, testCase.answer); @@ -512,5 +512,7 @@ private T self() { public static class AnswerValidationTestCase extends TestCase {} public static class QuestionValidationTestCase extends TestCase {} + + public static class DndItemChoiceEx extends DndItemChoice {} } From 6da01ae18bfd66fbc180ecccd52b8b5275012e38 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 8 Dec 2025 18:15:32 +0000 Subject: [PATCH 29/65] ensure question does not contain empty answer --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 34 +++++++++++-------- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 13 +++---- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 2736e2ac23..52df184930 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -33,7 +33,6 @@ import java.util.Objects; import java.util.Optional; import java.util.function.BiPredicate; -import java.util.function.Consumer; import java.util.stream.Collectors; /** @@ -103,20 +102,18 @@ private Optional validateSyntax(final Question question, return new ValidatorRules() // question - .add(Constants.FEEDBACK_NO_CORRECT_ANSWERS, (q, a) -> { - var res = q.getChoices() == null || q.getChoices().isEmpty(); - if (res) { - log.error(String.format("Question does not have any answers. %s src: %s", question.getId(), q.getCanonicalSourceFile())); - } - return res; - }) - .add("This question contains invalid answers.", (q, a) -> !q.getChoices().stream().allMatch(c -> { - var res = DndItemChoice.class.equals(c.getClass()); - if (!res) { - log.error(String.format("Expected DndItem in question (%s), instead found %s!", question.getId(), c.getClass())); - } - return res; - })) + .add(Constants.FEEDBACK_NO_CORRECT_ANSWERS, (q, a) -> logged( + q.getChoices() == null || q.getChoices().isEmpty(), + "Question does not have any answers. %s src: %s", q.getId(), q.getCanonicalSourceFile() + )) + .add("This question contains invalid answers.", (q, a) -> q.getChoices().stream().anyMatch(c -> logged( + !DndItemChoice.class.equals(c.getClass()), + "Expected DndItem in question (%s), instead found %s!", q.getId(), c.getClass() + ))) + .add("This question contains an empty answer.", (q, a) -> q.getChoices().stream().anyMatch(c -> logged( + c.getItems() == null, + "Expected list of DndItems, but none found in choice for question id (%s)", q.getId() + ))) // answer .add(Constants.FEEDBACK_NO_ANSWER_PROVIDED, (q, a) -> a.getItems() == null || a.getItems().isEmpty()) @@ -128,6 +125,13 @@ private Optional validateSyntax(final Question question, .check((IsaacDndQuestion) question, (DndItemChoice) answer); } + private boolean logged(final boolean result, final String message, final Object... args) { + if (result) { + log.error(String.format(message, args)); + } + return result; + } + private static class ValidatorRules { private final LinkedHashMap> rules = new LinkedHashMap<>(); diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index b097b10734..05ffa1f723 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -51,8 +51,6 @@ @RunWith(Theories.class) @SuppressWarnings("checkstyle:MissingJavadocType") public class IsaacDndValidatorTest { - - public static final Item item_3cm = item("6d3d", "3 cm"); public static final Item item_4cm = item("6d3e", "4 cm"); public static final Item item_5cm = item("6d3f", "5 cm"); @@ -313,7 +311,7 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) var response = testValidate(testCase.question, testCase.answer); assertFalse(response.isCorrect()); - assertEquals(testCase.feedback, response.getExplanation()); + assertEquals(testCase.feedback.getValue(), response.getExplanation().getValue()); assertEquals(testCase.dropZonesCorrect, response.getDropZonesCorrect()); } @@ -338,6 +336,10 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) .setQuestion(q -> q.setChoices(List.of(new DndItemChoiceEx()))) .expectExplanation("This question contains invalid answers.") .expectLogMessage(q -> String.format("Expected DndItem in question (%s), instead found class uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidatorTest$DndItemChoiceEx!", q.getId())), + new QuestionValidationTestCase().setTitle("answer with no items") + .setQuestion(q -> q.setChoices(List.of(new DndItemChoice()))) + .expectExplanation("This question contains an empty answer.") + .expectLogMessage(q -> String.format("Expected list of DndItems, but none found in choice for question id (%s)", q.getId())) }; @Theory @@ -357,7 +359,6 @@ public final void testQuestionValidation(final QuestionValidationTestCase testCa // TODO: instead of wrongTypeChoices, assert that each choice has a drop zone id and id // TODO: exclude invalid choices from question - // - not DndItemChoice // - choice without items // - choice with items other than Item (maybe here: DnDItem) // - no correct answer @@ -461,9 +462,9 @@ public Map build() { static class TestCase> { public IsaacDndQuestion question = createQuestion( - correct(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) ); - public DndItemChoice answer= answer(); + public DndItemChoice answer = answer(); public Content feedback; public Map dropZonesCorrect; public String loggedMessage; From 13250ec3f55a2898263bc2a8e41694ab28a17749 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Tue, 9 Dec 2025 10:00:49 +0000 Subject: [PATCH 30/65] test empty answer (as opposed to null) --- .../uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java | 2 +- .../uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 52df184930..4344cf5754 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -111,7 +111,7 @@ private Optional validateSyntax(final Question question, "Expected DndItem in question (%s), instead found %s!", q.getId(), c.getClass() ))) .add("This question contains an empty answer.", (q, a) -> q.getChoices().stream().anyMatch(c -> logged( - c.getItems() == null, + c.getItems() == null || c.getItems().isEmpty(), "Expected list of DndItems, but none found in choice for question id (%s)", q.getId() ))) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 05ffa1f723..b4ce9dc9d3 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -336,7 +336,11 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) .setQuestion(q -> q.setChoices(List.of(new DndItemChoiceEx()))) .expectExplanation("This question contains invalid answers.") .expectLogMessage(q -> String.format("Expected DndItem in question (%s), instead found class uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidatorTest$DndItemChoiceEx!", q.getId())), - new QuestionValidationTestCase().setTitle("answer with no items") + new QuestionValidationTestCase().setTitle("answer with empty items") + .setQuestion(correct()) + .expectExplanation("This question contains an empty answer.") + .expectLogMessage(q -> String.format("Expected list of DndItems, but none found in choice for question id (%s)", q.getId())), + new QuestionValidationTestCase().setTitle("answer with null items") .setQuestion(q -> q.setChoices(List.of(new DndItemChoice()))) .expectExplanation("This question contains an empty answer.") .expectLogMessage(q -> String.format("Expected list of DndItems, but none found in choice for question id (%s)", q.getId())) @@ -359,7 +363,6 @@ public final void testQuestionValidation(final QuestionValidationTestCase testCa // TODO: instead of wrongTypeChoices, assert that each choice has a drop zone id and id // TODO: exclude invalid choices from question - // - choice without items // - choice with items other than Item (maybe here: DnDItem) // - no correct answer From 583314d62753c7cca635d7fd037d6fafbfecc411 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Tue, 9 Dec 2025 14:15:05 +0000 Subject: [PATCH 31/65] ensure question contains dnd answers --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 31 ++++++++++++++----- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 16 ++++++++-- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 4344cf5754..a8fe0c25ee 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -23,12 +23,13 @@ import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; import uk.ac.cam.cl.dtg.isaac.dos.content.Content; +import uk.ac.cam.cl.dtg.isaac.dos.content.DndItem; import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; import uk.ac.cam.cl.dtg.isaac.dos.content.Question; +import java.util.ArrayList; import java.util.Comparator; import java.util.Date; -import java.util.LinkedHashMap; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -106,13 +107,17 @@ private Optional validateSyntax(final Question question, q.getChoices() == null || q.getChoices().isEmpty(), "Question does not have any answers. %s src: %s", q.getId(), q.getCanonicalSourceFile() )) - .add("This question contains invalid answers.", (q, a) -> q.getChoices().stream().anyMatch(c -> logged( + .add("This question contains at least one invalid answer.", (q, a) -> q.getChoices().stream().anyMatch(c -> logged( !DndItemChoice.class.equals(c.getClass()), "Expected DndItem in question (%s), instead found %s!", q.getId(), c.getClass() ))) .add("This question contains an empty answer.", (q, a) -> q.getChoices().stream().anyMatch(c -> logged( c.getItems() == null || c.getItems().isEmpty(), - "Expected list of DndItems, but none found in choice for question id (%s)", q.getId() + "Expected list of DndItems, but none found in choice for question id (%s)!", q.getId() + ))) + .add("This question contains at least one invalid answer.", (q, a) -> q.getChoices().stream().anyMatch(c -> logged( + c.getItems().stream().anyMatch(i -> i.getClass() != DndItem.class), + "Expected list of DndItems, but something else found in choice for question id (%s)!", q.getId(), c.getClass() ))) // answer @@ -133,18 +138,28 @@ private boolean logged(final boolean result, final String message, final Object. } private static class ValidatorRules { - private final LinkedHashMap> rules = new LinkedHashMap<>(); + private final List rules = new ArrayList<>(); public ValidatorRules add(final String key, final BiPredicate rule) { - rules.put(key, rule); + rules.add(new Rule(key, rule)); return this; } public Optional check(final IsaacDndQuestion q, final DndItemChoice a) { - return rules.entrySet().stream() - .filter(e -> e.getValue().test(q, a)) - .map(e -> new DndValidationResponse(q.getId(), a, false, null, new Content(e.getKey()), new Date())) + return rules.stream() + .filter(r -> r.predicate.test(q, a)) + .map(e -> new DndValidationResponse(q.getId(), a, false, null, new Content(e.message), new Date())) .findFirst(); } + + private static class Rule { + public final String message; + public final BiPredicate predicate; + + public Rule(final String message, final BiPredicate predicate) { + this.message = message; + this.predicate = predicate; + } + } } } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index b4ce9dc9d3..0828ccad57 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -334,16 +334,20 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) .expectLogMessage(q -> String.format("Question does not have any answers. %s src: %s", q.getId(), q.getCanonicalSourceFile())), new QuestionValidationTestCase().setTitle("answer not for a DnD question") .setQuestion(q -> q.setChoices(List.of(new DndItemChoiceEx()))) - .expectExplanation("This question contains invalid answers.") + .expectExplanation("This question contains at least one invalid answer.") .expectLogMessage(q -> String.format("Expected DndItem in question (%s), instead found class uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidatorTest$DndItemChoiceEx!", q.getId())), new QuestionValidationTestCase().setTitle("answer with empty items") .setQuestion(correct()) .expectExplanation("This question contains an empty answer.") - .expectLogMessage(q -> String.format("Expected list of DndItems, but none found in choice for question id (%s)", q.getId())), + .expectLogMessage(q -> String.format("Expected list of DndItems, but none found in choice for question id (%s)!", q.getId())), new QuestionValidationTestCase().setTitle("answer with null items") .setQuestion(q -> q.setChoices(List.of(new DndItemChoice()))) .expectExplanation("This question contains an empty answer.") - .expectLogMessage(q -> String.format("Expected list of DndItems, but none found in choice for question id (%s)", q.getId())) + .expectLogMessage(q -> String.format("Expected list of DndItems, but none found in choice for question id (%s)!", q.getId())), + new QuestionValidationTestCase().setTitle("answer with non-dnd items") + .setQuestion(correct(new DndItemEx("id", "value", "dropZoneId"))) + .expectExplanation("This question contains at least one invalid answer.") + .expectLogMessage(q -> String.format("Expected list of DndItems, but something else found in choice for question id (%s)!", q.getId())) }; @Theory @@ -518,5 +522,11 @@ public static class AnswerValidationTestCase extends TestCase {} public static class DndItemChoiceEx extends DndItemChoice {} + + public static class DndItemEx extends DndItem { + public DndItemEx(String id, String value, String dropZoneId) { + super(id, value, dropZoneId); + } + } } From 0a27a69975eee3282b1c29915ddf73c564286896 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Tue, 9 Dec 2025 14:40:38 +0000 Subject: [PATCH 32/65] check that each question has at least 1 correct answer --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 4 ++++ .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 20 +++++++++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index a8fe0c25ee..79dc1f5535 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -107,6 +107,10 @@ private Optional validateSyntax(final Question question, q.getChoices() == null || q.getChoices().isEmpty(), "Question does not have any answers. %s src: %s", q.getId(), q.getCanonicalSourceFile() )) + .add(Constants.FEEDBACK_NO_CORRECT_ANSWERS, (q, a) -> logged( + q.getChoices().stream().noneMatch(Choice::isCorrect), + "Question does not have any correct answers. %s src: %s", q.getId(), q.getCanonicalSourceFile() + )) .add("This question contains at least one invalid answer.", (q, a) -> q.getChoices().stream().anyMatch(c -> logged( !DndItemChoice.class.equals(c.getClass()), "Expected DndItem in question (%s), instead found %s!", q.getId(), c.getClass() diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 0828ccad57..d056a0c22c 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -36,10 +36,13 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.UUID; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.UnaryOperator; +import java.util.stream.Collectors; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -332,8 +335,12 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) .setQuestion(q -> q.setChoices(null)) .expectExplanation(Constants.FEEDBACK_NO_CORRECT_ANSWERS) .expectLogMessage(q -> String.format("Question does not have any answers. %s src: %s", q.getId(), q.getCanonicalSourceFile())), + new QuestionValidationTestCase().setTitle("only incorrect answers") + .setQuestion(incorrect(choose(item_3cm, "leg_1"))) + .expectExplanation(Constants.FEEDBACK_NO_CORRECT_ANSWERS) + .expectLogMessage(q -> String.format("Question does not have any correct answers. %s src: %s", q.getId(), q.getCanonicalSourceFile())), new QuestionValidationTestCase().setTitle("answer not for a DnD question") - .setQuestion(q -> q.setChoices(List.of(new DndItemChoiceEx()))) + .setQuestion(q -> q.setChoices(List.of(new DndItemChoiceEx("correct")))) .expectExplanation("This question contains at least one invalid answer.") .expectLogMessage(q -> String.format("Expected DndItem in question (%s), instead found class uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidatorTest$DndItemChoiceEx!", q.getId())), new QuestionValidationTestCase().setTitle("answer with empty items") @@ -341,7 +348,7 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) .expectExplanation("This question contains an empty answer.") .expectLogMessage(q -> String.format("Expected list of DndItems, but none found in choice for question id (%s)!", q.getId())), new QuestionValidationTestCase().setTitle("answer with null items") - .setQuestion(q -> q.setChoices(List.of(new DndItemChoice()))) + .setQuestion(q -> q.setChoices(Stream.of(new DndItemChoice()).peek(c -> c.setCorrect(true)).collect(Collectors.toList()))) .expectExplanation("This question contains an empty answer.") .expectLogMessage(q -> String.format("Expected list of DndItems, but none found in choice for question id (%s)!", q.getId())), new QuestionValidationTestCase().setTitle("answer with non-dnd items") @@ -366,9 +373,6 @@ public final void testQuestionValidation(final QuestionValidationTestCase testCa // TODO: instead of wrongTypeChoices, assert that each choice has a drop zone id and id - // TODO: exclude invalid choices from question - // - choice with items other than Item (maybe here: DnDItem) - // - no correct answer private static DndValidationResponse testValidate(final IsaacDndQuestion question, final Choice choice) { return new IsaacDndValidator().validateQuestionResponse(question, choice); @@ -521,7 +525,11 @@ public static class AnswerValidationTestCase extends TestCase {} - public static class DndItemChoiceEx extends DndItemChoice {} + public static class DndItemChoiceEx extends DndItemChoice { + public DndItemChoiceEx(String correct) { + this.correct = correct.equals("correct"); + } + } public static class DndItemEx extends DndItem { public DndItemEx(String id, String value, String dropZoneId) { From 51877cbfa58957e6e29b3a71349d067dcbf03e29 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Tue, 9 Dec 2025 14:59:35 +0000 Subject: [PATCH 33/65] check that answers default to incorrect --- .../uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index d056a0c22c..6689bf758d 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -339,6 +339,10 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) .setQuestion(incorrect(choose(item_3cm, "leg_1"))) .expectExplanation(Constants.FEEDBACK_NO_CORRECT_ANSWERS) .expectLogMessage(q -> String.format("Question does not have any correct answers. %s src: %s", q.getId(), q.getCanonicalSourceFile())), + new QuestionValidationTestCase().setTitle("answers without explicit correctness are treated as incorrect") + .setQuestion(answer(choose(item_3cm, "leg_1"))) + .expectExplanation(Constants.FEEDBACK_NO_CORRECT_ANSWERS) + .expectLogMessage(q -> String.format("Question does not have any correct answers. %s src: %s", q.getId(), q.getCanonicalSourceFile())), new QuestionValidationTestCase().setTitle("answer not for a DnD question") .setQuestion(q -> q.setChoices(List.of(new DndItemChoiceEx("correct")))) .expectExplanation("This question contains at least one invalid answer.") From 46b30d802923ed53c03ce8febb1448869ac51e55 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Tue, 9 Dec 2025 16:22:54 +0000 Subject: [PATCH 34/65] on question, check answers have valid id, dropZoneId --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 15 ++-- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 69 +++++++++++-------- 2 files changed, 49 insertions(+), 35 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 79dc1f5535..f2412f2b7a 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -44,13 +44,12 @@ public class IsaacDndValidator implements IValidator { @Override public final DndValidationResponse validateQuestionResponse(final Question question, final Choice answer) { - var errorResponse = validateSyntax(question, answer); - return errorResponse.orElseGet( - () -> validateMarks((IsaacDndQuestion) question, (DndItemChoice) answer) + return validate(question, answer).orElseGet( + () -> mark((IsaacDndQuestion) question, (DndItemChoice) answer) ); } - private DndValidationResponse validateMarks(final IsaacDndQuestion question, final DndItemChoice answer) { + private DndValidationResponse mark(final IsaacDndQuestion question, final DndItemChoice answer) { List sortedAnswers = question.getChoices().stream() .sorted(Comparator.comparingInt(c -> c.countPartialMatchesIn(answer))) .collect(Collectors.toList()); @@ -87,7 +86,7 @@ private Content explain( }); } - private Optional validateSyntax(final Question question, final Choice answer) { + private Optional validate(final Question question, final Choice answer) { Objects.requireNonNull(question); Objects.requireNonNull(answer); @@ -121,7 +120,11 @@ private Optional validateSyntax(final Question question, ))) .add("This question contains at least one invalid answer.", (q, a) -> q.getChoices().stream().anyMatch(c -> logged( c.getItems().stream().anyMatch(i -> i.getClass() != DndItem.class), - "Expected list of DndItems, but something else found in choice for question id (%s)!", q.getId(), c.getClass() + "Expected list of DndItems, but something else found in choice for question id (%s)!", q.getId() + ))) + .add("This question contains at least one answer in an unrecognised format.", (q, a) -> q.getChoices().stream().anyMatch(c -> logged( + c.getItems().stream().anyMatch(i -> i.getId() == null || i.getDropZoneId() == null || Objects.equals(i.getId(), "") || Objects.equals(i.getDropZoneId(), "")), + "Found item with missing id or drop zone id in answer for question id (%s)!", q.getId() ))) // answer diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 6689bf758d..e4adb4b9e6 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -36,10 +36,10 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.UUID; import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.Supplier; import java.util.function.UnaryOperator; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -325,40 +325,47 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) // TODO: check when a non-existing drop zone was used? (and anything that doesn't exist in a correct answer is invalid?) + static Supplier itemUnrecognisedFormatCase = () -> new QuestionValidationTestCase() + .expectExplanation("This question contains at least one answer in an unrecognised format.") + .expectLogMessage(q -> String.format("Found item with missing id or drop zone id in answer for question id (%s)!", q.getId())); + + static Supplier noAnswersTestCase = () -> new QuestionValidationTestCase() + .expectExplanation(Constants.FEEDBACK_NO_CORRECT_ANSWERS) + .expectLogMessage(q -> String.format("Question does not have any answers. %s src: %s", q.getId(), q.getCanonicalSourceFile())); + + static Supplier noCorrectAnswersTestCase = () -> noAnswersTestCase.get() + .expectLogMessage(q -> String.format("Question does not have any correct answers. %s src: %s", q.getId(), q.getCanonicalSourceFile())); + + static Supplier emptyItemsTestCase = () -> new QuestionValidationTestCase() + .expectExplanation("This question contains an empty answer.") + .expectLogMessage(q -> String.format("Expected list of DndItems, but none found in choice for question id (%s)!", q.getId())); + @DataPoints public static QuestionValidationTestCase[] questionValidationTestCases = { - new QuestionValidationTestCase().setTitle("answers empty") - .setQuestion(q -> q.setChoices(List.of())) - .expectExplanation(Constants.FEEDBACK_NO_CORRECT_ANSWERS) - .expectLogMessage(q -> String.format("Question does not have any answers. %s src: %s", q.getId(), q.getCanonicalSourceFile())), - new QuestionValidationTestCase().setTitle("answers null") - .setQuestion(q -> q.setChoices(null)) - .expectExplanation(Constants.FEEDBACK_NO_CORRECT_ANSWERS) - .expectLogMessage(q -> String.format("Question does not have any answers. %s src: %s", q.getId(), q.getCanonicalSourceFile())), - new QuestionValidationTestCase().setTitle("only incorrect answers") - .setQuestion(incorrect(choose(item_3cm, "leg_1"))) - .expectExplanation(Constants.FEEDBACK_NO_CORRECT_ANSWERS) - .expectLogMessage(q -> String.format("Question does not have any correct answers. %s src: %s", q.getId(), q.getCanonicalSourceFile())), - new QuestionValidationTestCase().setTitle("answers without explicit correctness are treated as incorrect") - .setQuestion(answer(choose(item_3cm, "leg_1"))) - .expectExplanation(Constants.FEEDBACK_NO_CORRECT_ANSWERS) - .expectLogMessage(q -> String.format("Question does not have any correct answers. %s src: %s", q.getId(), q.getCanonicalSourceFile())), + noAnswersTestCase.get().setTitle("answers empty").setQuestion(q -> q.setChoices(List.of())), + noAnswersTestCase.get().setTitle("answers null").setQuestion(q -> q.setChoices(null)), + noCorrectAnswersTestCase.get().setTitle("only incorrect answers").setQuestion(incorrect(choose(item_3cm, "leg_1"))), + noCorrectAnswersTestCase.get().setTitle("answers without explicit correctness are treated as incorrect") + .setQuestion(answer(choose(item_3cm, "leg_1"))), new QuestionValidationTestCase().setTitle("answer not for a DnD question") .setQuestion(q -> q.setChoices(List.of(new DndItemChoiceEx("correct")))) .expectExplanation("This question contains at least one invalid answer.") .expectLogMessage(q -> String.format("Expected DndItem in question (%s), instead found class uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidatorTest$DndItemChoiceEx!", q.getId())), - new QuestionValidationTestCase().setTitle("answer with empty items") - .setQuestion(correct()) - .expectExplanation("This question contains an empty answer.") - .expectLogMessage(q -> String.format("Expected list of DndItems, but none found in choice for question id (%s)!", q.getId())), - new QuestionValidationTestCase().setTitle("answer with null items") - .setQuestion(q -> q.setChoices(Stream.of(new DndItemChoice()).peek(c -> c.setCorrect(true)).collect(Collectors.toList()))) - .expectExplanation("This question contains an empty answer.") - .expectLogMessage(q -> String.format("Expected list of DndItems, but none found in choice for question id (%s)!", q.getId())), + emptyItemsTestCase.get().setTitle("answer with empty items").setQuestion(correct()), + emptyItemsTestCase.get().setTitle("answer with null items") + .setQuestion(q -> q.setChoices(Stream.of(new DndItemChoice()).peek(c -> c.setCorrect(true)).collect(Collectors.toList()))), new QuestionValidationTestCase().setTitle("answer with non-dnd items") .setQuestion(correct(new DndItemEx("id", "value", "dropZoneId"))) .expectExplanation("This question contains at least one invalid answer.") - .expectLogMessage(q -> String.format("Expected list of DndItems, but something else found in choice for question id (%s)!", q.getId())) + .expectLogMessage(q -> String.format("Expected list of DndItems, but something else found in choice for question id (%s)!", q.getId())), + itemUnrecognisedFormatCase.get().setTitle("answer with missing item_id") + .setQuestion(correct(new DndItem(null, "value", "dropZoneId"))), + itemUnrecognisedFormatCase.get().setTitle("answer with empty item_id") + .setQuestion(correct(new DndItem("", "value", "dropZoneId"))), + itemUnrecognisedFormatCase.get().setTitle("answer with missing dropZoneId") + .setQuestion(correct(new DndItem("item_id", "value", null))), + itemUnrecognisedFormatCase.get().setTitle("answer with empty dropZoneId") + .setQuestion(correct(new DndItem("item_id", "value", ""))) }; @Theory @@ -483,6 +490,7 @@ static class TestCase> { public Content feedback; public Map dropZonesCorrect; public String loggedMessage; + private Function logMessageOp; public T setTitle(final String title) { return self(); @@ -510,17 +518,20 @@ public T expectExplanation(final String feedback) { return self(); } - public T expectDropZonesCorrect(UnaryOperator op) { + public T expectDropZonesCorrect(final UnaryOperator op) { this.dropZonesCorrect = op.apply(new DropZonesCorrectFactory()).build(); return self(); } - public T expectLogMessage(Function op) { - this.loggedMessage = op.apply(question); + public T expectLogMessage(final Function op) { + this.logMessageOp = op; return self(); } private T self() { + if (this.logMessageOp != null) { + this.loggedMessage = logMessageOp.apply(this.question); + } return (T) this; } } From 805b95d4a0d86fb8bcc6d91ba6b0f3235d769bc6 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Thu, 11 Dec 2025 10:27:07 +0000 Subject: [PATCH 35/65] merge main --- .../cl/dtg/isaac/dos/IsaacDndQuestion.java | 86 ++----------------- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 8 +- 2 files changed, 11 insertions(+), 83 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java index a1e2ebe84e..2e28f3a9db 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java @@ -17,20 +17,14 @@ import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; import uk.ac.cam.cl.dtg.isaac.dos.content.DTOMapping; -import uk.ac.cam.cl.dtg.isaac.dos.content.DndItem; import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; -import uk.ac.cam.cl.dtg.isaac.dos.content.Item; import uk.ac.cam.cl.dtg.isaac.dos.content.JsonContentType; -import uk.ac.cam.cl.dtg.isaac.dos.content.Question; -import uk.ac.cam.cl.dtg.isaac.dto.IsaacClozeQuestionDTO; import uk.ac.cam.cl.dtg.isaac.dto.IsaacDndQuestionDTO; -import uk.ac.cam.cl.dtg.isaac.dto.IsaacItemQuestionDTO; -import uk.ac.cam.cl.dtg.isaac.quiz.IsaacClozeValidator; import uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidator; -import uk.ac.cam.cl.dtg.isaac.quiz.IsaacItemQuestionValidator; import uk.ac.cam.cl.dtg.isaac.quiz.ValidatesWith; import java.util.List; +import java.util.stream.Collectors; /** @@ -40,41 +34,16 @@ @DTOMapping(IsaacDndQuestionDTO.class) @JsonContentType("isaacDndQuestion") @ValidatesWith(IsaacDndValidator.class) -public class IsaacDndQuestion extends Question { - private List items; - private Boolean randomiseItems; - - public List getItems() { - return items; - } - - public void setItems(final List items) { - this.items = items; - } - - /** - * Gets whether to randomiseItems. - * - * @return randomiseItems - */ - public Boolean getRandomiseItems() { - return randomiseItems; - } - - /** - * Sets the randomiseItems. - * - * @param randomiseItems - * the randomiseItems to set - */ - public void setRandomiseItems(final Boolean randomiseItems) { - this.randomiseItems = randomiseItems; - } +public class IsaacDndQuestion extends IsaacItemQuestion { private Boolean withReplacement; // Detailed feedback option not needed in the client so not in DTO: private Boolean detailedItemFeedback; + public List getDndItemChoices() { + return this.choices.stream().map(c -> (DndItemChoice) c).collect(Collectors.toList()); + } + public Boolean getWithReplacement() { return withReplacement; } @@ -90,45 +59,4 @@ public Boolean getDetailedItemFeedback() { public void setDetailedItemFeedback(final Boolean detailedItemFeedback) { this.detailedItemFeedback = detailedItemFeedback; } - - protected List choices; - protected Boolean randomiseChoices; - - /** - * Gets the choices. - * - * @return the choices - */ - public final List getChoices() { - return choices; - } - - /** - * Sets the choices. - * - * @param choices - * the choices to set - */ - public final void setChoices(final List choices) { - this.choices = choices; - } - - /** - * Gets the whether to randomlyOrderUnits. - * - * @return randomiseChoices - */ - public Boolean getRandomiseChoices() { - return randomiseChoices; - } - - /** - * Sets the randomiseChoices. - * - * @param randomiseChoices - * the randomiseChoices to set - */ - public void setRandomiseChoices(final Boolean randomiseChoices) { - this.randomiseChoices = randomiseChoices; - } -} +} \ No newline at end of file diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index f2412f2b7a..dc61d22e73 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -50,7 +50,7 @@ public final DndValidationResponse validateQuestionResponse(final Question quest } private DndValidationResponse mark(final IsaacDndQuestion question, final DndItemChoice answer) { - List sortedAnswers = question.getChoices().stream() + List sortedAnswers = question.getDndItemChoices().stream() .sorted(Comparator.comparingInt(c -> c.countPartialMatchesIn(answer))) .collect(Collectors.toList()); @@ -114,15 +114,15 @@ private Optional validate(final Question question, final !DndItemChoice.class.equals(c.getClass()), "Expected DndItem in question (%s), instead found %s!", q.getId(), c.getClass() ))) - .add("This question contains an empty answer.", (q, a) -> q.getChoices().stream().anyMatch(c -> logged( + .add("This question contains an empty answer.", (q, a) -> q.getDndItemChoices().stream().anyMatch(c -> logged( c.getItems() == null || c.getItems().isEmpty(), "Expected list of DndItems, but none found in choice for question id (%s)!", q.getId() ))) - .add("This question contains at least one invalid answer.", (q, a) -> q.getChoices().stream().anyMatch(c -> logged( + .add("This question contains at least one invalid answer.", (q, a) -> q.getDndItemChoices().stream().anyMatch(c -> logged( c.getItems().stream().anyMatch(i -> i.getClass() != DndItem.class), "Expected list of DndItems, but something else found in choice for question id (%s)!", q.getId() ))) - .add("This question contains at least one answer in an unrecognised format.", (q, a) -> q.getChoices().stream().anyMatch(c -> logged( + .add("This question contains at least one answer in an unrecognised format.", (q, a) -> q.getDndItemChoices().stream().anyMatch(c -> logged( c.getItems().stream().anyMatch(i -> i.getId() == null || i.getDropZoneId() == null || Objects.equals(i.getId(), "") || Objects.equals(i.getDropZoneId(), "")), "Found item with missing id or drop zone id in answer for question id (%s)!", q.getId() ))) From 7e84f8bb5edd5fd4abab9419ff35ad217bbcc665 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Thu, 11 Dec 2025 13:22:42 +0000 Subject: [PATCH 36/65] data provider for correctness tests --- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 87 ++++++++----------- 1 file changed, 35 insertions(+), 52 deletions(-) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index e4adb4b9e6..5562b1252b 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -50,6 +50,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import org.apache.logging.log4j.core.Logger; +import uk.ac.cam.cl.dtg.isaac.dos.content.ParsonsChoice; @RunWith(Theories.class) @SuppressWarnings("checkstyle:MissingJavadocType") @@ -61,53 +62,33 @@ public class IsaacDndValidatorTest { public static final Item item_12cm = item("6d3h", "12 cm"); public static final Item item_13cm = item("6d3i", "13 cm"); - @Test - public final void correctness_singleCorrectMatch_CorrectResponseShouldBeReturned() { - var question = createQuestion( - correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) - ); - var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); - - var response = testValidate(question, answer); - - assertTrue(response.isCorrect()); - } - - @Test - public final void correctness_singleIncorrectMatch_IncorrectResponseShouldBeReturned() { - var question = createQuestion( - correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) - ); - var answer = answer(choose(item_4cm, "leg_1"), choose(item_5cm, "leg_2"), choose(item_3cm, "hypothenuse")); - - var response = testValidate(question, answer); - - assertFalse(response.isCorrect()); - } - - @Test - public final void correctness_partialMatchForCorrect_IncorrectResponseShouldBeReturned() { - var question = createQuestion( - correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) - ); - var answer = answer(choose(item_4cm, "leg_2"), choose(item_5cm, "leg_1"), choose(item_3cm, "hypothenuse")); - - var response = testValidate(question, answer); - - assertFalse(response.isCorrect()); - } - - @Test - public final void correctness_moreSpecificIncorrectMatchOverridesCorrect_IncorrectResponseShouldBeReturned() { - var question = createQuestion( - correct(choose(item_5cm, "hypothenuse")), - incorrect(answer(choose(item_3cm, "leg_2"), choose(item_5cm, "hypothenuse"))) - ); - var answer = answer(choose(item_3cm, "leg_2"), choose(item_5cm, "hypothenuse")); + @DataPoints + public static CorrectnessTestCase[] correctnessTestCases = { + new CorrectnessTestCase().setTitle("singleCorrectMatch_Correct") + .setQuestion(correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + .setAnswer(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + .expectCorrect(true), + new CorrectnessTestCase().setTitle("singleIncorrectMatch_Incorrect") + .setQuestion(correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + .setAnswer(answer(choose(item_4cm, "leg_1"), choose(item_5cm, "leg_2"), choose(item_3cm, "hypothenuse"))) + .expectCorrect(false), + new CorrectnessTestCase().setTitle("partialMatchForCorrect_Incorrect") + .setQuestion(correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + .setAnswer(answer(choose(item_5cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_3cm, "hypothenuse"))) + .expectCorrect(false), + new CorrectnessTestCase().setTitle("moreSpecificIncorrectMatchOverridesCorrect_Incorrect") + .setQuestion( + correct(choose(item_5cm, "hypothenuse")), + incorrect(answer(choose(item_3cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + ).setAnswer(answer(choose(item_3cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + .expectCorrect(false) + }; - var response = testValidate(question, answer); + @Theory + public final void testCorrectness(final CorrectnessTestCase testCase) { + var response = testValidate(testCase.question, testCase.answer); - assertFalse(response.isCorrect()); + assertEquals(testCase.correct, response.isCorrect()); } // Test that subset match answers return an appropriate explanation @@ -348,9 +329,9 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) noCorrectAnswersTestCase.get().setTitle("answers without explicit correctness are treated as incorrect") .setQuestion(answer(choose(item_3cm, "leg_1"))), new QuestionValidationTestCase().setTitle("answer not for a DnD question") - .setQuestion(q -> q.setChoices(List.of(new DndItemChoiceEx("correct")))) + .setQuestion(q -> q.setChoices(List.of(new ParsonsChoice() {{correct = true; setItems(List.of(new Item("", ""))); }}))) .expectExplanation("This question contains at least one invalid answer.") - .expectLogMessage(q -> String.format("Expected DndItem in question (%s), instead found class uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidatorTest$DndItemChoiceEx!", q.getId())), + .expectLogMessage(q -> String.format("Expected DndItem in question (%s), instead found class uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidatorTest$1!", q.getId())), emptyItemsTestCase.get().setTitle("answer with empty items").setQuestion(correct()), emptyItemsTestCase.get().setTitle("answer with null items") .setQuestion(q -> q.setChoices(Stream.of(new DndItemChoice()).peek(c -> c.setCorrect(true)).collect(Collectors.toList()))), @@ -490,6 +471,7 @@ static class TestCase> { public Content feedback; public Map dropZonesCorrect; public String loggedMessage; + public boolean correct = false; private Function logMessageOp; public T setTitle(final String title) { @@ -513,6 +495,11 @@ public T setAnswer(final DndItemChoice answer) { return self(); } + public T expectCorrect(final boolean correct) { + this.correct = correct; + return self(); + } + public T expectExplanation(final String feedback) { this.feedback = new Content(feedback); return self(); @@ -540,11 +527,7 @@ public static class AnswerValidationTestCase extends TestCase {} - public static class DndItemChoiceEx extends DndItemChoice { - public DndItemChoiceEx(String correct) { - this.correct = correct.equals("correct"); - } - } + public static class CorrectnessTestCase extends TestCase {} public static class DndItemEx extends DndItem { public DndItemEx(String id, String value, String dropZoneId) { From 1d46c3110972db6ec53d7f8e5fcc84a4038b557e Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Thu, 11 Dec 2025 15:00:29 +0000 Subject: [PATCH 37/65] data provider for explanation tests --- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 122 +++++++----------- 1 file changed, 49 insertions(+), 73 deletions(-) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 5562b1252b..fae0034857 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -99,81 +99,44 @@ public final void testCorrectness(final CorrectnessTestCase testCase) { // - should return all? // - should return just one, but predictably? // - @Test - public final void explanation_exactMatchIncorrect_shouldReturnMatching() { - var hypothenuseMustBeLargest = new Content("The hypothenuse must be the longest side of a right triangle"); - var question = createQuestion( - correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")), - incorrect(hypothenuseMustBeLargest, choose(item_3cm, "hypothenuse")) - ); - var answer = answer(choose(item_3cm, "hypothenuse")); - - var response = testValidate(question, answer); - - assertFalse(response.isCorrect()); - assertEquals(response.getExplanation(), hypothenuseMustBeLargest); - } - - @Test - public final void explanation_exactMatchCorrect_shouldReturnMatching() { - var correctFeedback = new Content("That's how it's done! Observe that the hypothenuse is always the longest" - + " side of a right triangle"); - var question = createQuestion(correct( - correctFeedback, - choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse") - )); - var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); - - var response = testValidate(question, answer); - - assertTrue(response.isCorrect()); - assertEquals(response.getExplanation(), correctFeedback); - } - - @Test - public final void explanation_exactMatchIncorrectDefault_shouldReturnDefault() { - var defaultFeedback = new Content("Isaac can't help you."); - var question = createQuestion( - correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")), - incorrect(choose(item_4cm, "hypothenuse")) - ); - question.setDefaultFeedback(defaultFeedback); - var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_4cm, "hypothenuse")); - - var response = testValidate(question, answer); - - assertFalse(response.isCorrect()); - assertEquals(response.getExplanation(), defaultFeedback); - } - - @Test - public final void explanation_defaultIncorrect_shouldReturnDefault() { - var question = createQuestion( - correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) - ); - var defaultFeedback = new Content("Isaac cannot help you."); - question.setDefaultFeedback(defaultFeedback); - var answer = answer(choose(item_4cm, "leg_1"), choose(item_5cm, "leg_2"), choose(item_3cm, "hypothenuse")); - - var response = testValidate(question, answer); - assertFalse(response.isCorrect()); - assertEquals(response.getExplanation(), defaultFeedback); - } + @DataPoints + public static ExplanationTestCase[] explanationTestCases = { + new ExplanationTestCase().setTitle("exactMatchIncorrect_shouldReturnMatching") + .setQuestion( + correct(choose(item_3cm, "leg_1")), + incorrect(ExplanationTestCase.testFeedback, choose(item_4cm, "leg_1")) + ).setAnswer(answer(choose(item_4cm, "leg_1"))) + .expectCorrect(false), + new ExplanationTestCase().setTitle("exactMatchCorrect_shouldReturnMatching") + .setQuestion(correct(ExplanationTestCase.testFeedback, choose(item_3cm, "leg_1"))) + .setAnswer(answer(choose(item_3cm, "leg_1"))) + .expectCorrect(true), + new ExplanationTestCase().setTitle("exactMatchIncorrect_shouldReturnDefaultFeedbackForQuestion") + .setQuestion(correct(choose(item_3cm, "leg_1")), incorrect(choose(item_4cm, "leg_1"))) + .tapQuestion(q -> q.setDefaultFeedback(ExplanationTestCase.testFeedback)) + .setAnswer(answer(choose(item_4cm, "leg_1"))) + .expectCorrect(false), + new ExplanationTestCase().setTitle("unMatchedIncorrect_shouldReturnDefaultFeedbackForQuestion") + .setQuestion(correct(choose(item_3cm, "leg_1"))) + .tapQuestion(q -> q.setDefaultFeedback(ExplanationTestCase.testFeedback)) + .setAnswer(answer(choose(item_4cm, "leg_1"))) + .expectCorrect(false), + new ExplanationTestCase().setTitle("defaultCorrect_shouldReturnNone") + .setQuestion(correct(choose(item_3cm, "leg_1"))) + .setAnswer(answer(choose(item_3cm, "leg_1"))) + .expectNoExplanation() + .expectCorrect(true) + // todo expect null explanation on incorrect answer? (check cloze behaviour) + }; - @Test - public final void explanation_defaultCorrect_shouldReturnNone() { - var question = createQuestion( - correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) - ); - var defaultFeedback = new Content("Isaac cannot help you."); - question.setDefaultFeedback(defaultFeedback); - var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); - var response = testValidate(question, answer); + @Theory + public final void testExplanation(ExplanationTestCase testCase) { + var response = testValidate(testCase.question, testCase.answer); - assertTrue(response.isCorrect()); - assertNull(response.getExplanation()); + assertEquals(response.isCorrect(), testCase.correct); + assertEquals(response.getExplanation(), testCase.feedback); } @Test @@ -464,11 +427,13 @@ public Map build() { } static class TestCase> { + public static Content testFeedback = new Content("some test feedback"); + public IsaacDndQuestion question = createQuestion( correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) ); public DndItemChoice answer = answer(); - public Content feedback; + public Content feedback = testFeedback; public Map dropZonesCorrect; public String loggedMessage; public boolean correct = false; @@ -483,6 +448,11 @@ public T setQuestion(final DndItemChoice... choices) { return self(); } + public T tapQuestion(final Consumer op) { + op.accept(question); + return self(); + } + public T setQuestion(final Consumer op) { var question = createQuestion(); op.accept(question); @@ -505,6 +475,11 @@ public T expectExplanation(final String feedback) { return self(); } + public T expectNoExplanation() { + this.feedback = null; + return self(); + } + public T expectDropZonesCorrect(final UnaryOperator op) { this.dropZonesCorrect = op.apply(new DropZonesCorrectFactory()).build(); return self(); @@ -529,10 +504,11 @@ public static class QuestionValidationTestCase extends TestCase {} + public static class ExplanationTestCase extends TestCase {} + public static class DndItemEx extends DndItem { public DndItemEx(String id, String value, String dropZoneId) { super(id, value, dropZoneId); } } } - From da039f4e0deb9b058704a6535bb95cd483b7770e Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Thu, 11 Dec 2025 15:26:11 +0000 Subject: [PATCH 38/65] data provider for dropZonesCorrect tests --- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 119 ++++++++---------- 1 file changed, 51 insertions(+), 68 deletions(-) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index fae0034857..59c07c44fe 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -139,76 +139,48 @@ public final void testExplanation(ExplanationTestCase testCase) { assertEquals(response.getExplanation(), testCase.feedback); } - @Test - public final void dropZonesCorrect_incorrectNotRequested_shouldReturnNull() { - var question = createQuestion( - correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) - ); - question.setDetailedItemFeedback(false); - var answer = answer(choose(item_3cm, "leg_2"), choose(item_4cm, "leg_1"), choose(item_5cm, "hypothenuse")); - - var response = testValidate(question, answer); - assertFalse(response.isCorrect()); - assertNull(response.getDropZonesCorrect()); - } - - @Test - public final void dropZonesCorrect_correctNotRequested_shouldReturnNull() { - var question = createQuestion( - correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) - ); - question.setDetailedItemFeedback(false); - var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); - - var response = testValidate(question, answer); - assertTrue(response.isCorrect()); - assertNull(response.getDropZonesCorrect()); - } - - @Test - public final void dropZonesCorrect_allCorrect_shouldReturnAllCorrect() { - var question = createQuestion( - correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) - ); - question.setDetailedItemFeedback(true); - var answer = answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")); - - var response = testValidate(question, answer); - assertTrue(response.isCorrect()); - assertEquals( - new DropZonesCorrectFactory().setLeg1(true).setLeg2(true).setHypothenuse(true).build(), - response.getDropZonesCorrect() - ); - } - - @Test - public final void dropZonesCorrect_someIncorrect_shouldReturnWhetherCorrect() { - var question = createQuestion( - correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) - ); - question.setDetailedItemFeedback(true); - var answer = answer(choose(item_6cm, "leg_1"), choose(item_5cm, "leg_2"), choose(item_5cm, "hypothenuse")); + static Supplier disabledItemFeedbackNoDropZones = () -> new DropZonesTestCase() + .tapQuestion(q -> q.setDetailedItemFeedback(false)) + .expectNoDropZones(); - var response = testValidate(question, answer); - assertFalse(response.isCorrect()); - assertEquals( - new DropZonesCorrectFactory().setLeg1(false).setLeg2(false).setHypothenuse(true).build(), - response.getDropZonesCorrect() - ); - } + static Supplier enabledItemFeedback = () -> new DropZonesTestCase() + .tapQuestion(q -> q.setDetailedItemFeedback(true)); - @Test - public final void dropZonesCorrect_multipleCorrectAnswers_decidesCorrectnessBasedOnClosestOne() { - var question = createQuestion( - correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")), - correct(choose(item_5cm, "leg_1"), choose(item_12cm, "leg_2"), choose(item_13cm, "hypothenuse")) - ); - question.setDetailedItemFeedback(true); - var answer = answer(choose(item_5cm, "leg_1")); + @DataPoints + public static DropZonesTestCase[] dropZonesCorrectTestCases = { + disabledItemFeedbackNoDropZones.get().setTitle("incorrectNotRequestsed_NotReturned") + .setQuestion(correct(choose(item_3cm, "leg_1"))) + .setAnswer(answer(choose(item_4cm, "leg_1"))) + .expectCorrect(false), + disabledItemFeedbackNoDropZones.get().setTitle("correctNotRequested_NotReturned") + .setQuestion(correct(choose(item_3cm, "leg_1"))) + .setAnswer(answer(choose(item_3cm, "leg_1"))) + .expectCorrect(true), + enabledItemFeedback.get().setTitle("allCorrect_ShouldReturnAllCorrect") + .setQuestion(correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + .setAnswer(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + .expectCorrect(true) + .expectDropZonesCorrect(d -> d.setLeg1(true).setLeg2(true).setHypothenuse(true)), + enabledItemFeedback.get().setTitle("someIncorrect_ShouldReturnWhetherCorrect") + .setQuestion(correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + .setAnswer(answer(choose(item_6cm, "leg_1"), choose(item_5cm, "leg_2"), choose(item_5cm, "hypothenuse"))) + .expectCorrect(false) + .expectDropZonesCorrect(d -> d.setLeg1(false).setLeg2(false).setHypothenuse(true)), + enabledItemFeedback.get().setTitle("multipleCorrectAnswers_decidesCorrectnessBasedOnClosesMatch") + .setQuestion( + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")), + correct(choose(item_5cm, "leg_1"), choose(item_12cm, "leg_2"), choose(item_13cm, "hypothenuse")) + ) + .setAnswer(answer(choose(item_5cm, "leg_1"))) + .expectCorrect(false) + .expectDropZonesCorrect(d -> d.setLeg1(true)) + }; - var response = testValidate(question, answer); - assertFalse(response.isCorrect()); - assertEquals(new DropZonesCorrectFactory().setLeg1(true).build(), response.getDropZonesCorrect()); + @Theory + public final void testDropZonesCorrect(final DropZonesTestCase testCase) { + var response = testValidate(testCase.question, testCase.answer); + assertEquals(response.isCorrect(), testCase.correct); + assertEquals(response.getDropZonesCorrect(), testCase.dropZonesCorrect); } @DataPoints @@ -438,6 +410,7 @@ static class TestCase> { public String loggedMessage; public boolean correct = false; private Function logMessageOp; + private Consumer questionOp; public T setTitle(final String title) { return self(); @@ -449,7 +422,7 @@ public T setQuestion(final DndItemChoice... choices) { } public T tapQuestion(final Consumer op) { - op.accept(question); + this.questionOp = op; return self(); } @@ -485,6 +458,11 @@ public T expectDropZonesCorrect(final UnaryOperator op) return self(); } + public T expectNoDropZones() { + this.dropZonesCorrect = null; + return self(); + } + public T expectLogMessage(final Function op) { this.logMessageOp = op; return self(); @@ -494,6 +472,9 @@ private T self() { if (this.logMessageOp != null) { this.loggedMessage = logMessageOp.apply(this.question); } + if (this.questionOp != null) { + questionOp.accept(this.question); + } return (T) this; } } @@ -506,6 +487,8 @@ public static class CorrectnessTestCase extends TestCase {} public static class ExplanationTestCase extends TestCase {} + public static class DropZonesTestCase extends TestCase {} + public static class DndItemEx extends DndItem { public DndItemEx(String id, String value, String dropZoneId) { super(id, value, dropZoneId); From f35da1e8dd5a314142664335cd3f45d8ed051df7 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Thu, 11 Dec 2025 17:25:05 +0000 Subject: [PATCH 39/65] add integration test for invalid question --- .../cl/dtg/isaac/dos/content/ItemChoice.java | 1 - .../cl/dtg/isaac/api/QuestionFacadeIT.java | 36 +++++++++++++++++++ .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 28 +++++---------- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/ItemChoice.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/ItemChoice.java index 9d554a9288..0bc3345703 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/ItemChoice.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/ItemChoice.java @@ -26,7 +26,6 @@ @DTOMapping(ItemChoiceDTO.class) @JsonContentType("itemChoice") public class ItemChoice extends Choice { - private Boolean allowSubsetMatch; private List items; diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java index f728fe2602..433b7b9a88 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -1,5 +1,6 @@ package uk.ac.cam.cl.dtg.isaac.api; import com.google.inject.Injector; +import org.json.JSONArray; import org.json.JSONObject; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -68,6 +69,30 @@ public void rightAnswer() throws Exception { @Nested class DndQuestion { + @Test + public void invalidQuestion() throws Exception { + var question = persistJSON("i1", new JSONObject() + .put("type", "isaacDndQuestion") + .put("choices", new JSONArray().put( + new JSONObject() + .put("type", "dndChoice") + .put("correct", true) + .put("items", new JSONArray().put( + new JSONObject().put("dropZoneId", "dzid") // bad because missing id + )) + )) + ); + var answer = "{\"type\": \"dndChoice\"}"; + + var response = subject().client().post(url(question.getString("id")), answer).readEntityAsJson(); + + assertFalse(response.getBoolean("correct")); + assertEquals( + "This question contains at least one answer in an unrecognised format.", + response.getJSONObject("explanation").getString("value") + ); + } + @ParameterizedTest @CsvSource(value = { "{};Unable to map response to a Choice;404", @@ -192,6 +217,17 @@ private IsaacDndQuestion persist(final IsaacDndQuestion question) throws Excepti return question; } + private JSONObject persistJSON(final String id, final JSONObject questionJSON) throws Exception { + questionJSON.put("id", id); + elasticSearchProvider.bulkIndexWithIDs( + "6c2ba42c5c83d8f31b3b385b3a9f9400a12807c9", + "content", + List.of(immutableEntry(id, questionJSON.toString())) + ); + return questionJSON; + } + + private TestServer subject() throws Exception { Injector testInjector = createNiceMock(Injector.class); expect(testInjector.getInstance(IsaacStringMatchValidator.class)).andReturn(stringMatchValidator).anyTimes(); diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 59c07c44fe..75de5188f5 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -87,7 +87,6 @@ public class IsaacDndValidatorTest { @Theory public final void testCorrectness(final CorrectnessTestCase testCase) { var response = testValidate(testCase.question, testCase.answer); - assertEquals(testCase.correct, response.isCorrect()); } @@ -134,7 +133,6 @@ public final void testCorrectness(final CorrectnessTestCase testCase) { @Theory public final void testExplanation(ExplanationTestCase testCase) { var response = testValidate(testCase.question, testCase.answer); - assertEquals(response.isCorrect(), testCase.correct); assertEquals(response.getExplanation(), testCase.feedback); } @@ -237,8 +235,6 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) // TODO: when a partial match contains incorrect items, show feedback about this, // rather than telling the user they needed to submit more items. - // TODO: invalid questions that are not producible on the UI should never be marked (still return explanation) - // TODO: check when a non-existing drop zone was used? (and anything that doesn't exist in a correct answer is invalid?) static Supplier itemUnrecognisedFormatCase = () -> new QuestionValidationTestCase() @@ -260,7 +256,8 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) public static QuestionValidationTestCase[] questionValidationTestCases = { noAnswersTestCase.get().setTitle("answers empty").setQuestion(q -> q.setChoices(List.of())), noAnswersTestCase.get().setTitle("answers null").setQuestion(q -> q.setChoices(null)), - noCorrectAnswersTestCase.get().setTitle("only incorrect answers").setQuestion(incorrect(choose(item_3cm, "leg_1"))), + noCorrectAnswersTestCase.get().setTitle("only incorrect answers") + .setQuestion(incorrect(choose(item_3cm, "leg_1"))), noCorrectAnswersTestCase.get().setTitle("answers without explicit correctness are treated as incorrect") .setQuestion(answer(choose(item_3cm, "leg_1"))), new QuestionValidationTestCase().setTitle("answer not for a DnD question") @@ -298,11 +295,8 @@ public final void testQuestionValidation(final QuestionValidationTestCase testCa appender.assertMessage(testCase.loggedMessage); } - // TODO: instead of wrongTypeChoices, assert that each choice has a drop zone id and id - - private static DndValidationResponse testValidate(final IsaacDndQuestion question, final Choice choice) { - return new IsaacDndValidator().validateQuestionResponse(question, choice); + return new IsaacDndValidator().validateQuestionResponse(question, choice); } private static TestAppender testValidateWithLogs(final IsaacDndQuestion question, final Choice choice) { @@ -319,7 +313,6 @@ private static TestAppender testValidateWithLogs(final IsaacDndQuestion question } } - @SuppressWarnings("checkstyle:MissingJavadocType") public static DndItemChoice answer(final DndItem... list) { var c = new DndItemChoice(); c.setItems(List.of(list)); @@ -327,14 +320,12 @@ public static DndItemChoice answer(final DndItem... list) { return c; } - @SuppressWarnings("checkstyle:MissingJavadocType") public static DndItem choose(final Item item, final String dropZoneId) { var value = new DndItem(item.getId(), item.getValue(), dropZoneId); value.setType("dndItem"); return value; } - @SuppressWarnings("checkstyle:MissingJavadocType") public static IsaacDndQuestion createQuestion(final DndItemChoice... answers) { var question = new IsaacDndQuestion(); question.setId(UUID.randomUUID().toString()); @@ -368,7 +359,6 @@ public static DndItemChoice incorrect(final ContentBase explanation, final DndIt return choice; } - @SuppressWarnings("checkstyle:MissingJavadocType") public static Item item(final String id, final String value) { Item item = new Item(id, value); item.setType("item"); @@ -421,11 +411,6 @@ public T setQuestion(final DndItemChoice... choices) { return self(); } - public T tapQuestion(final Consumer op) { - this.questionOp = op; - return self(); - } - public T setQuestion(final Consumer op) { var question = createQuestion(); op.accept(question); @@ -433,6 +418,11 @@ public T setQuestion(final Consumer op) { return self(); } + public T tapQuestion(final Consumer op) { + this.questionOp = op; + return self(); + } + public T setAnswer(final DndItemChoice answer) { this.answer = answer; return self(); @@ -490,7 +480,7 @@ public static class ExplanationTestCase extends TestCase {} public static class DropZonesTestCase extends TestCase {} public static class DndItemEx extends DndItem { - public DndItemEx(String id, String value, String dropZoneId) { + public DndItemEx(final String id, final String value, final String dropZoneId) { super(id, value, dropZoneId); } } From cdc1a9393450e3ee1a1d16522007cd3faadf033d Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Thu, 11 Dec 2025 17:40:52 +0000 Subject: [PATCH 40/65] test that items is not empty --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 4 ++++ .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 19 ++++++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index dc61d22e73..4445bb63e9 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -126,6 +126,10 @@ private Optional validate(final Question question, final c.getItems().stream().anyMatch(i -> i.getId() == null || i.getDropZoneId() == null || Objects.equals(i.getId(), "") || Objects.equals(i.getDropZoneId(), "")), "Found item with missing id or drop zone id in answer for question id (%s)!", q.getId() ))) + .add("This question is missing items", (q, a) -> logged( + q.getItems() == null || q.getItems().isEmpty(), + "Expected items in question (%s), but didn't find any!", q.getId() + )) // answer .add(Constants.FEEDBACK_NO_ANSWER_PROVIDED, (q, a) -> a.getItems() == null || a.getItems().isEmpty()) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 75de5188f5..4fa08738c0 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -17,7 +17,6 @@ import org.apache.logging.log4j.Level; import org.apache.logging.log4j.LogManager; -import org.junit.Test; import org.junit.experimental.theories.DataPoints; import org.junit.experimental.theories.Theories; import org.junit.experimental.theories.Theory; @@ -46,8 +45,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; import org.apache.logging.log4j.core.Logger; import uk.ac.cam.cl.dtg.isaac.dos.content.ParsonsChoice; @@ -248,10 +245,14 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) static Supplier noCorrectAnswersTestCase = () -> noAnswersTestCase.get() .expectLogMessage(q -> String.format("Question does not have any correct answers. %s src: %s", q.getId(), q.getCanonicalSourceFile())); - static Supplier emptyItemsTestCase = () -> new QuestionValidationTestCase() + static Supplier answerEmptyItemsTestCase = () -> new QuestionValidationTestCase() .expectExplanation("This question contains an empty answer.") .expectLogMessage(q -> String.format("Expected list of DndItems, but none found in choice for question id (%s)!", q.getId())); + static Supplier questionEmptyAnswersTestCase = () -> new QuestionValidationTestCase() + .expectExplanation("This question is missing items") + .expectLogMessage(q -> String.format("Expected items in question (%s), but didn't find any!", q.getId())); + @DataPoints public static QuestionValidationTestCase[] questionValidationTestCases = { noAnswersTestCase.get().setTitle("answers empty").setQuestion(q -> q.setChoices(List.of())), @@ -264,8 +265,8 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) .setQuestion(q -> q.setChoices(List.of(new ParsonsChoice() {{correct = true; setItems(List.of(new Item("", ""))); }}))) .expectExplanation("This question contains at least one invalid answer.") .expectLogMessage(q -> String.format("Expected DndItem in question (%s), instead found class uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidatorTest$1!", q.getId())), - emptyItemsTestCase.get().setTitle("answer with empty items").setQuestion(correct()), - emptyItemsTestCase.get().setTitle("answer with null items") + answerEmptyItemsTestCase.get().setTitle("answer with empty items").setQuestion(correct()), + answerEmptyItemsTestCase.get().setTitle("answer with null items") .setQuestion(q -> q.setChoices(Stream.of(new DndItemChoice()).peek(c -> c.setCorrect(true)).collect(Collectors.toList()))), new QuestionValidationTestCase().setTitle("answer with non-dnd items") .setQuestion(correct(new DndItemEx("id", "value", "dropZoneId"))) @@ -278,7 +279,11 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) itemUnrecognisedFormatCase.get().setTitle("answer with missing dropZoneId") .setQuestion(correct(new DndItem("item_id", "value", null))), itemUnrecognisedFormatCase.get().setTitle("answer with empty dropZoneId") - .setQuestion(correct(new DndItem("item_id", "value", ""))) + .setQuestion(correct(new DndItem("item_id", "value", ""))), + questionEmptyAnswersTestCase.get().setTitle("items is null") + .tapQuestion(q -> q.setItems(null)), + questionEmptyAnswersTestCase.get().setTitle("items is empty") + .tapQuestion(q -> q.setItems(List.of())) }; @Theory From beb04681cb4acac95d5dde5f4ec0bf92db0b7920 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Thu, 11 Dec 2025 18:00:49 +0000 Subject: [PATCH 41/65] prefer correct answers --- .../uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java | 2 +- .../uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java | 8 +++++++- .../ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java | 8 +++++--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java index 77f34d6e7a..20ae1ca1c4 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java @@ -62,7 +62,7 @@ public boolean matches(final DndItemChoice rhs) { public int countPartialMatchesIn(final DndItemChoice rhs) { return this.items.stream() - .map(lhsItem -> dropZoneEql(lhsItem, rhs) ? -1 : 0) + .map(lhsItem -> dropZoneEql(lhsItem, rhs) ? 1 : 0) .mapToInt(Integer::intValue) .sum(); } diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 4445bb63e9..155d6ae65c 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -51,7 +51,13 @@ public final DndValidationResponse validateQuestionResponse(final Question quest private DndValidationResponse mark(final IsaacDndQuestion question, final DndItemChoice answer) { List sortedAnswers = question.getDndItemChoices().stream() - .sorted(Comparator.comparingInt(c -> c.countPartialMatchesIn(answer))) + .sorted((rhs, lhs) -> { + int compared = lhs.countPartialMatchesIn(answer) - rhs.countPartialMatchesIn(answer); + if (compared == 0) { + return lhs.isCorrect() && rhs.isCorrect() ? 0 : (lhs.isCorrect() ? 1 : -1); + } + return compared; + }) .collect(Collectors.toList()); Optional matchedAnswer = sortedAnswers.stream().filter(lhs -> lhs.matches(answer)).findFirst(); diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 4fa08738c0..31ea9c32b6 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -78,7 +78,11 @@ public class IsaacDndValidatorTest { correct(choose(item_5cm, "hypothenuse")), incorrect(answer(choose(item_3cm, "leg_2"), choose(item_5cm, "hypothenuse"))) ).setAnswer(answer(choose(item_3cm, "leg_2"), choose(item_5cm, "hypothenuse"))) - .expectCorrect(false) + .expectCorrect(false), + new CorrectnessTestCase().setTitle("sameAnswerCorrectAndIncorrect_Correct") + .setQuestion(incorrect(choose(item_3cm, "leg_1")), correct(choose(item_3cm, "leg_1"))) + .setAnswer(answer(choose(item_3cm, "leg_1"))) + .expectCorrect(true) }; @Theory @@ -88,8 +92,6 @@ public final void testCorrectness(final CorrectnessTestCase testCase) { } // Test that subset match answers return an appropriate explanation - // TODO: correct-incorrect contradiction among levels should be invalid question (during ETL?) - // - James says we should just accept as correct when contradiction // TODO: multiple matching explanations // - on same level? (or even across levels?) // - should return all? From 15a101040a4a055556ca05a0e4a8ee37a6d16967 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Thu, 11 Dec 2025 18:39:01 +0000 Subject: [PATCH 42/65] extract rule validator --- .../cl/dtg/isaac/dos/IsaacDndQuestion.java | 6 +- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 61 ++++--------------- .../cl/dtg/isaac/quiz/ValidationUtils.java | 29 +++++++++ .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 3 +- 4 files changed, 44 insertions(+), 55 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java index 2e28f3a9db..ad81679b89 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java @@ -15,7 +15,7 @@ */ package uk.ac.cam.cl.dtg.isaac.dos; -import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; +import org.apache.commons.lang3.BooleanUtils; import uk.ac.cam.cl.dtg.isaac.dos.content.DTOMapping; import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; import uk.ac.cam.cl.dtg.isaac.dos.content.JsonContentType; @@ -52,8 +52,8 @@ public void setWithReplacement(final Boolean withReplacement) { this.withReplacement = withReplacement; } - public Boolean getDetailedItemFeedback() { - return detailedItemFeedback; + public boolean getDetailedItemFeedback() { + return BooleanUtils.isTrue(detailedItemFeedback); } public void setDetailedItemFeedback(final Boolean detailedItemFeedback) { diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 155d6ae65c..978d98939f 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -44,9 +44,9 @@ public class IsaacDndValidator implements IValidator { @Override public final DndValidationResponse validateQuestionResponse(final Question question, final Choice answer) { - return validate(question, answer).orElseGet( - () -> mark((IsaacDndQuestion) question, (DndItemChoice) answer) - ); + return validate(question, answer) + .map(msg -> new DndValidationResponse(question.getId(), answer, false, null, new Content(msg), new Date())) + .orElseGet(() -> mark((IsaacDndQuestion) question, (DndItemChoice) answer)); } private DndValidationResponse mark(final IsaacDndQuestion question, final DndItemChoice answer) { @@ -59,40 +59,27 @@ private DndValidationResponse mark(final IsaacDndQuestion question, final DndIte return compared; }) .collect(Collectors.toList()); - Optional matchedAnswer = sortedAnswers.stream().filter(lhs -> lhs.matches(answer)).findFirst(); + DndItemChoice closestCorrect = sortedAnswers.stream().filter(Choice::isCorrect).findFirst().orElse(null); - DndItemChoice closestCorrectAnswer = sortedAnswers.stream().filter(Choice::isCorrect).findFirst().orElse(null); - - var id = question.getId(); var isCorrect = matchedAnswer.map(Choice::isCorrect).orElse(false); - var dropZonesCorrect = BooleanUtils.isTrue(question.getDetailedItemFeedback()) - ? closestCorrectAnswer.getDropZonesCorrect(answer) - : null; - var explanation = explain(isCorrect, closestCorrectAnswer, question, answer, matchedAnswer); - var date = new Date(); - return new DndValidationResponse(id, answer, isCorrect, dropZonesCorrect, explanation, date); - } - - private Content explain( - final boolean isCorrect, final DndItemChoice correctAnswer, final IsaacDndQuestion question, - final DndItemChoice answer, final Optional matchedAnswer - ) { - return (Content) matchedAnswer.map(Choice::getExplanation).orElseGet(() -> { + var dropZonesCorrect = question.getDetailedItemFeedback() ? closestCorrect.getDropZonesCorrect(answer) : null; + var feedback = (Content) matchedAnswer.map(Choice::getExplanation).orElseGet(() -> { if (isCorrect) { return null; } - if (answer.getItems().size() < correctAnswer.getItems().size()) { + if (answer.getItems().size() < closestCorrect.getItems().size()) { return new Content("You did not provide a valid answer; it does not contain an item for each gap."); } - if (answer.getItems().size() > correctAnswer.getItems().size()) { + if (answer.getItems().size() > closestCorrect.getItems().size()) { return new Content("You did not provide a valid answer; it contains more items than gaps."); } return question.getDefaultFeedback(); }); + return new DndValidationResponse(question.getId(), answer, isCorrect, dropZonesCorrect, feedback, new Date()); } - private Optional validate(final Question question, final Choice answer) { + private Optional validate(final Question question, final Choice answer) { Objects.requireNonNull(question); Objects.requireNonNull(answer); @@ -106,7 +93,7 @@ private Optional validate(final Question question, final "This validator only works with IsaacDndQuestions (%s is not IsaacDndQuestion)", question.getId())); } - return new ValidatorRules() + return new ValidationUtils.BiRuleValidator() // question .add(Constants.FEEDBACK_NO_CORRECT_ANSWERS, (q, a) -> logged( q.getChoices() == null || q.getChoices().isEmpty(), @@ -153,30 +140,4 @@ private boolean logged(final boolean result, final String message, final Object. } return result; } - - private static class ValidatorRules { - private final List rules = new ArrayList<>(); - - public ValidatorRules add(final String key, final BiPredicate rule) { - rules.add(new Rule(key, rule)); - return this; - } - - public Optional check(final IsaacDndQuestion q, final DndItemChoice a) { - return rules.stream() - .filter(r -> r.predicate.test(q, a)) - .map(e -> new DndValidationResponse(q.getId(), a, false, null, new Content(e.message), new Date())) - .findFirst(); - } - - private static class Rule { - public final String message; - public final BiPredicate predicate; - - public Rule(final String message, final BiPredicate predicate) { - this.message = message; - this.predicate = predicate; - } - } - } } diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/ValidationUtils.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/ValidationUtils.java index 9bc6a8058d..fcad3d6bee 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/ValidationUtils.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/ValidationUtils.java @@ -5,7 +5,11 @@ import java.math.BigDecimal; import java.math.MathContext; import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; +import java.util.Optional; +import java.util.function.BiPredicate; import static java.lang.Math.max; import static java.lang.Math.min; @@ -279,4 +283,29 @@ public static boolean tooManySignificantFigures(final String valueToCheck, final return sigFigsFromUser.sigFigsMin > maxAllowedSigFigs; } + public static class BiRuleValidator { + private final List rules = new ArrayList<>(); + + public BiRuleValidator add(final String key, final BiPredicate rule) { + rules.add(new Rule(key, rule)); + return this; + } + + public Optional check(final T t, final U u) { + return rules.stream() + .filter(r -> r.predicate.test(t, u)) + .map(r -> r.message) + .findFirst(); + } + + private class Rule { + public final String message; + public final BiPredicate predicate; + + public Rule(final String message, final BiPredicate predicate) { + this.message = message; + this.predicate = predicate; + } + } + } } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 31ea9c32b6..c891342ad9 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -167,8 +167,7 @@ public final void testExplanation(ExplanationTestCase testCase) { .setQuestion( correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")), correct(choose(item_5cm, "leg_1"), choose(item_12cm, "leg_2"), choose(item_13cm, "hypothenuse")) - ) - .setAnswer(answer(choose(item_5cm, "leg_1"))) + ).setAnswer(answer(choose(item_5cm, "leg_1"))) .expectCorrect(false) .expectDropZonesCorrect(d -> d.setLeg1(true)) }; From 083c1557bbe7e9a336db4b32c262f7b597e82942 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Fri, 12 Dec 2025 10:55:51 +0000 Subject: [PATCH 43/65] refactor, remove redundant DndItemChoice class --- .../cl/dtg/isaac/dos/IsaacDndQuestion.java | 6 +- .../cl/dtg/isaac/dos/content/DndChoice.java | 62 ++++++++++++- .../dtg/isaac/dos/content/DndItemChoice.java | 90 ------------------- .../dtg/isaac/dto/content/DndChoiceDTO.java | 19 +++- .../isaac/dto/content/DndItemChoiceDTO.java | 47 ---------- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 32 +++---- .../segue/dao/content/ChoiceDeserializer.java | 2 +- .../cl/dtg/util/mappers/ContentMapper.java | 2 - .../cl/dtg/isaac/api/QuestionFacadeIT.java | 18 ++-- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 32 +++---- 10 files changed, 121 insertions(+), 189 deletions(-) delete mode 100644 src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java delete mode 100644 src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/DndItemChoiceDTO.java diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java index ad81679b89..dcb53e58d0 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java @@ -17,7 +17,7 @@ import org.apache.commons.lang3.BooleanUtils; import uk.ac.cam.cl.dtg.isaac.dos.content.DTOMapping; -import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; +import uk.ac.cam.cl.dtg.isaac.dos.content.DndChoice; import uk.ac.cam.cl.dtg.isaac.dos.content.JsonContentType; import uk.ac.cam.cl.dtg.isaac.dto.IsaacDndQuestionDTO; import uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidator; @@ -40,8 +40,8 @@ public class IsaacDndQuestion extends IsaacItemQuestion { // Detailed feedback option not needed in the client so not in DTO: private Boolean detailedItemFeedback; - public List getDndItemChoices() { - return this.choices.stream().map(c -> (DndItemChoice) c).collect(Collectors.toList()); + public List getDndChoices() { + return this.choices.stream().map(c -> (DndChoice) c).collect(Collectors.toList()); } public Boolean getWithReplacement() { diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndChoice.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndChoice.java index f337ca06bc..bf7d15ea01 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndChoice.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndChoice.java @@ -17,16 +17,74 @@ import uk.ac.cam.cl.dtg.isaac.dto.content.DndChoiceDTO; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + /** - * Choice for Dnd Questions, containing a list of DndItems. + * Choice for Item Questions, containing a list of Items. * */ @DTOMapping(DndChoiceDTO.class) @JsonContentType("dndChoice") -public class DndChoice extends ItemChoice { +public class DndChoice extends Choice { + + private Boolean allowSubsetMatch; + private List items; + /** * Default constructor required for mapping. */ public DndChoice() { } + + public List getItems() { + return items; + } + + public void setItems(final List items) { + this.items = items; + } + + public Boolean isAllowSubsetMatch() { + return this.allowSubsetMatch; + } + + public void setAllowSubsetMatch(final boolean allowSubsetMatch) { + this.allowSubsetMatch = allowSubsetMatch; + } + + public boolean matches(final DndChoice rhs) { + return this.items.stream().allMatch(lhsItem -> dropZoneEql(lhsItem, rhs)) + && this.items.size() == rhs.getItems().size(); + } + + public int countPartialMatchesIn(final DndChoice rhs) { + return this.items.stream() + .map(lhsItem -> dropZoneEql(lhsItem, rhs) ? 1 : 0) + .mapToInt(Integer::intValue) + .sum(); + } + + public Map getDropZonesCorrect(final DndChoice rhs) { + return this.items.stream() + .filter(lhsItem -> rhs.getItemByDropZone(lhsItem.getDropZoneId()).isPresent()) + .collect(Collectors.toMap( + DndItem::getDropZoneId, + lhsItem -> dropZoneEql(lhsItem, rhs)) + ); + } + + private static boolean dropZoneEql(DndItem lhsItem, DndChoice rhs) { + return rhs.getItemByDropZone(lhsItem.getDropZoneId()) + .map(rhsItem -> rhsItem.getId().equals(lhsItem.getId())) + .orElse(false); + } + + private Optional getItemByDropZone(final String dropZoneId) { + return this.items.stream() + .filter(item -> item.getDropZoneId().equals(dropZoneId)) + .findFirst(); + } } diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java deleted file mode 100644 index 20ae1ca1c4..0000000000 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndItemChoice.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2019 James Sharkey - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package uk.ac.cam.cl.dtg.isaac.dos.content; - -import uk.ac.cam.cl.dtg.isaac.dto.content.DndItemChoiceDTO; - -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - -/** - * Choice for Item Questions, containing a list of Items. - * - */ -@DTOMapping(DndItemChoiceDTO.class) -@JsonContentType("dndChoice") -public class DndItemChoice extends Choice { - - private Boolean allowSubsetMatch; - private List items; - - /** - * Default constructor required for mapping. - */ - public DndItemChoice() { - } - - public List getItems() { - return items; - } - - public void setItems(final List items) { - this.items = items; - } - - public Boolean isAllowSubsetMatch() { - return this.allowSubsetMatch; - } - - public void setAllowSubsetMatch(final boolean allowSubsetMatch) { - this.allowSubsetMatch = allowSubsetMatch; - } - - public boolean matches(final DndItemChoice rhs) { - return this.items.stream().allMatch(lhsItem -> dropZoneEql(lhsItem, rhs)) - && this.items.size() == rhs.getItems().size(); - } - - public int countPartialMatchesIn(final DndItemChoice rhs) { - return this.items.stream() - .map(lhsItem -> dropZoneEql(lhsItem, rhs) ? 1 : 0) - .mapToInt(Integer::intValue) - .sum(); - } - - public Map getDropZonesCorrect(final DndItemChoice rhs) { - return this.items.stream() - .filter(lhsItem -> rhs.getItemByDropZone(lhsItem.getDropZoneId()).isPresent()) - .collect(Collectors.toMap( - DndItem::getDropZoneId, - lhsItem -> dropZoneEql(lhsItem, rhs)) - ); - } - - private static boolean dropZoneEql(DndItem lhsItem, DndItemChoice rhs) { - return rhs.getItemByDropZone(lhsItem.getDropZoneId()) - .map(rhsItem -> rhsItem.getId().equals(lhsItem.getId())) - .orElse(false); - } - - private Optional getItemByDropZone(final String dropZoneId) { - return this.items.stream() - .filter(item -> item.getDropZoneId().equals(dropZoneId)) - .findFirst(); - } -} diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/DndChoiceDTO.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/DndChoiceDTO.java index e32e3a80dc..d969d3b122 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/DndChoiceDTO.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/DndChoiceDTO.java @@ -15,11 +15,16 @@ */ package uk.ac.cam.cl.dtg.isaac.dto.content; +import java.util.List; + /** * Choice for Dnd Questions, containing a list of DndItems. * */ -public class DndChoiceDTO extends ItemChoiceDTO { +public class DndChoiceDTO extends ChoiceDTO { + + private Boolean allowSubsetMatch; + private List items; /** * Default constructor required for mapping. @@ -27,4 +32,16 @@ public class DndChoiceDTO extends ItemChoiceDTO { public DndChoiceDTO() { } + public List getItems() { + return items; + } + + public void setItems(final List items) { + this.items = items; + } + + public Boolean isAllowSubsetMatch() { return this.allowSubsetMatch; } + + public void setAllowSubsetMatch(final boolean allowSubsetMatch) { this.allowSubsetMatch = allowSubsetMatch; } + } diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/DndItemChoiceDTO.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/DndItemChoiceDTO.java deleted file mode 100644 index 5d0fc36668..0000000000 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/DndItemChoiceDTO.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2019 James Sharkey - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package uk.ac.cam.cl.dtg.isaac.dto.content; - -import java.util.List; - -/** - * Choice for Dnd Questions, containing a list of DndItems. - * - */ -public class DndItemChoiceDTO extends ChoiceDTO { - - private Boolean allowSubsetMatch; - private List items; - - /** - * Default constructor required for mapping. - */ - public DndItemChoiceDTO() { - } - - public List getItems() { - return items; - } - - public void setItems(final List items) { - this.items = items; - } - - public Boolean isAllowSubsetMatch() { return this.allowSubsetMatch; } - - public void setAllowSubsetMatch(final boolean allowSubsetMatch) { this.allowSubsetMatch = allowSubsetMatch; } - -} diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 978d98939f..1ec604ee04 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -15,7 +15,6 @@ */ package uk.ac.cam.cl.dtg.isaac.quiz; -import org.apache.commons.lang3.BooleanUtils; import org.slf4j.LoggerFactory; import org.slf4j.Logger; import uk.ac.cam.cl.dtg.isaac.api.Constants; @@ -23,17 +22,14 @@ import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; import uk.ac.cam.cl.dtg.isaac.dos.content.Content; +import uk.ac.cam.cl.dtg.isaac.dos.content.DndChoice; import uk.ac.cam.cl.dtg.isaac.dos.content.DndItem; -import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; import uk.ac.cam.cl.dtg.isaac.dos.content.Question; -import java.util.ArrayList; -import java.util.Comparator; import java.util.Date; import java.util.List; import java.util.Objects; import java.util.Optional; -import java.util.function.BiPredicate; import java.util.stream.Collectors; /** @@ -46,11 +42,11 @@ public class IsaacDndValidator implements IValidator { public final DndValidationResponse validateQuestionResponse(final Question question, final Choice answer) { return validate(question, answer) .map(msg -> new DndValidationResponse(question.getId(), answer, false, null, new Content(msg), new Date())) - .orElseGet(() -> mark((IsaacDndQuestion) question, (DndItemChoice) answer)); + .orElseGet(() -> mark((IsaacDndQuestion) question, (DndChoice) answer)); } - private DndValidationResponse mark(final IsaacDndQuestion question, final DndItemChoice answer) { - List sortedAnswers = question.getDndItemChoices().stream() + private DndValidationResponse mark(final IsaacDndQuestion question, final DndChoice answer) { + List sortedAnswers = question.getDndChoices().stream() .sorted((rhs, lhs) -> { int compared = lhs.countPartialMatchesIn(answer) - rhs.countPartialMatchesIn(answer); if (compared == 0) { @@ -59,8 +55,8 @@ private DndValidationResponse mark(final IsaacDndQuestion question, final DndIte return compared; }) .collect(Collectors.toList()); - Optional matchedAnswer = sortedAnswers.stream().filter(lhs -> lhs.matches(answer)).findFirst(); - DndItemChoice closestCorrect = sortedAnswers.stream().filter(Choice::isCorrect).findFirst().orElse(null); + Optional matchedAnswer = sortedAnswers.stream().filter(lhs -> lhs.matches(answer)).findFirst(); + DndChoice closestCorrect = sortedAnswers.stream().filter(Choice::isCorrect).findFirst().orElse(null); var isCorrect = matchedAnswer.map(Choice::isCorrect).orElse(false); var dropZonesCorrect = question.getDetailedItemFeedback() ? closestCorrect.getDropZonesCorrect(answer) : null; @@ -83,9 +79,9 @@ private Optional validate(final Question question, final Choice answer) Objects.requireNonNull(question); Objects.requireNonNull(answer); - if (!(answer instanceof DndItemChoice)) { + if (!(answer instanceof DndChoice)) { throw new IllegalArgumentException(String.format( - "This validator only works with DndItemChoices (%s is not DndItemChoice)", question.getId())); + "This validator only works with DndChoices (%s is not DndChoice)", question.getId())); } if (!(question instanceof IsaacDndQuestion)) { @@ -93,7 +89,7 @@ private Optional validate(final Question question, final Choice answer) "This validator only works with IsaacDndQuestions (%s is not IsaacDndQuestion)", question.getId())); } - return new ValidationUtils.BiRuleValidator() + return new ValidationUtils.BiRuleValidator() // question .add(Constants.FEEDBACK_NO_CORRECT_ANSWERS, (q, a) -> logged( q.getChoices() == null || q.getChoices().isEmpty(), @@ -104,18 +100,18 @@ private Optional validate(final Question question, final Choice answer) "Question does not have any correct answers. %s src: %s", q.getId(), q.getCanonicalSourceFile() )) .add("This question contains at least one invalid answer.", (q, a) -> q.getChoices().stream().anyMatch(c -> logged( - !DndItemChoice.class.equals(c.getClass()), + !DndChoice.class.equals(c.getClass()), "Expected DndItem in question (%s), instead found %s!", q.getId(), c.getClass() ))) - .add("This question contains an empty answer.", (q, a) -> q.getDndItemChoices().stream().anyMatch(c -> logged( + .add("This question contains an empty answer.", (q, a) -> q.getDndChoices().stream().anyMatch(c -> logged( c.getItems() == null || c.getItems().isEmpty(), "Expected list of DndItems, but none found in choice for question id (%s)!", q.getId() ))) - .add("This question contains at least one invalid answer.", (q, a) -> q.getDndItemChoices().stream().anyMatch(c -> logged( + .add("This question contains at least one invalid answer.", (q, a) -> q.getDndChoices().stream().anyMatch(c -> logged( c.getItems().stream().anyMatch(i -> i.getClass() != DndItem.class), "Expected list of DndItems, but something else found in choice for question id (%s)!", q.getId() ))) - .add("This question contains at least one answer in an unrecognised format.", (q, a) -> q.getDndItemChoices().stream().anyMatch(c -> logged( + .add("This question contains at least one answer in an unrecognised format.", (q, a) -> q.getDndChoices().stream().anyMatch(c -> logged( c.getItems().stream().anyMatch(i -> i.getId() == null || i.getDropZoneId() == null || Objects.equals(i.getId(), "") || Objects.equals(i.getDropZoneId(), "")), "Found item with missing id or drop zone id in answer for question id (%s)!", q.getId() ))) @@ -131,7 +127,7 @@ private Optional validate(final Question question, final Choice answer) .add(Constants.FEEDBACK_UNRECOGNISED_FORMAT, (q, a) -> a.getItems().stream().anyMatch(i -> i.getId() == null) || a.getItems().stream().anyMatch(i -> i.getDropZoneId() == null)) - .check((IsaacDndQuestion) question, (DndItemChoice) answer); + .check((IsaacDndQuestion) question, (DndChoice) answer); } private boolean logged(final boolean result, final String message, final Object... args) { diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/content/ChoiceDeserializer.java b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/content/ChoiceDeserializer.java index 65636632b5..b40e92c7eb 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/content/ChoiceDeserializer.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/content/ChoiceDeserializer.java @@ -90,7 +90,7 @@ public Choice deserialize(final JsonParser jsonParser, final DeserializationCont case "coordinateChoice": return getSingletonChoiceMapper().readValue(root.toString(), CoordinateChoice.class); case "dndChoice": - return getSingletonChoiceMapper().readValue(root.toString(), DndItemChoice.class); + return getSingletonChoiceMapper().readValue(root.toString(), DndChoice.class); case "itemChoice": return getSingletonChoiceMapper().readValue(root.toString(), ItemChoice.class); default: diff --git a/src/main/java/uk/ac/cam/cl/dtg/util/mappers/ContentMapper.java b/src/main/java/uk/ac/cam/cl/dtg/util/mappers/ContentMapper.java index 6ab65b69cb..14be148c9b 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/util/mappers/ContentMapper.java +++ b/src/main/java/uk/ac/cam/cl/dtg/util/mappers/ContentMapper.java @@ -62,7 +62,6 @@ default T map(ContentDTO source, Class targetClass) { @SubclassMapping(source = RegexPattern.class, target = RegexPatternDTO.class) @SubclassMapping(source = StringChoice.class, target = StringChoiceDTO.class) // ItemChoice subclasses must come before ItemChoice - @SubclassMapping(source = DndItemChoice.class, target = DndItemChoiceDTO.class) @SubclassMapping(source = CoordinateChoice.class, target = CoordinateChoiceDTO.class) @SubclassMapping(source = ParsonsChoice.class, target = ParsonsChoiceDTO.class) @SubclassMapping(source = DndChoice.class, target = DndChoiceDTO.class) @@ -83,7 +82,6 @@ default T map(ContentDTO source, Class targetClass) { @SubclassMapping(source = RegexPatternDTO.class, target = RegexPattern.class) @SubclassMapping(source = StringChoiceDTO.class, target = StringChoice.class) // ItemChoiceDTO subclasses must come before ItemChoiceDTO - @SubclassMapping(source = DndItemChoiceDTO.class, target = DndItemChoice.class) @SubclassMapping(source = CoordinateChoiceDTO.class, target = CoordinateChoice.class) @SubclassMapping(source = ParsonsChoiceDTO.class, target = ParsonsChoice.class) @SubclassMapping(source = DndChoiceDTO.class, target = DndChoice.class) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java index 433b7b9a88..018b48c2fb 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -8,7 +8,7 @@ import org.junit.jupiter.params.provider.CsvSource; import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; import uk.ac.cam.cl.dtg.isaac.dos.content.Content; -import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; +import uk.ac.cam.cl.dtg.isaac.dos.content.DndChoice; import uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidator; import uk.ac.cam.cl.dtg.isaac.quiz.IsaacStringMatchValidator; import uk.ac.cam.cl.dtg.segue.api.QuestionFacade; @@ -96,7 +96,7 @@ public void invalidQuestion() throws Exception { @ParameterizedTest @CsvSource(value = { "{};Unable to map response to a Choice;404", - "{\"type\": \"unknown\"};This validator only works with DndItemChoices;400", + "{\"type\": \"unknown\"};This validator only works with DndChoices;400", "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\", \"a\": \"a\"}]};Unable to map response to a Choice;404", "{\"type\": \"dndChoice\", \"items\": \"some_string\"};Unable to map response to a Choice;404", "{\"type\": \"dndChoice\", \"items\": [{\"id\": [{}], \"dropZoneId\": \"leg_1\"}]};Unable to map response to a Choice;404" @@ -135,16 +135,16 @@ public void emptyAnswer_IncorrectReturned(final String answerStr) throws Excepti var response = subject().client().post(url(dndQuestion.getId()), answerStr).readEntityAsJson(); assertFalse(response.getBoolean("correct")); assertEquals( - readEntity(new JSONObject(answerStr), DndItemChoice.class), - readEntity(response.getJSONObject("answer"), DndItemChoice.class) + readEntity(new JSONObject(answerStr), DndChoice.class), + readEntity(response.getJSONObject("answer"), DndChoice.class) ); } @ParameterizedTest @CsvSource(value = { "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\"}]}", - "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\", \"type\": \"dndItem\"}]}", - "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\", \"type\": \"unknown\"}]}" +// "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\", \"type\": \"dndItem\"}]}", +// "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\", \"type\": \"unknown\"}]}" }, delimiter = ';') public void correctAnswer_CorrectReturned(final String answerStr) throws Exception { var dndQuestion = persist(createQuestion( @@ -154,8 +154,8 @@ public void correctAnswer_CorrectReturned(final String answerStr) throws Excepti var response = subject().client().post(url(dndQuestion.getId()), answerStr).readEntityAsJson(); assertTrue(response.getBoolean("correct")); assertEquals( - readEntity(new JSONObject(answerStr), DndItemChoice.class), - readEntity(response.getJSONObject("answer"), DndItemChoice.class) + readEntity(new JSONObject(answerStr), DndChoice.class), + readEntity(response.getJSONObject("answer"), DndChoice.class) ); } @@ -169,7 +169,7 @@ public void wrongAnswer_IncorrectReturned() throws Exception { var response = subject().client().post(url(dndQuestion.getId()), answer).readEntityAsJson(); assertFalse(response.getBoolean("correct")); - assertEquals(answer, readEntity(response.getJSONObject("answer"), DndItemChoice.class)); + assertEquals(answer, readEntity(response.getJSONObject("answer"), DndChoice.class)); } @Test diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index c891342ad9..650a79b645 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -28,8 +28,8 @@ import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; import uk.ac.cam.cl.dtg.isaac.dos.content.Content; import uk.ac.cam.cl.dtg.isaac.dos.content.ContentBase; +import uk.ac.cam.cl.dtg.isaac.dos.content.DndChoice; import uk.ac.cam.cl.dtg.isaac.dos.content.DndItem; -import uk.ac.cam.cl.dtg.isaac.dos.content.DndItemChoice; import uk.ac.cam.cl.dtg.isaac.dos.content.Item; import java.util.HashMap; @@ -185,18 +185,18 @@ public final void testDropZonesCorrect(final DropZonesTestCase testCase) { .setAnswer(answer()) .expectExplanation(Constants.FEEDBACK_NO_ANSWER_PROVIDED), new AnswerValidationTestCase().setTitle("itemsEmpty") - .setAnswer(new DndItemChoice()) + .setAnswer(new DndChoice()) .expectExplanation(Constants.FEEDBACK_NO_ANSWER_PROVIDED), new AnswerValidationTestCase().setTitle("itemsNotEnough") .setQuestion(correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"))) .setAnswer(answer(choose(item_3cm, "leg_1"))) .expectExplanation("You did not provide a valid answer; it does not contain an item for each gap.") - .expectDropZonesCorrect(feedback -> feedback.setLeg1(true)), + .expectDropZonesCorrect(f -> f.setLeg1(true)), new AnswerValidationTestCase().setTitle("itemsTooMany") .setQuestion(correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"))) .expectExplanation("You did not provide a valid answer; it contains more items than gaps.") - .expectDropZonesCorrect(feedback -> feedback.setLeg1(true)), + .expectDropZonesCorrect(f -> f.setLeg1(true)), new AnswerValidationTestCase().setTitle("itemNotOnQuestion") .setQuestion(correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(new Item("bad_id", "some_value"), "leg_1"))) @@ -215,7 +215,7 @@ public final void testDropZonesCorrect(final DropZonesTestCase testCase) { incorrect(new Content("Leg 1 should be less than 4 cm"), choose(item_4cm, "leg_1")) ).setAnswer(answer(choose(item_4cm, "leg_1"))) .expectExplanation("Leg 1 should be less than 4 cm") - .expectDropZonesCorrect(feedback -> feedback.setLeg1(false)) + .expectDropZonesCorrect(f -> f.setLeg1(false)) // TODO: if drop zone does not exist in question }; @@ -268,7 +268,7 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) .expectLogMessage(q -> String.format("Expected DndItem in question (%s), instead found class uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidatorTest$1!", q.getId())), answerEmptyItemsTestCase.get().setTitle("answer with empty items").setQuestion(correct()), answerEmptyItemsTestCase.get().setTitle("answer with null items") - .setQuestion(q -> q.setChoices(Stream.of(new DndItemChoice()).peek(c -> c.setCorrect(true)).collect(Collectors.toList()))), + .setQuestion(q -> q.setChoices(Stream.of(new DndChoice()).peek(c -> c.setCorrect(true)).collect(Collectors.toList()))), new QuestionValidationTestCase().setTitle("answer with non-dnd items") .setQuestion(correct(new DndItemEx("id", "value", "dropZoneId"))) .expectExplanation("This question contains at least one invalid answer.") @@ -319,8 +319,8 @@ private static TestAppender testValidateWithLogs(final IsaacDndQuestion question } } - public static DndItemChoice answer(final DndItem... list) { - var c = new DndItemChoice(); + public static DndChoice answer(final DndItem... list) { + var c = new DndChoice(); c.setItems(List.of(list)); c.setType("dndChoice"); return c; @@ -332,7 +332,7 @@ public static DndItem choose(final Item item, final String dropZoneId) { return value; } - public static IsaacDndQuestion createQuestion(final DndItemChoice... answers) { + public static IsaacDndQuestion createQuestion(final DndChoice... answers) { var question = new IsaacDndQuestion(); question.setId(UUID.randomUUID().toString()); question.setItems(List.of(item_3cm, item_4cm, item_5cm, item_6cm, item_12cm, item_13cm)); @@ -341,25 +341,25 @@ public static IsaacDndQuestion createQuestion(final DndItemChoice... answers) { return question; } - public static DndItemChoice correct(final DndItem... list) { + public static DndChoice correct(final DndItem... list) { var choice = answer(list); choice.setCorrect(true); return choice; } - public static DndItemChoice correct(final ContentBase explanation, final DndItem... list) { + public static DndChoice correct(final ContentBase explanation, final DndItem... list) { var choice = correct(list); choice.setExplanation(explanation); return choice; } - public static DndItemChoice incorrect(final DndItem... list) { + public static DndChoice incorrect(final DndItem... list) { var choice = answer(list); choice.setCorrect(false); return choice; } - public static DndItemChoice incorrect(final ContentBase explanation, final DndItem... list) { + public static DndChoice incorrect(final ContentBase explanation, final DndItem... list) { var choice = incorrect(list); choice.setExplanation(explanation); return choice; @@ -400,7 +400,7 @@ static class TestCase> { public IsaacDndQuestion question = createQuestion( correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) ); - public DndItemChoice answer = answer(); + public DndChoice answer = answer(); public Content feedback = testFeedback; public Map dropZonesCorrect; public String loggedMessage; @@ -412,7 +412,7 @@ public T setTitle(final String title) { return self(); } - public T setQuestion(final DndItemChoice... choices) { + public T setQuestion(final DndChoice... choices) { this.question = createQuestion(choices); return self(); } @@ -429,7 +429,7 @@ public T tapQuestion(final Consumer op) { return self(); } - public T setAnswer(final DndItemChoice answer) { + public T setAnswer(final DndChoice answer) { this.answer = answer; return self(); } From cbb19a2011eca88fc6ea1d2eec8766f8686aae20 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Fri, 12 Dec 2025 15:34:32 +0000 Subject: [PATCH 44/65] refactor, DO and DTO should not contain logic --- .../cl/dtg/isaac/dos/IsaacDndQuestion.java | 14 +-- .../cl/dtg/isaac/dos/content/DndChoice.java | 48 +------- .../dtg/isaac/dto/content/DndChoiceDTO.java | 7 -- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 108 +++++++++++++++--- .../cl/dtg/isaac/api/QuestionFacadeIT.java | 17 ++- 5 files changed, 108 insertions(+), 86 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java index dcb53e58d0..c55e239ccd 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/IsaacDndQuestion.java @@ -15,18 +15,12 @@ */ package uk.ac.cam.cl.dtg.isaac.dos; -import org.apache.commons.lang3.BooleanUtils; import uk.ac.cam.cl.dtg.isaac.dos.content.DTOMapping; -import uk.ac.cam.cl.dtg.isaac.dos.content.DndChoice; import uk.ac.cam.cl.dtg.isaac.dos.content.JsonContentType; import uk.ac.cam.cl.dtg.isaac.dto.IsaacDndQuestionDTO; import uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidator; import uk.ac.cam.cl.dtg.isaac.quiz.ValidatesWith; -import java.util.List; -import java.util.stream.Collectors; - - /** * Content DO for IsaacDndQuestions. * @@ -40,10 +34,6 @@ public class IsaacDndQuestion extends IsaacItemQuestion { // Detailed feedback option not needed in the client so not in DTO: private Boolean detailedItemFeedback; - public List getDndChoices() { - return this.choices.stream().map(c -> (DndChoice) c).collect(Collectors.toList()); - } - public Boolean getWithReplacement() { return withReplacement; } @@ -52,8 +42,8 @@ public void setWithReplacement(final Boolean withReplacement) { this.withReplacement = withReplacement; } - public boolean getDetailedItemFeedback() { - return BooleanUtils.isTrue(detailedItemFeedback); + public Boolean getDetailedItemFeedback() { + return detailedItemFeedback; } public void setDetailedItemFeedback(final Boolean detailedItemFeedback) { diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndChoice.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndChoice.java index bf7d15ea01..e300ed383f 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndChoice.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/content/DndChoice.java @@ -18,9 +18,6 @@ import uk.ac.cam.cl.dtg.isaac.dto.content.DndChoiceDTO; import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; /** * Choice for Item Questions, containing a list of Items. @@ -29,8 +26,6 @@ @DTOMapping(DndChoiceDTO.class) @JsonContentType("dndChoice") public class DndChoice extends Choice { - - private Boolean allowSubsetMatch; private List items; /** @@ -46,45 +41,4 @@ public List getItems() { public void setItems(final List items) { this.items = items; } - - public Boolean isAllowSubsetMatch() { - return this.allowSubsetMatch; - } - - public void setAllowSubsetMatch(final boolean allowSubsetMatch) { - this.allowSubsetMatch = allowSubsetMatch; - } - - public boolean matches(final DndChoice rhs) { - return this.items.stream().allMatch(lhsItem -> dropZoneEql(lhsItem, rhs)) - && this.items.size() == rhs.getItems().size(); - } - - public int countPartialMatchesIn(final DndChoice rhs) { - return this.items.stream() - .map(lhsItem -> dropZoneEql(lhsItem, rhs) ? 1 : 0) - .mapToInt(Integer::intValue) - .sum(); - } - - public Map getDropZonesCorrect(final DndChoice rhs) { - return this.items.stream() - .filter(lhsItem -> rhs.getItemByDropZone(lhsItem.getDropZoneId()).isPresent()) - .collect(Collectors.toMap( - DndItem::getDropZoneId, - lhsItem -> dropZoneEql(lhsItem, rhs)) - ); - } - - private static boolean dropZoneEql(DndItem lhsItem, DndChoice rhs) { - return rhs.getItemByDropZone(lhsItem.getDropZoneId()) - .map(rhsItem -> rhsItem.getId().equals(lhsItem.getId())) - .orElse(false); - } - - private Optional getItemByDropZone(final String dropZoneId) { - return this.items.stream() - .filter(item -> item.getDropZoneId().equals(dropZoneId)) - .findFirst(); - } -} +} \ No newline at end of file diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/DndChoiceDTO.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/DndChoiceDTO.java index d969d3b122..5a82f67db1 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/DndChoiceDTO.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dto/content/DndChoiceDTO.java @@ -22,8 +22,6 @@ * */ public class DndChoiceDTO extends ChoiceDTO { - - private Boolean allowSubsetMatch; private List items; /** @@ -39,9 +37,4 @@ public List getItems() { public void setItems(final List items) { this.items = items; } - - public Boolean isAllowSubsetMatch() { return this.allowSubsetMatch; } - - public void setAllowSubsetMatch(final boolean allowSubsetMatch) { this.allowSubsetMatch = allowSubsetMatch; } - } diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 1ec604ee04..9ff7a7cb57 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -15,6 +15,7 @@ */ package uk.ac.cam.cl.dtg.isaac.quiz; +import org.apache.commons.lang3.BooleanUtils; import org.slf4j.LoggerFactory; import org.slf4j.Logger; import uk.ac.cam.cl.dtg.isaac.api.Constants; @@ -28,9 +29,11 @@ import java.util.Date; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Validator that only provides functionality to validate Drag and drop questions. @@ -42,11 +45,12 @@ public class IsaacDndValidator implements IValidator { public final DndValidationResponse validateQuestionResponse(final Question question, final Choice answer) { return validate(question, answer) .map(msg -> new DndValidationResponse(question.getId(), answer, false, null, new Content(msg), new Date())) - .orElseGet(() -> mark((IsaacDndQuestion) question, (DndChoice) answer)); + .orElseGet(() -> mark(IsaacDndQuestionEx.of(question), DndChoiceEx.of(answer))); } - private DndValidationResponse mark(final IsaacDndQuestion question, final DndChoice answer) { - List sortedAnswers = question.getDndChoices().stream() + private DndValidationResponse mark(final IsaacDndQuestionEx question, final DndChoiceEx answer) { + // TODO: extract sorting, to comply with IValidator interface + List sortedAnswers = question.getDndChoices() .sorted((rhs, lhs) -> { int compared = lhs.countPartialMatchesIn(answer) - rhs.countPartialMatchesIn(answer); if (compared == 0) { @@ -55,10 +59,10 @@ private DndValidationResponse mark(final IsaacDndQuestion question, final DndCho return compared; }) .collect(Collectors.toList()); - Optional matchedAnswer = sortedAnswers.stream().filter(lhs -> lhs.matches(answer)).findFirst(); - DndChoice closestCorrect = sortedAnswers.stream().filter(Choice::isCorrect).findFirst().orElse(null); + Optional matchedAnswer = sortedAnswers.stream().filter(lhs -> lhs.matches(answer)).findFirst(); + DndChoiceEx closestCorrect = sortedAnswers.stream().filter(DndChoiceEx::isCorrect).findFirst().orElse(null); - var isCorrect = matchedAnswer.map(Choice::isCorrect).orElse(false); + var isCorrect = matchedAnswer.map(DndChoiceEx::isCorrect).orElse(false); var dropZonesCorrect = question.getDetailedItemFeedback() ? closestCorrect.getDropZonesCorrect(answer) : null; var feedback = (Content) matchedAnswer.map(Choice::getExplanation).orElseGet(() -> { if (isCorrect) { @@ -89,7 +93,7 @@ private Optional validate(final Question question, final Choice answer) "This validator only works with IsaacDndQuestions (%s is not IsaacDndQuestion)", question.getId())); } - return new ValidationUtils.BiRuleValidator() + return new ValidationUtils.BiRuleValidator() // question .add(Constants.FEEDBACK_NO_CORRECT_ANSWERS, (q, a) -> logged( q.getChoices() == null || q.getChoices().isEmpty(), @@ -103,16 +107,16 @@ private Optional validate(final Question question, final Choice answer) !DndChoice.class.equals(c.getClass()), "Expected DndItem in question (%s), instead found %s!", q.getId(), c.getClass() ))) - .add("This question contains an empty answer.", (q, a) -> q.getDndChoices().stream().anyMatch(c -> logged( + .add("This question contains an empty answer.", (q, a) -> q.getDndChoices().anyMatch(c -> logged( c.getItems() == null || c.getItems().isEmpty(), "Expected list of DndItems, but none found in choice for question id (%s)!", q.getId() ))) - .add("This question contains at least one invalid answer.", (q, a) -> q.getDndChoices().stream().anyMatch(c -> logged( + .add("This question contains at least one invalid answer.", (q, a) -> q.getDndChoices().anyMatch(c -> logged( c.getItems().stream().anyMatch(i -> i.getClass() != DndItem.class), "Expected list of DndItems, but something else found in choice for question id (%s)!", q.getId() ))) - .add("This question contains at least one answer in an unrecognised format.", (q, a) -> q.getDndChoices().stream().anyMatch(c -> logged( - c.getItems().stream().anyMatch(i -> i.getId() == null || i.getDropZoneId() == null || Objects.equals(i.getId(), "") || Objects.equals(i.getDropZoneId(), "")), + .add("This question contains at least one answer in an unrecognised format.", (q, a) -> q.getDndChoices().anyMatch(c -> logged( + c.getDndItems().anyMatch(i -> i.getId() == null || i.getDropZoneId() == null || Objects.equals(i.getId(), "") || Objects.equals(i.getDropZoneId(), "")), "Found item with missing id or drop zone id in answer for question id (%s)!", q.getId() ))) .add("This question is missing items", (q, a) -> logged( @@ -125,9 +129,9 @@ private Optional validate(final Question question, final Choice answer) .add(Constants.FEEDBACK_UNRECOGNISED_ITEMS, (q, a) -> a.getItems().stream().anyMatch(answerItem -> !q.getItems().contains(answerItem))) .add(Constants.FEEDBACK_UNRECOGNISED_FORMAT, - (q, a) -> a.getItems().stream().anyMatch(i -> i.getId() == null) - || a.getItems().stream().anyMatch(i -> i.getDropZoneId() == null)) - .check((IsaacDndQuestion) question, (DndChoice) answer); + (q, a) -> a.getDndItems().anyMatch(i -> i.getId() == null) + || a.getDndItems().anyMatch(i -> i.getDropZoneId() == null)) + .check(IsaacDndQuestionEx.of(question), DndChoiceEx.of(answer)); } private boolean logged(final boolean result, final String message, final Object... args) { @@ -136,4 +140,80 @@ private boolean logged(final boolean result, final String message, final Object. } return result; } + + private static class IsaacDndQuestionEx extends IsaacDndQuestion { + public static IsaacDndQuestionEx of(final Question question) { + return new IsaacDndQuestionEx((IsaacDndQuestion) question); + } + + public IsaacDndQuestionEx(final IsaacDndQuestion question) { + super(); + setItems(question.getItems()); + setId(question.getId()); + setCanonicalSourceFile(question.getCanonicalSourceFile()); + setChoices(question.getChoices()); + setDetailedItemFeedback(question.getDetailedItemFeedback()); + setDefaultFeedback(question.getDefaultFeedback()); + } + + public Stream getDndChoices() { + return super.getChoices().stream().map(DndChoiceEx::of); + } + + @Override + public Boolean getDetailedItemFeedback() { + return BooleanUtils.isTrue(super.getDetailedItemFeedback()); + } + } + + private static class DndChoiceEx extends DndChoice { + public static DndChoiceEx of(final Choice c) { + return new DndChoiceEx((DndChoice) c); + } + + DndChoiceEx(final DndChoice choice) { + super(); + setItems(choice.getItems()); + setCorrect(choice.isCorrect()); + setExplanation(choice.getExplanation()); + setType(choice.getType()); + } + + public Stream getDndItems() { + return super.getItems().stream().map(i -> (DndItem) i); + } + + public boolean matches(final DndChoiceEx rhs) { + return this.getDndItems().allMatch(lhsItem -> dropZoneEql(lhsItem, rhs)) + && super.getItems().size() == rhs.getItems().size(); + } + + public int countPartialMatchesIn(final DndChoiceEx rhs) { + return this.getDndItems() + .map(lhsItem -> dropZoneEql(lhsItem, rhs) ? 1 : 0) + .mapToInt(Integer::intValue) + .sum(); + } + + public Map getDropZonesCorrect(final DndChoiceEx rhs) { + return this.getDndItems() + .filter(lhsItem -> rhs.getItemByDropZone(lhsItem.getDropZoneId()).isPresent()) + .collect(Collectors.toMap( + DndItem::getDropZoneId, + lhsItem -> dropZoneEql(lhsItem, rhs)) + ); + } + + private static boolean dropZoneEql(final DndItem lhsItem, final DndChoiceEx rhs) { + return rhs.getItemByDropZone(lhsItem.getDropZoneId()) + .map(rhsItem -> rhsItem.getId().equals(lhsItem.getId())) + .orElse(false); + } + + private Optional getItemByDropZone(final String dropZoneId) { + return this.getDndItems() + .filter(item -> item.getDropZoneId().equals(dropZoneId)) + .findFirst(); + } + } } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java index 018b48c2fb..42463712d8 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -143,8 +143,8 @@ public void emptyAnswer_IncorrectReturned(final String answerStr) throws Excepti @ParameterizedTest @CsvSource(value = { "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\"}]}", -// "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\", \"type\": \"dndItem\"}]}", -// "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\", \"type\": \"unknown\"}]}" + "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\", \"type\": \"dndItem\"}]}", + "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\", \"type\": \"unknown\"}]}" }, delimiter = ';') public void correctAnswer_CorrectReturned(final String answerStr) throws Exception { var dndQuestion = persist(createQuestion( @@ -153,10 +153,8 @@ public void correctAnswer_CorrectReturned(final String answerStr) throws Excepti var response = subject().client().post(url(dndQuestion.getId()), answerStr).readEntityAsJson(); assertTrue(response.getBoolean("correct")); - assertEquals( - readEntity(new JSONObject(answerStr), DndChoice.class), - readEntity(response.getJSONObject("answer"), DndChoice.class) - ); + + assertEquals(readChoice(new JSONObject(answerStr)), readChoice(response.getJSONObject("answer"))); } @Test @@ -249,6 +247,13 @@ private T readEntity(final JSONObject value, final Class klass) throws E return contentMapper.getSharedContentObjectMapper().readValue(value.toString(), klass); } + private String readChoice(final JSONObject value) throws Exception { + value.put("tags", new JSONArray()); + value.getJSONArray("items").getJSONObject(0).put("tags", new JSONArray()); + DndChoice parsed = contentMapper.getSharedContentObjectMapper().readValue(value.toString(), DndChoice.class); + return contentMapper.getSharedContentObjectMapper().writeValueAsString(parsed); + } + private static final IsaacStringMatchValidator stringMatchValidator = new IsaacStringMatchValidator(); private static final IsaacDndValidator dndValidator = new IsaacDndValidator(); } From 0d2471e9c6362f91b0c36136a577103d3ccd36d6 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Fri, 12 Dec 2025 17:02:18 +0000 Subject: [PATCH 45/65] test that partial match does not return feedback --- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 650a79b645..4ba858a6f9 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -43,6 +43,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import static org.junit.Assert.assertNull; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -120,6 +121,15 @@ public final void testCorrectness(final CorrectnessTestCase testCase) { .tapQuestion(q -> q.setDefaultFeedback(ExplanationTestCase.testFeedback)) .setAnswer(answer(choose(item_4cm, "leg_1"))) .expectCorrect(false), + new ExplanationTestCase().setTitle("partialMatchIncorrect_shouldReturnDefaultFeedbackForQuestion") + .setQuestion( + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2")), + incorrect(new Content("feedback for choice"), choose(item_5cm, "leg_1"), choose(item_6cm, "leg_2")) + ) + .tapQuestion(q -> q.setDefaultFeedback(new Content("feedback for question"))) + .setAnswer(answer(choose(item_5cm, "leg_1"), choose(item_12cm, "leg_2"))) + .expectCorrect(false) + .expectExplanation("feedback for question"), new ExplanationTestCase().setTitle("defaultCorrect_shouldReturnNone") .setQuestion(correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(item_3cm, "leg_1"))) @@ -133,7 +143,11 @@ public final void testCorrectness(final CorrectnessTestCase testCase) { public final void testExplanation(ExplanationTestCase testCase) { var response = testValidate(testCase.question, testCase.answer); assertEquals(response.isCorrect(), testCase.correct); - assertEquals(response.getExplanation(), testCase.feedback); + if (testCase.feedback != null) { + assertEquals(testCase.feedback.getValue(), response.getExplanation().getValue()); + } else { + assertNull(response.getExplanation()); + } } static Supplier disabledItemFeedbackNoDropZones = () -> new DropZonesTestCase() @@ -230,9 +244,6 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) assertEquals(testCase.dropZonesCorrect, response.getDropZonesCorrect()); } - // TODO: when a partial match contains incorrect items, show feedback about this, - // rather than telling the user they needed to submit more items. - // TODO: check when a non-existing drop zone was used? (and anything that doesn't exist in a correct answer is invalid?) static Supplier itemUnrecognisedFormatCase = () -> new QuestionValidationTestCase() From 1915c3c6624ad04e46c07bb9e591f7311f0e0609 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Fri, 12 Dec 2025 17:28:39 +0000 Subject: [PATCH 46/65] allow explanation wildcard matches for incorrect --- .../cam/cl/dtg/isaac/quiz/IsaacDndValidator.java | 15 ++++++++------- .../cl/dtg/isaac/quiz/IsaacDndValidatorTest.java | 10 ++++++++-- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 9ff7a7cb57..a33c64f969 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -71,9 +71,6 @@ private DndValidationResponse mark(final IsaacDndQuestionEx question, final DndC if (answer.getItems().size() < closestCorrect.getItems().size()) { return new Content("You did not provide a valid answer; it does not contain an item for each gap."); } - if (answer.getItems().size() > closestCorrect.getItems().size()) { - return new Content("You did not provide a valid answer; it contains more items than gaps."); - } return question.getDefaultFeedback(); }); return new DndValidationResponse(question.getId(), answer, isCorrect, dropZonesCorrect, feedback, new Date()); @@ -128,9 +125,10 @@ private Optional validate(final Question question, final Choice answer) .add(Constants.FEEDBACK_NO_ANSWER_PROVIDED, (q, a) -> a.getItems() == null || a.getItems().isEmpty()) .add(Constants.FEEDBACK_UNRECOGNISED_ITEMS, (q, a) -> a.getItems().stream().anyMatch(answerItem -> !q.getItems().contains(answerItem))) - .add(Constants.FEEDBACK_UNRECOGNISED_FORMAT, - (q, a) -> a.getDndItems().anyMatch(i -> i.getId() == null) + .add(Constants.FEEDBACK_UNRECOGNISED_FORMAT, (q, a) -> a.getDndItems().anyMatch(i -> i.getId() == null) || a.getDndItems().anyMatch(i -> i.getDropZoneId() == null)) + .add("You did not provide a valid answer; it contains more items than gaps.", + (q, a) -> a.getItems().size() > q.getAnyCorrect().map(c -> c.getItems().size()).orElse(0)) .check(IsaacDndQuestionEx.of(question), DndChoiceEx.of(answer)); } @@ -164,6 +162,10 @@ public Stream getDndChoices() { public Boolean getDetailedItemFeedback() { return BooleanUtils.isTrue(super.getDetailedItemFeedback()); } + + public Optional getAnyCorrect() { + return this.getDndChoices().filter(DndChoiceEx::isCorrect).findFirst(); + } } private static class DndChoiceEx extends DndChoice { @@ -184,8 +186,7 @@ public Stream getDndItems() { } public boolean matches(final DndChoiceEx rhs) { - return this.getDndItems().allMatch(lhsItem -> dropZoneEql(lhsItem, rhs)) - && super.getItems().size() == rhs.getItems().size(); + return this.getDndItems().allMatch(lhsItem -> dropZoneEql(lhsItem, rhs)); } public int countPartialMatchesIn(final DndChoiceEx rhs) { diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 4ba858a6f9..2ea36570d1 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -116,6 +116,13 @@ public final void testCorrectness(final CorrectnessTestCase testCase) { .tapQuestion(q -> q.setDefaultFeedback(ExplanationTestCase.testFeedback)) .setAnswer(answer(choose(item_4cm, "leg_1"))) .expectCorrect(false), + new ExplanationTestCase().setTitle("matchIncorrectWildcard_shouldReturnMatching") + .setQuestion( + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2")), + incorrect(new Content("leg_1 is not 5"), choose(item_5cm, "leg_1")) + ).setAnswer(answer(choose(item_5cm, "leg_1"), choose(item_6cm, "leg_2"))) + .expectCorrect(false) + .expectExplanation("leg_1 is not 5"), new ExplanationTestCase().setTitle("unMatchedIncorrect_shouldReturnDefaultFeedbackForQuestion") .setQuestion(correct(choose(item_3cm, "leg_1"))) .tapQuestion(q -> q.setDefaultFeedback(ExplanationTestCase.testFeedback)) @@ -209,8 +216,7 @@ public final void testDropZonesCorrect(final DropZonesTestCase testCase) { new AnswerValidationTestCase().setTitle("itemsTooMany") .setQuestion(correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"))) - .expectExplanation("You did not provide a valid answer; it contains more items than gaps.") - .expectDropZonesCorrect(f -> f.setLeg1(true)), + .expectExplanation("You did not provide a valid answer; it contains more items than gaps."), new AnswerValidationTestCase().setTitle("itemNotOnQuestion") .setQuestion(correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(new Item("bad_id", "some_value"), "leg_1"))) From 2cbae107720974162de60d0cf23dc9188d2a2026 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Fri, 12 Dec 2025 17:41:49 +0000 Subject: [PATCH 47/65] test explanation incorrect multimatch --- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 2ea36570d1..3a6da87e3c 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -92,13 +92,6 @@ public final void testCorrectness(final CorrectnessTestCase testCase) { assertEquals(testCase.correct, response.isCorrect()); } - // Test that subset match answers return an appropriate explanation - // TODO: multiple matching explanations - // - on same level? (or even across levels?) - // - should return all? - // - should return just one, but predictably? - // - @DataPoints public static ExplanationTestCase[] explanationTestCases = { new ExplanationTestCase().setTitle("exactMatchIncorrect_shouldReturnMatching") @@ -116,13 +109,20 @@ public final void testCorrectness(final CorrectnessTestCase testCase) { .tapQuestion(q -> q.setDefaultFeedback(ExplanationTestCase.testFeedback)) .setAnswer(answer(choose(item_4cm, "leg_1"))) .expectCorrect(false), - new ExplanationTestCase().setTitle("matchIncorrectWildcard_shouldReturnMatching") + new ExplanationTestCase().setTitle("matchIncorrectSubset_shouldReturnMatching") + .setQuestion( + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2")), + incorrect(ExplanationTestCase.testFeedback, choose(item_5cm, "leg_1")) + ).setAnswer(answer(choose(item_5cm, "leg_1"), choose(item_6cm, "leg_2"))) + .expectCorrect(false), + new ExplanationTestCase().setTitle("multiMatchIncorrectSubset_shouldReturnMatching") .setQuestion( correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2")), - incorrect(new Content("leg_1 is not 5"), choose(item_5cm, "leg_1")) + incorrect(new Content("leg_1 can't be 5"), choose(item_5cm, "leg_1")), + incorrect(new Content("leg_2 can't be 6"), choose(item_6cm, "leg_2")) ).setAnswer(answer(choose(item_5cm, "leg_1"), choose(item_6cm, "leg_2"))) .expectCorrect(false) - .expectExplanation("leg_1 is not 5"), + .expectExplanation("leg_2 can't be 6"), new ExplanationTestCase().setTitle("unMatchedIncorrect_shouldReturnDefaultFeedbackForQuestion") .setQuestion(correct(choose(item_3cm, "leg_1"))) .tapQuestion(q -> q.setDefaultFeedback(ExplanationTestCase.testFeedback)) @@ -132,8 +132,7 @@ public final void testCorrectness(final CorrectnessTestCase testCase) { .setQuestion( correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2")), incorrect(new Content("feedback for choice"), choose(item_5cm, "leg_1"), choose(item_6cm, "leg_2")) - ) - .tapQuestion(q -> q.setDefaultFeedback(new Content("feedback for question"))) + ).tapQuestion(q -> q.setDefaultFeedback(new Content("feedback for question"))) .setAnswer(answer(choose(item_5cm, "leg_1"), choose(item_12cm, "leg_2"))) .expectCorrect(false) .expectExplanation("feedback for question"), From 9dab957f20590065e28d3ecc38ccde389744e828 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 15 Dec 2025 10:42:02 +0000 Subject: [PATCH 48/65] refactor QuestionEx class into QuestionHelpers --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 52 +++++++------------ 1 file changed, 19 insertions(+), 33 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index a33c64f969..c30f92d474 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -15,6 +15,7 @@ */ package uk.ac.cam.cl.dtg.isaac.quiz; +import com.google.common.collect.Lists; import org.apache.commons.lang3.BooleanUtils; import org.slf4j.LoggerFactory; import org.slf4j.Logger; @@ -26,6 +27,7 @@ import uk.ac.cam.cl.dtg.isaac.dos.content.DndChoice; import uk.ac.cam.cl.dtg.isaac.dos.content.DndItem; import uk.ac.cam.cl.dtg.isaac.dos.content.Question; +import uk.ac.cam.cl.dtg.util.mappers.QuestionValidationMapperImpl; import java.util.Date; import java.util.List; @@ -45,12 +47,11 @@ public class IsaacDndValidator implements IValidator { public final DndValidationResponse validateQuestionResponse(final Question question, final Choice answer) { return validate(question, answer) .map(msg -> new DndValidationResponse(question.getId(), answer, false, null, new Content(msg), new Date())) - .orElseGet(() -> mark(IsaacDndQuestionEx.of(question), DndChoiceEx.of(answer))); + .orElseGet(() -> mark((IsaacDndQuestion) question, DndChoiceEx.of(answer))); } - private DndValidationResponse mark(final IsaacDndQuestionEx question, final DndChoiceEx answer) { - // TODO: extract sorting, to comply with IValidator interface - List sortedAnswers = question.getDndChoices() + private DndValidationResponse mark(final IsaacDndQuestion question, final DndChoiceEx answer) { + List sortedAnswers = QuestionHelpers.getChoices(question) .sorted((rhs, lhs) -> { int compared = lhs.countPartialMatchesIn(answer) - rhs.countPartialMatchesIn(answer); if (compared == 0) { @@ -63,7 +64,7 @@ private DndValidationResponse mark(final IsaacDndQuestionEx question, final DndC DndChoiceEx closestCorrect = sortedAnswers.stream().filter(DndChoiceEx::isCorrect).findFirst().orElse(null); var isCorrect = matchedAnswer.map(DndChoiceEx::isCorrect).orElse(false); - var dropZonesCorrect = question.getDetailedItemFeedback() ? closestCorrect.getDropZonesCorrect(answer) : null; + var dropZonesCorrect = QuestionHelpers.getDetailedItemFeedback(question) ? closestCorrect.getDropZonesCorrect(answer) : null; var feedback = (Content) matchedAnswer.map(Choice::getExplanation).orElseGet(() -> { if (isCorrect) { return null; @@ -90,7 +91,7 @@ private Optional validate(final Question question, final Choice answer) "This validator only works with IsaacDndQuestions (%s is not IsaacDndQuestion)", question.getId())); } - return new ValidationUtils.BiRuleValidator() + return new ValidationUtils.BiRuleValidator() // question .add(Constants.FEEDBACK_NO_CORRECT_ANSWERS, (q, a) -> logged( q.getChoices() == null || q.getChoices().isEmpty(), @@ -104,15 +105,15 @@ private Optional validate(final Question question, final Choice answer) !DndChoice.class.equals(c.getClass()), "Expected DndItem in question (%s), instead found %s!", q.getId(), c.getClass() ))) - .add("This question contains an empty answer.", (q, a) -> q.getDndChoices().anyMatch(c -> logged( + .add("This question contains an empty answer.", (q, a) -> QuestionHelpers.getChoices(q).anyMatch(c -> logged( c.getItems() == null || c.getItems().isEmpty(), "Expected list of DndItems, but none found in choice for question id (%s)!", q.getId() ))) - .add("This question contains at least one invalid answer.", (q, a) -> q.getDndChoices().anyMatch(c -> logged( + .add("This question contains at least one invalid answer.", (q, a) -> QuestionHelpers.getChoices(q).anyMatch(c -> logged( c.getItems().stream().anyMatch(i -> i.getClass() != DndItem.class), "Expected list of DndItems, but something else found in choice for question id (%s)!", q.getId() ))) - .add("This question contains at least one answer in an unrecognised format.", (q, a) -> q.getDndChoices().anyMatch(c -> logged( + .add("This question contains at least one answer in an unrecognised format.", (q, a) -> QuestionHelpers.getChoices(q).anyMatch(c -> logged( c.getDndItems().anyMatch(i -> i.getId() == null || i.getDropZoneId() == null || Objects.equals(i.getId(), "") || Objects.equals(i.getDropZoneId(), "")), "Found item with missing id or drop zone id in answer for question id (%s)!", q.getId() ))) @@ -128,8 +129,8 @@ private Optional validate(final Question question, final Choice answer) .add(Constants.FEEDBACK_UNRECOGNISED_FORMAT, (q, a) -> a.getDndItems().anyMatch(i -> i.getId() == null) || a.getDndItems().anyMatch(i -> i.getDropZoneId() == null)) .add("You did not provide a valid answer; it contains more items than gaps.", - (q, a) -> a.getItems().size() > q.getAnyCorrect().map(c -> c.getItems().size()).orElse(0)) - .check(IsaacDndQuestionEx.of(question), DndChoiceEx.of(answer)); + (q, a) -> a.getItems().size() > QuestionHelpers.getAnyCorrect(q).map(c -> c.getItems().size()).orElse(0)) + .check((IsaacDndQuestion) question, DndChoiceEx.of(answer)); } private boolean logged(final boolean result, final String message, final Object... args) { @@ -139,32 +140,17 @@ private boolean logged(final boolean result, final String message, final Object. return result; } - private static class IsaacDndQuestionEx extends IsaacDndQuestion { - public static IsaacDndQuestionEx of(final Question question) { - return new IsaacDndQuestionEx((IsaacDndQuestion) question); + private static class QuestionHelpers { + public static Stream getChoices(final IsaacDndQuestion question) { + return question.getChoices().stream().map(DndChoiceEx::of); } - public IsaacDndQuestionEx(final IsaacDndQuestion question) { - super(); - setItems(question.getItems()); - setId(question.getId()); - setCanonicalSourceFile(question.getCanonicalSourceFile()); - setChoices(question.getChoices()); - setDetailedItemFeedback(question.getDetailedItemFeedback()); - setDefaultFeedback(question.getDefaultFeedback()); - } - - public Stream getDndChoices() { - return super.getChoices().stream().map(DndChoiceEx::of); - } - - @Override - public Boolean getDetailedItemFeedback() { - return BooleanUtils.isTrue(super.getDetailedItemFeedback()); + public static Optional getAnyCorrect(final IsaacDndQuestion question) { + return getChoices(question).filter(DndChoice::isCorrect).findFirst(); } - public Optional getAnyCorrect() { - return this.getDndChoices().filter(DndChoiceEx::isCorrect).findFirst(); + public static boolean getDetailedItemFeedback(final IsaacDndQuestion question) { + return BooleanUtils.isTrue(question.getDetailedItemFeedback()); } } From de0f5988e6959dc713d8a8a1b68dd90ce1b74d19 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 15 Dec 2025 10:52:54 +0000 Subject: [PATCH 49/65] refactor ChoiceEx class into ChoiceHelpers --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 88 ++++++++----------- 1 file changed, 37 insertions(+), 51 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index c30f92d474..321efbe040 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -15,10 +15,9 @@ */ package uk.ac.cam.cl.dtg.isaac.quiz; -import com.google.common.collect.Lists; import org.apache.commons.lang3.BooleanUtils; -import org.slf4j.LoggerFactory; import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import uk.ac.cam.cl.dtg.isaac.api.Constants; import uk.ac.cam.cl.dtg.isaac.dos.DndValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; @@ -27,7 +26,6 @@ import uk.ac.cam.cl.dtg.isaac.dos.content.DndChoice; import uk.ac.cam.cl.dtg.isaac.dos.content.DndItem; import uk.ac.cam.cl.dtg.isaac.dos.content.Question; -import uk.ac.cam.cl.dtg.util.mappers.QuestionValidationMapperImpl; import java.util.Date; import java.util.List; @@ -47,24 +45,24 @@ public class IsaacDndValidator implements IValidator { public final DndValidationResponse validateQuestionResponse(final Question question, final Choice answer) { return validate(question, answer) .map(msg -> new DndValidationResponse(question.getId(), answer, false, null, new Content(msg), new Date())) - .orElseGet(() -> mark((IsaacDndQuestion) question, DndChoiceEx.of(answer))); + .orElseGet(() -> mark((IsaacDndQuestion) question, (DndChoice) answer)); } - private DndValidationResponse mark(final IsaacDndQuestion question, final DndChoiceEx answer) { - List sortedAnswers = QuestionHelpers.getChoices(question) + private DndValidationResponse mark(final IsaacDndQuestion question, final DndChoice answer) { + List sortedAnswers = QuestionHelpers.getChoices(question) .sorted((rhs, lhs) -> { - int compared = lhs.countPartialMatchesIn(answer) - rhs.countPartialMatchesIn(answer); + int compared = ChoiceHelpers.countPartialMatchesIn(lhs, answer) - ChoiceHelpers.countPartialMatchesIn(rhs, answer); if (compared == 0) { return lhs.isCorrect() && rhs.isCorrect() ? 0 : (lhs.isCorrect() ? 1 : -1); } return compared; }) .collect(Collectors.toList()); - Optional matchedAnswer = sortedAnswers.stream().filter(lhs -> lhs.matches(answer)).findFirst(); - DndChoiceEx closestCorrect = sortedAnswers.stream().filter(DndChoiceEx::isCorrect).findFirst().orElse(null); + Optional matchedAnswer = sortedAnswers.stream().filter(lhs -> ChoiceHelpers.matches(lhs, answer)).findFirst(); + DndChoice closestCorrect = sortedAnswers.stream().filter(DndChoice::isCorrect).findFirst().orElse(null); - var isCorrect = matchedAnswer.map(DndChoiceEx::isCorrect).orElse(false); - var dropZonesCorrect = QuestionHelpers.getDetailedItemFeedback(question) ? closestCorrect.getDropZonesCorrect(answer) : null; + var isCorrect = matchedAnswer.map(DndChoice::isCorrect).orElse(false); + var dropZonesCorrect = QuestionHelpers.getDetailedItemFeedback(question) ? ChoiceHelpers.getDropZonesCorrect(closestCorrect, answer) : null; var feedback = (Content) matchedAnswer.map(Choice::getExplanation).orElseGet(() -> { if (isCorrect) { return null; @@ -91,7 +89,7 @@ private Optional validate(final Question question, final Choice answer) "This validator only works with IsaacDndQuestions (%s is not IsaacDndQuestion)", question.getId())); } - return new ValidationUtils.BiRuleValidator() + return new ValidationUtils.BiRuleValidator() // question .add(Constants.FEEDBACK_NO_CORRECT_ANSWERS, (q, a) -> logged( q.getChoices() == null || q.getChoices().isEmpty(), @@ -114,7 +112,7 @@ private Optional validate(final Question question, final Choice answer) "Expected list of DndItems, but something else found in choice for question id (%s)!", q.getId() ))) .add("This question contains at least one answer in an unrecognised format.", (q, a) -> QuestionHelpers.getChoices(q).anyMatch(c -> logged( - c.getDndItems().anyMatch(i -> i.getId() == null || i.getDropZoneId() == null || Objects.equals(i.getId(), "") || Objects.equals(i.getDropZoneId(), "")), + ChoiceHelpers.getItems(c).anyMatch(i -> i.getId() == null || i.getDropZoneId() == null || Objects.equals(i.getId(), "") || Objects.equals(i.getDropZoneId(), "")), "Found item with missing id or drop zone id in answer for question id (%s)!", q.getId() ))) .add("This question is missing items", (q, a) -> logged( @@ -126,11 +124,11 @@ private Optional validate(final Question question, final Choice answer) .add(Constants.FEEDBACK_NO_ANSWER_PROVIDED, (q, a) -> a.getItems() == null || a.getItems().isEmpty()) .add(Constants.FEEDBACK_UNRECOGNISED_ITEMS, (q, a) -> a.getItems().stream().anyMatch(answerItem -> !q.getItems().contains(answerItem))) - .add(Constants.FEEDBACK_UNRECOGNISED_FORMAT, (q, a) -> a.getDndItems().anyMatch(i -> i.getId() == null) - || a.getDndItems().anyMatch(i -> i.getDropZoneId() == null)) + .add(Constants.FEEDBACK_UNRECOGNISED_FORMAT, (q, a) -> ChoiceHelpers.getItems(a).anyMatch(i -> i.getId() == null) + || ChoiceHelpers.getItems(a).anyMatch(i -> i.getDropZoneId() == null)) .add("You did not provide a valid answer; it contains more items than gaps.", (q, a) -> a.getItems().size() > QuestionHelpers.getAnyCorrect(q).map(c -> c.getItems().size()).orElse(0)) - .check((IsaacDndQuestion) question, DndChoiceEx.of(answer)); + .check((IsaacDndQuestion) question, (DndChoice) answer); } private boolean logged(final boolean result, final String message, final Object... args) { @@ -141,11 +139,11 @@ private boolean logged(final boolean result, final String message, final Object. } private static class QuestionHelpers { - public static Stream getChoices(final IsaacDndQuestion question) { - return question.getChoices().stream().map(DndChoiceEx::of); + public static Stream getChoices(final IsaacDndQuestion question) { + return question.getChoices().stream().map(c -> (DndChoice) c); } - public static Optional getAnyCorrect(final IsaacDndQuestion question) { + public static Optional getAnyCorrect(final IsaacDndQuestion question) { return getChoices(question).filter(DndChoice::isCorrect).findFirst(); } @@ -154,53 +152,41 @@ public static boolean getDetailedItemFeedback(final IsaacDndQuestion question) { } } - private static class DndChoiceEx extends DndChoice { - public static DndChoiceEx of(final Choice c) { - return new DndChoiceEx((DndChoice) c); - } - - DndChoiceEx(final DndChoice choice) { - super(); - setItems(choice.getItems()); - setCorrect(choice.isCorrect()); - setExplanation(choice.getExplanation()); - setType(choice.getType()); - } - - public Stream getDndItems() { - return super.getItems().stream().map(i -> (DndItem) i); + private static class ChoiceHelpers { + public static Stream getItems(final DndChoice choice) { + return choice.getItems().stream().map(i -> (DndItem) i); } - public boolean matches(final DndChoiceEx rhs) { - return this.getDndItems().allMatch(lhsItem -> dropZoneEql(lhsItem, rhs)); + public static boolean matches(final DndChoice lhs, final DndChoice rhs) { + return getItems(lhs).allMatch(lhsItem -> dropZoneEql(rhs, lhsItem)); } - public int countPartialMatchesIn(final DndChoiceEx rhs) { - return this.getDndItems() - .map(lhsItem -> dropZoneEql(lhsItem, rhs) ? 1 : 0) + public static int countPartialMatchesIn(final DndChoice lhs, final DndChoice rhs) { + return getItems(lhs) + .map(lhsItem -> dropZoneEql(rhs, lhsItem) ? 1 : 0) .mapToInt(Integer::intValue) .sum(); } - public Map getDropZonesCorrect(final DndChoiceEx rhs) { - return this.getDndItems() - .filter(lhsItem -> rhs.getItemByDropZone(lhsItem.getDropZoneId()).isPresent()) + public static Map getDropZonesCorrect(final DndChoice lhs, final DndChoice rhs) { + return getItems(lhs) + .filter(lhsItem -> getItemByDropZone(rhs, lhsItem.getDropZoneId()).isPresent()) .collect(Collectors.toMap( DndItem::getDropZoneId, - lhsItem -> dropZoneEql(lhsItem, rhs)) + lhsItem -> dropZoneEql(rhs, lhsItem)) ); } - private static boolean dropZoneEql(final DndItem lhsItem, final DndChoiceEx rhs) { - return rhs.getItemByDropZone(lhsItem.getDropZoneId()) - .map(rhsItem -> rhsItem.getId().equals(lhsItem.getId())) - .orElse(false); + private static boolean dropZoneEql(final DndChoice choice, final DndItem lhsItem) { + return getItemByDropZone(choice, lhsItem.getDropZoneId()) + .map(rhsItem -> rhsItem.getId().equals(lhsItem.getId())) + .orElse(false); } - private Optional getItemByDropZone(final String dropZoneId) { - return this.getDndItems() - .filter(item -> item.getDropZoneId().equals(dropZoneId)) - .findFirst(); + private static Optional getItemByDropZone(final DndChoice choice, final String dropZoneId) { + return getItems(choice) + .filter(item -> item.getDropZoneId().equals(dropZoneId)) + .findFirst(); } } } From 3a7f89d79a60a5c76fa2f53d2da4523d298f2891 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 15 Dec 2025 11:37:35 +0000 Subject: [PATCH 50/65] test null explanation for incorrect answer --- .../uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java | 8 ++++---- .../ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java | 8 ++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 321efbe040..1387764ffa 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -179,14 +179,14 @@ public static Map getDropZonesCorrect(final DndChoice lhs, fina private static boolean dropZoneEql(final DndChoice choice, final DndItem lhsItem) { return getItemByDropZone(choice, lhsItem.getDropZoneId()) - .map(rhsItem -> rhsItem.getId().equals(lhsItem.getId())) - .orElse(false); + .map(rhsItem -> rhsItem.getId().equals(lhsItem.getId())) + .orElse(false); } private static Optional getItemByDropZone(final DndChoice choice, final String dropZoneId) { return getItems(choice) - .filter(item -> item.getDropZoneId().equals(dropZoneId)) - .findFirst(); + .filter(item -> item.getDropZoneId().equals(dropZoneId)) + .findFirst(); } } } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 3a6da87e3c..340e329133 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -140,8 +140,12 @@ public final void testCorrectness(final CorrectnessTestCase testCase) { .setQuestion(correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(item_3cm, "leg_1"))) .expectNoExplanation() - .expectCorrect(true) - // todo expect null explanation on incorrect answer? (check cloze behaviour) + .expectCorrect(true), + new ExplanationTestCase().setTitle("noDefaultIncorrect_shouldReturnNone") + .setQuestion(correct(choose(item_3cm, "leg_1"))) + .setAnswer(answer(choose(item_4cm, "leg_1"))) + .expectNoExplanation() + .expectCorrect(false) }; From 8830d428ec34d8914c59af03b990cd394b9b907a Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 15 Dec 2025 14:34:40 +0000 Subject: [PATCH 51/65] validate that question has dropZones --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 49 +++++++++--- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 76 ++++++++++++++++++- 2 files changed, 115 insertions(+), 10 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 1387764ffa..6f3430b457 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -23,6 +23,7 @@ import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; import uk.ac.cam.cl.dtg.isaac.dos.content.Content; +import uk.ac.cam.cl.dtg.isaac.dos.content.ContentBase; import uk.ac.cam.cl.dtg.isaac.dos.content.DndChoice; import uk.ac.cam.cl.dtg.isaac.dos.content.DndItem; import uk.ac.cam.cl.dtg.isaac.dos.content.Question; @@ -32,6 +33,8 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.regex.MatchResult; +import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -51,18 +54,20 @@ public final DndValidationResponse validateQuestionResponse(final Question quest private DndValidationResponse mark(final IsaacDndQuestion question, final DndChoice answer) { List sortedAnswers = QuestionHelpers.getChoices(question) .sorted((rhs, lhs) -> { - int compared = ChoiceHelpers.countPartialMatchesIn(lhs, answer) - ChoiceHelpers.countPartialMatchesIn(rhs, answer); + int compared = ChoiceHelpers.countPartialMatchesIn(lhs, answer) + - ChoiceHelpers.countPartialMatchesIn(rhs, answer); if (compared == 0) { return lhs.isCorrect() && rhs.isCorrect() ? 0 : (lhs.isCorrect() ? 1 : -1); } return compared; }) .collect(Collectors.toList()); - Optional matchedAnswer = sortedAnswers.stream().filter(lhs -> ChoiceHelpers.matches(lhs, answer)).findFirst(); - DndChoice closestCorrect = sortedAnswers.stream().filter(DndChoice::isCorrect).findFirst().orElse(null); + var matchedAnswer = sortedAnswers.stream().filter(lhs -> ChoiceHelpers.matches(lhs, answer)).findFirst(); + var closestCorrect = sortedAnswers.stream().filter(DndChoice::isCorrect).findFirst().orElse(null); var isCorrect = matchedAnswer.map(DndChoice::isCorrect).orElse(false); - var dropZonesCorrect = QuestionHelpers.getDetailedItemFeedback(question) ? ChoiceHelpers.getDropZonesCorrect(closestCorrect, answer) : null; + var dropZonesCorrect = QuestionHelpers.getDetailedItemFeedback(question) + ? ChoiceHelpers.getDropZonesCorrect(closestCorrect, answer) : null; var feedback = (Content) matchedAnswer.map(Choice::getExplanation).orElseGet(() -> { if (isCorrect) { return null; @@ -119,6 +124,10 @@ private Optional validate(final Question question, final Choice answer) q.getItems() == null || q.getItems().isEmpty(), "Expected items in question (%s), but didn't find any!", q.getId() )) + .add("Question without dropZones found", (q, a) -> logged( + QuestionHelpers.getDropZones(q).isEmpty(), + "Question does not have any drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile() + )) // answer .add(Constants.FEEDBACK_NO_ANSWER_PROVIDED, (q, a) -> a.getItems() == null || a.getItems().isEmpty()) @@ -131,14 +140,14 @@ private Optional validate(final Question question, final Choice answer) .check((IsaacDndQuestion) question, (DndChoice) answer); } - private boolean logged(final boolean result, final String message, final Object... args) { + private static boolean logged(final boolean result, final String message, final Object... args) { if (result) { log.error(String.format(message, args)); } return result; } - private static class QuestionHelpers { + public static class QuestionHelpers { public static Stream getChoices(final IsaacDndQuestion question) { return question.getChoices().stream().map(c -> (DndChoice) c); } @@ -150,6 +159,28 @@ public static Optional getAnyCorrect(final IsaacDndQuestion question) public static boolean getDetailedItemFeedback(final IsaacDndQuestion question) { return BooleanUtils.isTrue(question.getDetailedItemFeedback()); } + + public static List getDropZones(final IsaacDndQuestion question) { + if (question.getChildren() == null) { + return List.of(); + } + return question.getChildren().stream() + .flatMap(QuestionHelpers::getContentDropZones) + .collect(Collectors.toList()); + } + + public static Stream getContentDropZones(final ContentBase content) { + if (content instanceof Content && ((Content) content).getValue() != null) { + var textContent = (Content) content; + String dndDropZoneRegexStr = "\\[drop-zone:(?[a-zA-Z0-9_-]+)(?\\|(?w-\\d+?)?(?h-\\d+?)?)?\\]"; + Pattern dndDropZoneRegex = Pattern.compile(dndDropZoneRegexStr); + return dndDropZoneRegex.matcher(textContent.getValue()).results().map(mr -> mr.group(1)); + } + if (content instanceof Content && ((Content) content).getChildren() != null) { + return ((Content) content).getChildren().stream().flatMap(QuestionHelpers::getContentDropZones); + } + return Stream.of(); + } } private static class ChoiceHelpers { @@ -177,9 +208,9 @@ public static Map getDropZonesCorrect(final DndChoice lhs, fina ); } - private static boolean dropZoneEql(final DndChoice choice, final DndItem lhsItem) { - return getItemByDropZone(choice, lhsItem.getDropZoneId()) - .map(rhsItem -> rhsItem.getId().equals(lhsItem.getId())) + private static boolean dropZoneEql(final DndChoice choice, final DndItem item) { + return getItemByDropZone(choice, item.getDropZoneId()) + .map(choiceItem -> choiceItem.getId().equals(item.getId())) .orElse(false); } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 340e329133..24d6291502 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -32,7 +32,9 @@ import uk.ac.cam.cl.dtg.isaac.dos.content.DndItem; import uk.ac.cam.cl.dtg.isaac.dos.content.Item; +import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.UUID; @@ -304,7 +306,11 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) questionEmptyAnswersTestCase.get().setTitle("items is null") .tapQuestion(q -> q.setItems(null)), questionEmptyAnswersTestCase.get().setTitle("items is empty") - .tapQuestion(q -> q.setItems(List.of())) + .tapQuestion(q -> q.setItems(List.of())), + new QuestionValidationTestCase().setTitle("has no drop zones") + .setChildren(null) + .expectExplanation("Question without dropZones found") + .expectLogMessage(q -> String.format("Question does not have any drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile()) ) }; @Theory @@ -321,6 +327,49 @@ public final void testQuestionValidation(final QuestionValidationTestCase testCa appender.assertMessage(testCase.loggedMessage); } + static Supplier invalidDropZone = () -> new GetDropZonesTestCase() + .expectDropZones(List.of()); + + @DataPoints + public static GetDropZonesTestCase[] getDropZonesTestCases = { + invalidDropZone.get().setContentChild(""), + invalidDropZone.get().setContentChild("no drop zone"), + invalidDropZone.get().setContentChild("[drop-zone A1]"), + invalidDropZone.get().setContentChild("[drop-zone: A1]"), + invalidDropZone.get().setContentChild("[drop-zone:A1 | w-100]"), + invalidDropZone.get().setContentChild("[drop-zone:A1|w-100 h-50]"), + invalidDropZone.get().setContentChild("[drop-zone:A1|h-100w-50]"), + new GetDropZonesTestCase().setTitle("noContent_noDropZones").setChildren(null).expectDropZones(List.of()), + new GetDropZonesTestCase().setTitle("singleDropZoneSingleText_returnsDropZone") + .setContentChild("[drop-zone:A1]") + .expectDropZones(List.of("A1")), + new GetDropZonesTestCase().setTitle("singleDropZoneSingleContent_returnsDropZone") + .setContentChild("[drop-zone:A1|w-100]") + .expectDropZones(List.of("A1")), + new GetDropZonesTestCase().setTitle("singleDropZoneSingleContentHeight_returnsDropZone") + .setContentChild("[drop-zone:A1|h-100]") + .expectDropZones(List.of("A1")), + new GetDropZonesTestCase().setTitle("singleDropZoneSingleContentWidthHeight_returnsDropZone") + .setContentChild("[drop-zone:A1|w-100h-50]") + .expectDropZones(List.of("A1")), + new GetDropZonesTestCase().setTitle("multiDropZoneSingleContent_returnsDropZone") + .setContentChild("Some text [drop-zone:A1], other text [drop-zone:A2]") + .expectDropZones(List.of("A1", "A2")), + new GetDropZonesTestCase().setTitle("singleDropZoneMultiContent_returnsDropZone") + .setContentChild("[drop-zone:A1]").addContentChild("[drop-zone:A2]") + .expectDropZones(List.of("A1", "A2")), + new GetDropZonesTestCase().setTitle("singleDropZoneNestedContent_returnsDropZone") + .setChildren(new LinkedList<>(List.of(new Content()))).addContentChild("[drop-zone:A2]") + .tapQuestion(q -> ((Content) q.getChildren().get(0)).setChildren(List.of(new Content("[drop-zone:A1]")))) + .expectDropZones(List.of("A1", "A2")) + }; + + @Theory + public final void testGetDropZones(final GetDropZonesTestCase testCase) { + var dropZones = IsaacDndValidator.QuestionHelpers.getDropZones(testCase.question); + assertEquals(testCase.dropZones, dropZones); + } + private static DndValidationResponse testValidate(final IsaacDndQuestion question, final Choice choice) { return new IsaacDndValidator().validateQuestionResponse(question, choice); } @@ -358,6 +407,7 @@ public static IsaacDndQuestion createQuestion(final DndChoice... answers) { question.setItems(List.of(item_3cm, item_4cm, item_5cm, item_6cm, item_12cm, item_13cm)); question.setChoices(List.of(answers)); question.setType("isaacDndQuestion"); + question.setChildren(List.of(new Content("[drop-zone:leg_1] [drop-zone:leg_2] [drop-zone:hypothenuse]"))); return question; } @@ -423,6 +473,7 @@ static class TestCase> { public DndChoice answer = answer(); public Content feedback = testFeedback; public Map dropZonesCorrect; + public List dropZones; public String loggedMessage; public boolean correct = false; private Function logMessageOp; @@ -444,6 +495,21 @@ public T setQuestion(final Consumer op) { return self(); } + public T setChildren(final List content) { + question.setChildren(content); + return self(); + } + + public T setContentChild(final String content) { + question.setChildren(new ArrayList<>(List.of(new Content(content)))); + return self(); + } + + public T addContentChild(final String content) { + question.getChildren().add(new Content(content)); + return self(); + } + public T tapQuestion(final Consumer op) { this.questionOp = op; return self(); @@ -484,6 +550,11 @@ public T expectLogMessage(final Function op) { return self(); } + public T expectDropZones(final List dropZones) { + this.dropZones = dropZones; + return self(); + } + private T self() { if (this.logMessageOp != null) { this.loggedMessage = logMessageOp.apply(this.question); @@ -505,9 +576,12 @@ public static class ExplanationTestCase extends TestCase {} public static class DropZonesTestCase extends TestCase {} + public static class GetDropZonesTestCase extends TestCase {} + public static class DndItemEx extends DndItem { public DndItemEx(final String id, final String value, final String dropZoneId) { super(id, value, dropZoneId); } } + } From 6da7eeef1711fcbccaef32d2bd2c297a216d8de2 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 15 Dec 2025 15:05:42 +0000 Subject: [PATCH 52/65] support finding dropzones in figures --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 7 ++ .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 66 ++++++++++++++----- 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 6f3430b457..1a5cc403ec 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -26,7 +26,9 @@ import uk.ac.cam.cl.dtg.isaac.dos.content.ContentBase; import uk.ac.cam.cl.dtg.isaac.dos.content.DndChoice; import uk.ac.cam.cl.dtg.isaac.dos.content.DndItem; +import uk.ac.cam.cl.dtg.isaac.dos.content.Figure; import uk.ac.cam.cl.dtg.isaac.dos.content.Question; +import uk.ac.cam.cl.dtg.util.FigureRegion; import java.util.Date; import java.util.List; @@ -170,6 +172,10 @@ public static List getDropZones(final IsaacDndQuestion question) { } public static Stream getContentDropZones(final ContentBase content) { + if (content instanceof Figure) { + var figure = (Figure) content; + return figure.getFigureRegions().stream().map(FigureRegion::getId); + } if (content instanceof Content && ((Content) content).getValue() != null) { var textContent = (Content) content; String dndDropZoneRegexStr = "\\[drop-zone:(?[a-zA-Z0-9_-]+)(?\\|(?w-\\d+?)?(?h-\\d+?)?)?\\]"; @@ -179,6 +185,7 @@ public static Stream getContentDropZones(final ContentBase content) { if (content instanceof Content && ((Content) content).getChildren() != null) { return ((Content) content).getChildren().stream().flatMap(QuestionHelpers::getContentDropZones); } + return Stream.of(); } } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 24d6291502..c650169bff 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -30,6 +30,7 @@ import uk.ac.cam.cl.dtg.isaac.dos.content.ContentBase; import uk.ac.cam.cl.dtg.isaac.dos.content.DndChoice; import uk.ac.cam.cl.dtg.isaac.dos.content.DndItem; +import uk.ac.cam.cl.dtg.isaac.dos.content.Figure; import uk.ac.cam.cl.dtg.isaac.dos.content.Item; import java.util.ArrayList; @@ -51,6 +52,7 @@ import org.apache.logging.log4j.core.Logger; import uk.ac.cam.cl.dtg.isaac.dos.content.ParsonsChoice; +import uk.ac.cam.cl.dtg.util.FigureRegion; @RunWith(Theories.class) @SuppressWarnings("checkstyle:MissingJavadocType") @@ -310,7 +312,7 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) new QuestionValidationTestCase().setTitle("has no drop zones") .setChildren(null) .expectExplanation("Question without dropZones found") - .expectLogMessage(q -> String.format("Question does not have any drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile()) ) + .expectLogMessage(q -> String.format("Question does not have any drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile())) }; @Theory @@ -328,7 +330,7 @@ public final void testQuestionValidation(final QuestionValidationTestCase testCa } static Supplier invalidDropZone = () -> new GetDropZonesTestCase() - .expectDropZones(List.of()); + .expectDropZones(); @DataPoints public static GetDropZonesTestCase[] getDropZonesTestCases = { @@ -339,29 +341,48 @@ public final void testQuestionValidation(final QuestionValidationTestCase testCa invalidDropZone.get().setContentChild("[drop-zone:A1 | w-100]"), invalidDropZone.get().setContentChild("[drop-zone:A1|w-100 h-50]"), invalidDropZone.get().setContentChild("[drop-zone:A1|h-100w-50]"), - new GetDropZonesTestCase().setTitle("noContent_noDropZones").setChildren(null).expectDropZones(List.of()), + new GetDropZonesTestCase().setTitle("noContent_noDropZones").setChildren(null).expectDropZones(), new GetDropZonesTestCase().setTitle("singleDropZoneSingleText_returnsDropZone") .setContentChild("[drop-zone:A1]") - .expectDropZones(List.of("A1")), + .expectDropZones("A1"), new GetDropZonesTestCase().setTitle("singleDropZoneSingleContent_returnsDropZone") .setContentChild("[drop-zone:A1|w-100]") - .expectDropZones(List.of("A1")), + .expectDropZones("A1"), new GetDropZonesTestCase().setTitle("singleDropZoneSingleContentHeight_returnsDropZone") .setContentChild("[drop-zone:A1|h-100]") - .expectDropZones(List.of("A1")), + .expectDropZones("A1"), new GetDropZonesTestCase().setTitle("singleDropZoneSingleContentWidthHeight_returnsDropZone") .setContentChild("[drop-zone:A1|w-100h-50]") - .expectDropZones(List.of("A1")), - new GetDropZonesTestCase().setTitle("multiDropZoneSingleContent_returnsDropZone") + .expectDropZones("A1"), + new GetDropZonesTestCase().setTitle("multiDropZoneSingleContent_returnsDropZones") .setContentChild("Some text [drop-zone:A1], other text [drop-zone:A2]") - .expectDropZones(List.of("A1", "A2")), - new GetDropZonesTestCase().setTitle("singleDropZoneMultiContent_returnsDropZone") - .setContentChild("[drop-zone:A1]").addContentChild("[drop-zone:A2]") - .expectDropZones(List.of("A1", "A2")), - new GetDropZonesTestCase().setTitle("singleDropZoneNestedContent_returnsDropZone") - .setChildren(new LinkedList<>(List.of(new Content()))).addContentChild("[drop-zone:A2]") + .expectDropZones("A1", "A2"), + new GetDropZonesTestCase().setTitle("multiDropZoneMultiContent_returnsDropZones") + .setContentChild("[drop-zone:A1] [drop-zone:A2]") + .addContentChild("[drop-zone:A3] [drop-zone:A4]") + .expectDropZones("A1", "A2", "A3", "A4"), + new GetDropZonesTestCase().setTitle("singleDropZoneNestedContent_returnsDropZones") + .setChildren(new LinkedList<>(List.of(new Content()))) + .addContentChild("[drop-zone:A2]") .tapQuestion(q -> ((Content) q.getChildren().get(0)).setChildren(List.of(new Content("[drop-zone:A1]")))) - .expectDropZones(List.of("A1", "A2")) + .expectDropZones("A1", "A2"), + new GetDropZonesTestCase().setTitle("figureContent_returnsDropZones") + .setChildren(List.of(createFigure("A1", "A2"))) + .expectDropZones("A1", "A2"), + new GetDropZonesTestCase().setTitle("mixedButNoNesting_returnsDropZones") + .setChildren(new LinkedList<>(List.of(createFigure("A1", "A2")))) + .addContentChild("[drop-zone:A3]") + .expectDropZones("A1", "A2", "A3"), + new GetDropZonesTestCase().setTitle("mixedNested_returnsDropZones") + .setChildren(new LinkedList<>(List.of(new Content()))) + .addContentChild("[drop-zone:A2]") + .tapQuestion(q -> { + Content content = (Content) q.getChildren().get(0); + content.setChildren(List.of( + new Content("[drop-zone:A1]"), + createFigure("F1", "F2") + )); + }).expectDropZones("A1", "F1", "F2", "A2") }; @Theory @@ -464,6 +485,17 @@ public Map build() { } } + public static Figure createFigure(final String... dropZones) { + var figure = new Figure(); + figure.setFigureRegions(new ArrayList<>(List.of())); + List.of(dropZones).forEach(dropZoneId -> { + var region = new FigureRegion(); + region.setId(dropZoneId); + figure.getFigureRegions().add(region); + }); + return figure; + } + static class TestCase> { public static Content testFeedback = new Content("some test feedback"); @@ -550,8 +582,8 @@ public T expectLogMessage(final Function op) { return self(); } - public T expectDropZones(final List dropZones) { - this.dropZones = dropZones; + public T expectDropZones(final String... dropZones) { + this.dropZones = List.of(dropZones); return self(); } From d76ecdb47134ad9f68a494180c428e6be9f34d92 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 15 Dec 2025 15:08:44 +0000 Subject: [PATCH 53/65] handle figures without regions --- .../java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java | 4 ++-- .../uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 1a5cc403ec..dd6cd4dac4 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -172,13 +172,13 @@ public static List getDropZones(final IsaacDndQuestion question) { } public static Stream getContentDropZones(final ContentBase content) { - if (content instanceof Figure) { + if (content instanceof Figure && ((Figure) content).getFigureRegions() != null) { var figure = (Figure) content; return figure.getFigureRegions().stream().map(FigureRegion::getId); } if (content instanceof Content && ((Content) content).getValue() != null) { var textContent = (Content) content; - String dndDropZoneRegexStr = "\\[drop-zone:(?[a-zA-Z0-9_-]+)(?\\|(?w-\\d+?)?(?h-\\d+?)?)?\\]"; + String dndDropZoneRegexStr = "\\[drop-zone:(?[a-zA-Z0-9_-]+)(?\\|(?w-\\d+?)?(?h-\\d+?)?)?]"; Pattern dndDropZoneRegex = Pattern.compile(dndDropZoneRegexStr); return dndDropZoneRegex.matcher(textContent.getValue()).results().map(mr -> mr.group(1)); } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index c650169bff..eb8e7941b4 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -366,6 +366,9 @@ public final void testQuestionValidation(final QuestionValidationTestCase testCa .addContentChild("[drop-zone:A2]") .tapQuestion(q -> ((Content) q.getChildren().get(0)).setChildren(List.of(new Content("[drop-zone:A1]")))) .expectDropZones("A1", "A2"), + new GetDropZonesTestCase().setTitle("figureContentWithoutDropZones_returnsNoZones") + .setChildren(List.of(new Figure())) + .expectDropZones(), new GetDropZonesTestCase().setTitle("figureContent_returnsDropZones") .setChildren(List.of(createFigure("A1", "A2"))) .expectDropZones("A1", "A2"), From b497951884ec53ce391bf19a229c132aedd51e6e Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 15 Dec 2025 15:58:44 +0000 Subject: [PATCH 54/65] validate question does not contain duplicate drop zones --- .../ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java | 11 ++++++++--- .../cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java | 10 +++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index dd6cd4dac4..ddbfd497dc 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -31,6 +31,7 @@ import uk.ac.cam.cl.dtg.util.FigureRegion; import java.util.Date; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -122,14 +123,18 @@ private Optional validate(final Question question, final Choice answer) ChoiceHelpers.getItems(c).anyMatch(i -> i.getId() == null || i.getDropZoneId() == null || Objects.equals(i.getId(), "") || Objects.equals(i.getDropZoneId(), "")), "Found item with missing id or drop zone id in answer for question id (%s)!", q.getId() ))) - .add("This question is missing items", (q, a) -> logged( + .add("This question is missing items.", (q, a) -> logged( q.getItems() == null || q.getItems().isEmpty(), "Expected items in question (%s), but didn't find any!", q.getId() )) - .add("Question without dropZones found", (q, a) -> logged( + .add("This question doesn't have any drop zones.", (q, a) -> logged( QuestionHelpers.getDropZones(q).isEmpty(), "Question does not have any drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile() )) + .add("This question contains duplicate drop zones.", (q, a) -> logged( + QuestionHelpers.getDropZones(q).size() != new HashSet<>(QuestionHelpers.getDropZones(q)).size(), + "Question contains duplicate drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile() + )) // answer .add(Constants.FEEDBACK_NO_ANSWER_PROVIDED, (q, a) -> a.getItems() == null || a.getItems().isEmpty()) @@ -171,7 +176,7 @@ public static List getDropZones(final IsaacDndQuestion question) { .collect(Collectors.toList()); } - public static Stream getContentDropZones(final ContentBase content) { + private static Stream getContentDropZones(final ContentBase content) { if (content instanceof Figure && ((Figure) content).getFigureRegions() != null) { var figure = (Figure) content; return figure.getFigureRegions().stream().map(FigureRegion::getId); diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index eb8e7941b4..cf4f8b8c9c 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -275,7 +275,7 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) .expectLogMessage(q -> String.format("Expected list of DndItems, but none found in choice for question id (%s)!", q.getId())); static Supplier questionEmptyAnswersTestCase = () -> new QuestionValidationTestCase() - .expectExplanation("This question is missing items") + .expectExplanation("This question is missing items.") .expectLogMessage(q -> String.format("Expected items in question (%s), but didn't find any!", q.getId())); @DataPoints @@ -311,8 +311,12 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) .tapQuestion(q -> q.setItems(List.of())), new QuestionValidationTestCase().setTitle("has no drop zones") .setChildren(null) - .expectExplanation("Question without dropZones found") - .expectLogMessage(q -> String.format("Question does not have any drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile())) + .expectExplanation("This question doesn't have any drop zones.") + .expectLogMessage(q -> String.format("Question does not have any drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile())), + new QuestionValidationTestCase().setTitle("has id duplication among drop zones") + .setContentChild("[drop-zone:A1]").addContentChild("[drop-zone:A1]") + .expectExplanation("This question contains duplicate drop zones.") + .expectLogMessage(q -> String.format("Question contains duplicate drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile())) }; @Theory From 02d4b39abff1ef0280677baae87668a219e5d51d Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 15 Dec 2025 16:41:18 +0000 Subject: [PATCH 55/65] correct answers must use all drop zones --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 4 + .../cl/dtg/isaac/api/QuestionFacadeIT.java | 8 +- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 89 +++++++++++-------- 3 files changed, 58 insertions(+), 43 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index ddbfd497dc..72c740b862 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -135,6 +135,10 @@ private Optional validate(final Question question, final Choice answer) QuestionHelpers.getDropZones(q).size() != new HashSet<>(QuestionHelpers.getDropZones(q)).size(), "Question contains duplicate drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile() )) + .add("This question contains a correct answer that doesn't use all drop zones.", (q, a) -> QuestionHelpers.getChoices(q).filter(DndChoice::isCorrect).anyMatch(c -> logged( + QuestionHelpers.getDropZones(q).size() != c.getItems().size(), + "Question contains correct answer that doesn't use all drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile() + ))) // answer .add(Constants.FEEDBACK_NO_ANSWER_PROVIDED, (q, a) -> a.getItems() == null || a.getItems().isEmpty()) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java index 42463712d8..de134f0aed 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/QuestionFacadeIT.java @@ -116,7 +116,7 @@ public void badRequest_ErrorReturned(final String answerStr, final String emsg, }, delimiter = ';') public void badRequest_IncorrectReturnedWithExplanation(final String answerStr, final String emsg) throws Exception { var dndQuestion = persist(createQuestion( - correct(choose(item_3cm, "leg_1")) + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse")) )); var response = subject().client().post(url(dndQuestion.getId()), answerStr).readEntityAsJson(); assertFalse(response.getBoolean("correct")); @@ -147,9 +147,9 @@ public void emptyAnswer_IncorrectReturned(final String answerStr) throws Excepti "{\"type\": \"dndChoice\", \"items\": [{\"id\": \"6d3d\", \"dropZoneId\": \"leg_1\", \"type\": \"unknown\"}]}" }, delimiter = ';') public void correctAnswer_CorrectReturned(final String answerStr) throws Exception { - var dndQuestion = persist(createQuestion( - correct(choose(item_3cm, "leg_1")) - )); + var dndQuestion = createQuestion(correct(choose(item_3cm, "leg_1"))); + dndQuestion.setChildren(List.of(new Content("[drop-zone:leg_1]"))); + persist(dndQuestion); var response = subject().client().post(url(dndQuestion.getId()), answerStr).readEntityAsJson(); assertTrue(response.getBoolean("correct")); diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index cf4f8b8c9c..ceb615833c 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -85,6 +85,7 @@ public class IsaacDndValidatorTest { ).setAnswer(answer(choose(item_3cm, "leg_2"), choose(item_5cm, "hypothenuse"))) .expectCorrect(false), new CorrectnessTestCase().setTitle("sameAnswerCorrectAndIncorrect_Correct") + .setChildren(List.of(new Content("[drop-zone:leg_1]"))) .setQuestion(incorrect(choose(item_3cm, "leg_1")), correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(item_3cm, "leg_1"))) .expectCorrect(true) @@ -99,27 +100,32 @@ public final void testCorrectness(final CorrectnessTestCase testCase) { @DataPoints public static ExplanationTestCase[] explanationTestCases = { new ExplanationTestCase().setTitle("exactMatchIncorrect_shouldReturnMatching") + .setChildren(List.of(new Content("[drop-zone:leg_1]"))) .setQuestion( correct(choose(item_3cm, "leg_1")), incorrect(ExplanationTestCase.testFeedback, choose(item_4cm, "leg_1")) ).setAnswer(answer(choose(item_4cm, "leg_1"))) .expectCorrect(false), new ExplanationTestCase().setTitle("exactMatchCorrect_shouldReturnMatching") + .setChildren(List.of(new Content("[drop-zone:leg_1]"))) .setQuestion(correct(ExplanationTestCase.testFeedback, choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(item_3cm, "leg_1"))) .expectCorrect(true), new ExplanationTestCase().setTitle("exactMatchIncorrect_shouldReturnDefaultFeedbackForQuestion") + .setChildren(List.of(new Content("[drop-zone:leg_1]"))) .setQuestion(correct(choose(item_3cm, "leg_1")), incorrect(choose(item_4cm, "leg_1"))) .tapQuestion(q -> q.setDefaultFeedback(ExplanationTestCase.testFeedback)) .setAnswer(answer(choose(item_4cm, "leg_1"))) .expectCorrect(false), new ExplanationTestCase().setTitle("matchIncorrectSubset_shouldReturnMatching") + .setChildren(List.of(new Content("[drop-zone:leg_1] [drop-zone:leg_2]"))) .setQuestion( correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2")), incorrect(ExplanationTestCase.testFeedback, choose(item_5cm, "leg_1")) ).setAnswer(answer(choose(item_5cm, "leg_1"), choose(item_6cm, "leg_2"))) .expectCorrect(false), new ExplanationTestCase().setTitle("multiMatchIncorrectSubset_shouldReturnMatching") + .setChildren(List.of(new Content("[drop-zone:leg_1] [drop-zone:leg_2]"))) .setQuestion( correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2")), incorrect(new Content("leg_1 can't be 5"), choose(item_5cm, "leg_1")), @@ -128,11 +134,13 @@ public final void testCorrectness(final CorrectnessTestCase testCase) { .expectCorrect(false) .expectExplanation("leg_2 can't be 6"), new ExplanationTestCase().setTitle("unMatchedIncorrect_shouldReturnDefaultFeedbackForQuestion") + .setChildren(List.of(new Content("[drop-zone:leg_1]"))) .setQuestion(correct(choose(item_3cm, "leg_1"))) .tapQuestion(q -> q.setDefaultFeedback(ExplanationTestCase.testFeedback)) .setAnswer(answer(choose(item_4cm, "leg_1"))) .expectCorrect(false), new ExplanationTestCase().setTitle("partialMatchIncorrect_shouldReturnDefaultFeedbackForQuestion") + .setChildren(List.of(new Content("[drop-zone:leg_1] [drop-zone:leg_2]"))) .setQuestion( correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2")), incorrect(new Content("feedback for choice"), choose(item_5cm, "leg_1"), choose(item_6cm, "leg_2")) @@ -141,11 +149,13 @@ public final void testCorrectness(final CorrectnessTestCase testCase) { .expectCorrect(false) .expectExplanation("feedback for question"), new ExplanationTestCase().setTitle("defaultCorrect_shouldReturnNone") + .setChildren(List.of(new Content("[drop-zone:leg_1]"))) .setQuestion(correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(item_3cm, "leg_1"))) .expectNoExplanation() .expectCorrect(true), new ExplanationTestCase().setTitle("noDefaultIncorrect_shouldReturnNone") + .setChildren(List.of(new Content("[drop-zone:leg_1]"))) .setQuestion(correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(item_4cm, "leg_1"))) .expectNoExplanation() @@ -174,10 +184,12 @@ public final void testExplanation(ExplanationTestCase testCase) { @DataPoints public static DropZonesTestCase[] dropZonesCorrectTestCases = { disabledItemFeedbackNoDropZones.get().setTitle("incorrectNotRequestsed_NotReturned") + .setChildren(List.of(new Content("[drop-zone:leg_1]"))) .setQuestion(correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(item_4cm, "leg_1"))) .expectCorrect(false), disabledItemFeedbackNoDropZones.get().setTitle("correctNotRequested_NotReturned") + .setChildren(List.of(new Content("[drop-zone:leg_1]"))) .setQuestion(correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(item_3cm, "leg_1"))) .expectCorrect(true), @@ -216,23 +228,28 @@ public final void testDropZonesCorrect(final DropZonesTestCase testCase) { .setAnswer(new DndChoice()) .expectExplanation(Constants.FEEDBACK_NO_ANSWER_PROVIDED), new AnswerValidationTestCase().setTitle("itemsNotEnough") + .setChildren(List.of(new Content("[drop-zone:leg_1] [drop-zone:leg_2]"))) .setQuestion(correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"))) .setAnswer(answer(choose(item_3cm, "leg_1"))) .expectExplanation("You did not provide a valid answer; it does not contain an item for each gap.") .expectDropZonesCorrect(f -> f.setLeg1(true)), new AnswerValidationTestCase().setTitle("itemsTooMany") + .setChildren(List.of(new Content("[drop-zone:leg_1]"))) .setQuestion(correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"))) .expectExplanation("You did not provide a valid answer; it contains more items than gaps."), new AnswerValidationTestCase().setTitle("itemNotOnQuestion") + .setChildren(List.of(new Content("[drop-zone:leg_1]"))) .setQuestion(correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(new Item("bad_id", "some_value"), "leg_1"))) .expectExplanation(Constants.FEEDBACK_UNRECOGNISED_ITEMS), new AnswerValidationTestCase().setTitle("itemMissingId") + .setChildren(List.of(new Content("[drop-zone:leg_1]"))) .setQuestion(correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(new Item(null, null), "leg_1"))) .expectExplanation(Constants.FEEDBACK_UNRECOGNISED_FORMAT), new AnswerValidationTestCase().setTitle("itemMissingDropZoneId") + .setChildren(List.of(new Content("[drop-zone:leg_1]"))) .setQuestion(correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(item_3cm, null))) .expectExplanation(Constants.FEEDBACK_UNRECOGNISED_FORMAT), @@ -314,9 +331,17 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) .expectExplanation("This question doesn't have any drop zones.") .expectLogMessage(q -> String.format("Question does not have any drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile())), new QuestionValidationTestCase().setTitle("has id duplication among drop zones") - .setContentChild("[drop-zone:A1]").addContentChild("[drop-zone:A1]") + .setChildren(List.of(new Content("[drop-zone:A1] [drop-zone:A1]"))) .expectExplanation("This question contains duplicate drop zones.") - .expectLogMessage(q -> String.format("Question contains duplicate drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile())) + .expectLogMessage(q -> String.format("Question contains duplicate drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile())), + new QuestionValidationTestCase().setTitle("correct answers contain a choice for each drop zone") + .setChildren(List.of(new Content("[drop-zone:leg_1] [drop-zone:leg_2]"))) + .setQuestion( + correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2")), + correct(choose(item_3cm, "leg_1")) + ) + .expectExplanation("This question contains a correct answer that doesn't use all drop zones.") + .expectLogMessage(q -> String.format("Question contains correct answer that doesn't use all drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile())) }; @Theory @@ -338,36 +363,36 @@ public final void testQuestionValidation(final QuestionValidationTestCase testCa @DataPoints public static GetDropZonesTestCase[] getDropZonesTestCases = { - invalidDropZone.get().setContentChild(""), - invalidDropZone.get().setContentChild("no drop zone"), - invalidDropZone.get().setContentChild("[drop-zone A1]"), - invalidDropZone.get().setContentChild("[drop-zone: A1]"), - invalidDropZone.get().setContentChild("[drop-zone:A1 | w-100]"), - invalidDropZone.get().setContentChild("[drop-zone:A1|w-100 h-50]"), - invalidDropZone.get().setContentChild("[drop-zone:A1|h-100w-50]"), + invalidDropZone.get().setChildren(List.of(new Content(""))), + invalidDropZone.get().setChildren(List.of(new Content("no drop zone"))), + invalidDropZone.get().setChildren(List.of(new Content("[drop-zone A1]"))), + invalidDropZone.get().setChildren(List.of(new Content("[drop-zone: A1]"))), + invalidDropZone.get().setChildren(List.of(new Content("[drop-zone:A1 | w-100]"))), + invalidDropZone.get().setChildren(List.of(new Content("[drop-zone:A1|w-100 h-50]"))), + invalidDropZone.get().setChildren(List.of(new Content("[drop-zone:A1|h-100w-50]"))), new GetDropZonesTestCase().setTitle("noContent_noDropZones").setChildren(null).expectDropZones(), new GetDropZonesTestCase().setTitle("singleDropZoneSingleText_returnsDropZone") - .setContentChild("[drop-zone:A1]") + .setChildren(List.of(new Content("[drop-zone:A1]"))) .expectDropZones("A1"), new GetDropZonesTestCase().setTitle("singleDropZoneSingleContent_returnsDropZone") - .setContentChild("[drop-zone:A1|w-100]") + .setChildren(List.of(new Content("[drop-zone:A1|w-100]"))) .expectDropZones("A1"), new GetDropZonesTestCase().setTitle("singleDropZoneSingleContentHeight_returnsDropZone") - .setContentChild("[drop-zone:A1|h-100]") + .setChildren(List.of(new Content("[drop-zone:A1|h-100]"))) .expectDropZones("A1"), new GetDropZonesTestCase().setTitle("singleDropZoneSingleContentWidthHeight_returnsDropZone") - .setContentChild("[drop-zone:A1|w-100h-50]") + .setChildren(List.of(new Content("[drop-zone:A1|w-100h-50]"))) .expectDropZones("A1"), new GetDropZonesTestCase().setTitle("multiDropZoneSingleContent_returnsDropZones") - .setContentChild("Some text [drop-zone:A1], other text [drop-zone:A2]") + .setChildren(List.of(new Content("Some text [drop-zone:A1], other text [drop-zone:A2]"))) .expectDropZones("A1", "A2"), new GetDropZonesTestCase().setTitle("multiDropZoneMultiContent_returnsDropZones") - .setContentChild("[drop-zone:A1] [drop-zone:A2]") - .addContentChild("[drop-zone:A3] [drop-zone:A4]") - .expectDropZones("A1", "A2", "A3", "A4"), + .setChildren(List.of( + new Content("[drop-zone:A1] [drop-zone:A2]"), + new Content("[drop-zone:A3] [drop-zone:A4]") + )).expectDropZones("A1", "A2", "A3", "A4"), new GetDropZonesTestCase().setTitle("singleDropZoneNestedContent_returnsDropZones") - .setChildren(new LinkedList<>(List.of(new Content()))) - .addContentChild("[drop-zone:A2]") + .setChildren(new LinkedList<>(List.of(new Content(), new Content("[drop-zone:A2]")))) .tapQuestion(q -> ((Content) q.getChildren().get(0)).setChildren(List.of(new Content("[drop-zone:A1]")))) .expectDropZones("A1", "A2"), new GetDropZonesTestCase().setTitle("figureContentWithoutDropZones_returnsNoZones") @@ -377,12 +402,10 @@ public final void testQuestionValidation(final QuestionValidationTestCase testCa .setChildren(List.of(createFigure("A1", "A2"))) .expectDropZones("A1", "A2"), new GetDropZonesTestCase().setTitle("mixedButNoNesting_returnsDropZones") - .setChildren(new LinkedList<>(List.of(createFigure("A1", "A2")))) - .addContentChild("[drop-zone:A3]") + .setChildren(new LinkedList<>(List.of(createFigure("A1", "A2"), new Content("[drop-zone:A3]")))) .expectDropZones("A1", "A2", "A3"), new GetDropZonesTestCase().setTitle("mixedNested_returnsDropZones") - .setChildren(new LinkedList<>(List.of(new Content()))) - .addContentChild("[drop-zone:A2]") + .setChildren(new LinkedList<>(List.of(new Content(), new Content("[drop-zone:A2]")))) .tapQuestion(q -> { Content content = (Content) q.getChildren().get(0); content.setChildren(List.of( @@ -516,7 +539,7 @@ static class TestCase> { public String loggedMessage; public boolean correct = false; private Function logMessageOp; - private Consumer questionOp; + private final List> questionOps = new ArrayList<>(); public T setTitle(final String title) { return self(); @@ -535,22 +558,12 @@ public T setQuestion(final Consumer op) { } public T setChildren(final List content) { - question.setChildren(content); - return self(); - } - - public T setContentChild(final String content) { - question.setChildren(new ArrayList<>(List.of(new Content(content)))); - return self(); - } - - public T addContentChild(final String content) { - question.getChildren().add(new Content(content)); + this.questionOps.add(q -> q.setChildren(content)); return self(); } public T tapQuestion(final Consumer op) { - this.questionOp = op; + this.questionOps.add(op); return self(); } @@ -595,12 +608,10 @@ public T expectDropZones(final String... dropZones) { } private T self() { + this.questionOps.forEach(op -> op.accept(this.question)); if (this.logMessageOp != null) { this.loggedMessage = logMessageOp.apply(this.question); } - if (this.questionOp != null) { - questionOp.accept(this.question); - } return (T) this; } } From 65d41467c5d1a249c6de2f95b41b6725e9161f99 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 15 Dec 2025 16:57:04 +0000 Subject: [PATCH 56/65] answers must contain valid drop zone references --- .../uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java | 5 +++++ .../cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java | 10 +++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 72c740b862..b0320d4c90 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -139,6 +139,11 @@ private Optional validate(final Question question, final Choice answer) QuestionHelpers.getDropZones(q).size() != c.getItems().size(), "Question contains correct answer that doesn't use all drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile() ))) + .add("One of the answers to this question references an invalid drop zone", (q, a) -> QuestionHelpers.getChoices(q).anyMatch(c -> logged( + c.getItems().stream().anyMatch(i -> !QuestionHelpers.getDropZones(q).contains(i.getDropZoneId())), + "Question contains invalid drop zone reference. %s src %s", q.getId(), q.getCanonicalSourceFile() + ))) + // answer .add(Constants.FEEDBACK_NO_ANSWER_PROVIDED, (q, a) -> a.getItems() == null || a.getItems().isEmpty()) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index ceb615833c..02b7046959 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -339,9 +339,13 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) .setQuestion( correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2")), correct(choose(item_3cm, "leg_1")) - ) - .expectExplanation("This question contains a correct answer that doesn't use all drop zones.") - .expectLogMessage(q -> String.format("Question contains correct answer that doesn't use all drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile())) + ).expectExplanation("This question contains a correct answer that doesn't use all drop zones.") + .expectLogMessage(q -> String.format("Question contains correct answer that doesn't use all drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile())), + new QuestionValidationTestCase().setTitle("drop zone references must be valid") + .setChildren(List.of(new Content("[drop-zone:leg_1]"))) + .setQuestion(correct(choose(item_3cm, "leg_1")), incorrect(choose(item_3cm, "leg_2"))) + .expectExplanation("One of the answers to this question references an invalid drop zone") + .expectLogMessage(q -> String.format("Question contains invalid drop zone reference. %s src %s", q.getId(), q.getCanonicalSourceFile())) }; @Theory From 3aed1d80a0a130d39f0ee924e2d6763694e3eecb Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 15 Dec 2025 17:14:23 +0000 Subject: [PATCH 57/65] user answers most contain valid drop zone references --- .../ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java | 13 +++++++------ .../cl/dtg/isaac/quiz/IsaacDndValidatorTest.java | 10 +++++++--- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index b0320d4c90..7f45deb3a3 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -36,7 +36,6 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.regex.MatchResult; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -144,13 +143,15 @@ private Optional validate(final Question question, final Choice answer) "Question contains invalid drop zone reference. %s src %s", q.getId(), q.getCanonicalSourceFile() ))) - // answer .add(Constants.FEEDBACK_NO_ANSWER_PROVIDED, (q, a) -> a.getItems() == null || a.getItems().isEmpty()) - .add(Constants.FEEDBACK_UNRECOGNISED_ITEMS, - (q, a) -> a.getItems().stream().anyMatch(answerItem -> !q.getItems().contains(answerItem))) - .add(Constants.FEEDBACK_UNRECOGNISED_FORMAT, (q, a) -> ChoiceHelpers.getItems(a).anyMatch(i -> i.getId() == null) - || ChoiceHelpers.getItems(a).anyMatch(i -> i.getDropZoneId() == null)) + .add(Constants.FEEDBACK_UNRECOGNISED_FORMAT, (q, a) -> ChoiceHelpers.getItems(a).anyMatch(i -> + i.getId() == null || i.getDropZoneId() == null + )) + .add(Constants.FEEDBACK_UNRECOGNISED_ITEMS, (q, a) -> a.getItems().stream().anyMatch(i -> + !q.getItems().contains(i) + || !QuestionHelpers.getDropZones(q).contains(i.getDropZoneId())) + ) .add("You did not provide a valid answer; it contains more items than gaps.", (q, a) -> a.getItems().size() > QuestionHelpers.getAnyCorrect(q).map(c -> c.getItems().size()).orElse(0)) .check((IsaacDndQuestion) question, (DndChoice) answer); diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 02b7046959..75b9336ad1 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -236,7 +236,7 @@ public final void testDropZonesCorrect(final DropZonesTestCase testCase) { new AnswerValidationTestCase().setTitle("itemsTooMany") .setChildren(List.of(new Content("[drop-zone:leg_1]"))) .setQuestion(correct(choose(item_3cm, "leg_1"))) - .setAnswer(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"))) + .setAnswer(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_1"))) .expectExplanation("You did not provide a valid answer; it contains more items than gaps."), new AnswerValidationTestCase().setTitle("itemNotOnQuestion") .setChildren(List.of(new Content("[drop-zone:leg_1]"))) @@ -259,8 +259,12 @@ public final void testDropZonesCorrect(final DropZonesTestCase testCase) { incorrect(new Content("Leg 1 should be less than 4 cm"), choose(item_4cm, "leg_1")) ).setAnswer(answer(choose(item_4cm, "leg_1"))) .expectExplanation("Leg 1 should be less than 4 cm") - .expectDropZonesCorrect(f -> f.setLeg1(false)) - // TODO: if drop zone does not exist in question + .expectDropZonesCorrect(f -> f.setLeg1(false)), + new AnswerValidationTestCase().setTitle("unrecognized drop zone") + .setChildren(List.of(new Content("[drop-zone:leg_1]"))) + .setQuestion(correct(choose(item_3cm, "leg_1"))) + .setAnswer(answer(choose(item_3cm, "leg_2"))) + .expectExplanation(Constants.FEEDBACK_UNRECOGNISED_ITEMS) }; @Theory From e254ee6038cf2dbd37347caeea4d6ca7e8aa3769 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 15 Dec 2025 17:51:37 +0000 Subject: [PATCH 58/65] extract constants and resolve code style --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 63 ++++++++++++++----- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 22 +++---- 2 files changed, 57 insertions(+), 28 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 7f45deb3a3..643ad511b6 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -36,6 +36,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.function.Predicate; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -44,6 +45,21 @@ * Validator that only provides functionality to validate Drag and drop questions. */ public class IsaacDndValidator implements IValidator { + public static final String FEEDBACK_QUESTION_INVALID_ANS = "This question contains at least one invalid answer."; + public static final String FEEDBACK_QUESTION_EMPTY_ANS = "This question contains an empty answer."; + public static final String FEEDBACK_QUESTION_UNRECOGNISED_ANS = "This question contains at least one answer in an " + + "unrecognised format."; + public static final String FEEDBACK_QUESTION_MISSING_ITEMS = "This question is missing items."; + public static final String FEEDBACK_QUESTION_NO_DZ = "This question doesn't have any drop zones."; + public static final String FEEDBACK_QUESTION_DUP_DZ = "This question contains duplicate drop zones."; + public static final String FEEDBACK_QUESTION_UNUSED_DZ = "This question contains a correct answer that doesn't use " + + "all drop zones."; + public static final String FEEDBACK_QUESTION_INVALID_DZ = "One of the answers to this question references an " + + "invalid drop zone."; + public static final String FEEDBACK_ANSWER_NOT_ENOUGH = "You did not provide a valid answer; it does not contain " + + "an item for each gap."; + public static final String FEEDBACK_ANSWER_TOO_MUCH = "You did not provide a valid answer; it contains more items " + + "than gaps."; private static final Logger log = LoggerFactory.getLogger(IsaacDndValidator.class); @Override @@ -75,7 +91,7 @@ private DndValidationResponse mark(final IsaacDndQuestion question, final DndCho return null; } if (answer.getItems().size() < closestCorrect.getItems().size()) { - return new Content("You did not provide a valid answer; it does not contain an item for each gap."); + return new Content(FEEDBACK_ANSWER_NOT_ENOUGH); } return question.getDefaultFeedback(); }); @@ -106,39 +122,42 @@ private Optional validate(final Question question, final Choice answer) q.getChoices().stream().noneMatch(Choice::isCorrect), "Question does not have any correct answers. %s src: %s", q.getId(), q.getCanonicalSourceFile() )) - .add("This question contains at least one invalid answer.", (q, a) -> q.getChoices().stream().anyMatch(c -> logged( + .add(FEEDBACK_QUESTION_INVALID_ANS, (q, a) -> q.getChoices().stream().anyMatch(c -> logged( !DndChoice.class.equals(c.getClass()), "Expected DndItem in question (%s), instead found %s!", q.getId(), c.getClass() ))) - .add("This question contains an empty answer.", (q, a) -> QuestionHelpers.getChoices(q).anyMatch(c -> logged( + .add(FEEDBACK_QUESTION_EMPTY_ANS, (q, a) -> QuestionHelpers.getChoices(q).anyMatch(c -> logged( c.getItems() == null || c.getItems().isEmpty(), "Expected list of DndItems, but none found in choice for question id (%s)!", q.getId() ))) - .add("This question contains at least one invalid answer.", (q, a) -> QuestionHelpers.getChoices(q).anyMatch(c -> logged( + .add(FEEDBACK_QUESTION_INVALID_ANS, (q, a) -> QuestionHelpers.getChoices(q).anyMatch(c -> logged( c.getItems().stream().anyMatch(i -> i.getClass() != DndItem.class), "Expected list of DndItems, but something else found in choice for question id (%s)!", q.getId() ))) - .add("This question contains at least one answer in an unrecognised format.", (q, a) -> QuestionHelpers.getChoices(q).anyMatch(c -> logged( - ChoiceHelpers.getItems(c).anyMatch(i -> i.getId() == null || i.getDropZoneId() == null || Objects.equals(i.getId(), "") || Objects.equals(i.getDropZoneId(), "")), - "Found item with missing id or drop zone id in answer for question id (%s)!", q.getId() + .add(FEEDBACK_QUESTION_UNRECOGNISED_ANS, (q, a) -> QuestionHelpers.getChoices(q).anyMatch(c -> logged( + ChoiceHelpers.getItems(c).anyMatch(i -> + i.getId() == null || i.getDropZoneId() == null + || Objects.equals(i.getId(), "") || Objects.equals(i.getDropZoneId(), "") + ), "Found item with missing id or drop zone id in answer for question id (%s)!", q.getId() ))) - .add("This question is missing items.", (q, a) -> logged( + .add(FEEDBACK_QUESTION_MISSING_ITEMS, (q, a) -> logged( q.getItems() == null || q.getItems().isEmpty(), "Expected items in question (%s), but didn't find any!", q.getId() )) - .add("This question doesn't have any drop zones.", (q, a) -> logged( + .add(FEEDBACK_QUESTION_NO_DZ, (q, a) -> logged( QuestionHelpers.getDropZones(q).isEmpty(), "Question does not have any drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile() )) - .add("This question contains duplicate drop zones.", (q, a) -> logged( + .add(FEEDBACK_QUESTION_DUP_DZ, (q, a) -> logged( QuestionHelpers.getDropZones(q).size() != new HashSet<>(QuestionHelpers.getDropZones(q)).size(), "Question contains duplicate drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile() )) - .add("This question contains a correct answer that doesn't use all drop zones.", (q, a) -> QuestionHelpers.getChoices(q).filter(DndChoice::isCorrect).anyMatch(c -> logged( + .add(FEEDBACK_QUESTION_UNUSED_DZ, (q, a) -> QuestionHelpers.anyCorrectMatch(q, c -> logged( QuestionHelpers.getDropZones(q).size() != c.getItems().size(), - "Question contains correct answer that doesn't use all drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile() + "Question contains correct answer that doesn't use all drop zones. %s src %s", + q.getId(), q.getCanonicalSourceFile() ))) - .add("One of the answers to this question references an invalid drop zone", (q, a) -> QuestionHelpers.getChoices(q).anyMatch(c -> logged( + .add(FEEDBACK_QUESTION_INVALID_DZ, (q, a) -> QuestionHelpers.getChoices(q).anyMatch(c -> logged( c.getItems().stream().anyMatch(i -> !QuestionHelpers.getDropZones(q).contains(i.getDropZoneId())), "Question contains invalid drop zone reference. %s src %s", q.getId(), q.getCanonicalSourceFile() ))) @@ -152,8 +171,9 @@ private Optional validate(final Question question, final Choice answer) !q.getItems().contains(i) || !QuestionHelpers.getDropZones(q).contains(i.getDropZoneId())) ) - .add("You did not provide a valid answer; it contains more items than gaps.", - (q, a) -> a.getItems().size() > QuestionHelpers.getAnyCorrect(q).map(c -> c.getItems().size()).orElse(0)) + .add(FEEDBACK_ANSWER_TOO_MUCH, (q, a) -> + a.getItems().size() > QuestionHelpers.getAnyCorrect(q).map(c -> c.getItems().size()).orElse(0) + ) .check((IsaacDndQuestion) question, (DndChoice) answer); } @@ -164,6 +184,7 @@ private static boolean logged(final boolean result, final String message, final return result; } + @SuppressWarnings("checkstyle:MissingJavadocType") public static class QuestionHelpers { public static Stream getChoices(final IsaacDndQuestion question) { return question.getChoices().stream().map(c -> (DndChoice) c); @@ -173,10 +194,18 @@ public static Optional getAnyCorrect(final IsaacDndQuestion question) return getChoices(question).filter(DndChoice::isCorrect).findFirst(); } + public static boolean anyCorrectMatch(final IsaacDndQuestion question, final Predicate p) { + return getChoices(question).filter(DndChoice::isCorrect).anyMatch(p); + + } + public static boolean getDetailedItemFeedback(final IsaacDndQuestion question) { return BooleanUtils.isTrue(question.getDetailedItemFeedback()); } + /** + * Collects the drop zone ids from any content within the question. + */ public static List getDropZones(final IsaacDndQuestion question) { if (question.getChildren() == null) { return List.of(); @@ -193,8 +222,8 @@ private static Stream getContentDropZones(final ContentBase content) { } if (content instanceof Content && ((Content) content).getValue() != null) { var textContent = (Content) content; - String dndDropZoneRegexStr = "\\[drop-zone:(?[a-zA-Z0-9_-]+)(?\\|(?w-\\d+?)?(?h-\\d+?)?)?]"; - Pattern dndDropZoneRegex = Pattern.compile(dndDropZoneRegexStr); + String expr = "\\[drop-zone:(?[a-zA-Z0-9_-]+)(?\\|(?w-\\d+?)?(?h-\\d+?)?)?]"; + Pattern dndDropZoneRegex = Pattern.compile(expr); return dndDropZoneRegex.matcher(textContent.getValue()).results().map(mr -> mr.group(1)); } if (content instanceof Content && ((Content) content).getChildren() != null) { diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 75b9336ad1..a325e77749 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -231,13 +231,13 @@ public final void testDropZonesCorrect(final DropZonesTestCase testCase) { .setChildren(List.of(new Content("[drop-zone:leg_1] [drop-zone:leg_2]"))) .setQuestion(correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"))) .setAnswer(answer(choose(item_3cm, "leg_1"))) - .expectExplanation("You did not provide a valid answer; it does not contain an item for each gap.") + .expectExplanation(IsaacDndValidator.FEEDBACK_ANSWER_NOT_ENOUGH) .expectDropZonesCorrect(f -> f.setLeg1(true)), new AnswerValidationTestCase().setTitle("itemsTooMany") .setChildren(List.of(new Content("[drop-zone:leg_1]"))) .setQuestion(correct(choose(item_3cm, "leg_1"))) .setAnswer(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_1"))) - .expectExplanation("You did not provide a valid answer; it contains more items than gaps."), + .expectExplanation(IsaacDndValidator.FEEDBACK_ANSWER_TOO_MUCH), new AnswerValidationTestCase().setTitle("itemNotOnQuestion") .setChildren(List.of(new Content("[drop-zone:leg_1]"))) .setQuestion(correct(choose(item_3cm, "leg_1"))) @@ -281,7 +281,7 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) // TODO: check when a non-existing drop zone was used? (and anything that doesn't exist in a correct answer is invalid?) static Supplier itemUnrecognisedFormatCase = () -> new QuestionValidationTestCase() - .expectExplanation("This question contains at least one answer in an unrecognised format.") + .expectExplanation(IsaacDndValidator.FEEDBACK_QUESTION_UNRECOGNISED_ANS) .expectLogMessage(q -> String.format("Found item with missing id or drop zone id in answer for question id (%s)!", q.getId())); static Supplier noAnswersTestCase = () -> new QuestionValidationTestCase() @@ -292,11 +292,11 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) .expectLogMessage(q -> String.format("Question does not have any correct answers. %s src: %s", q.getId(), q.getCanonicalSourceFile())); static Supplier answerEmptyItemsTestCase = () -> new QuestionValidationTestCase() - .expectExplanation("This question contains an empty answer.") + .expectExplanation(IsaacDndValidator.FEEDBACK_QUESTION_EMPTY_ANS) .expectLogMessage(q -> String.format("Expected list of DndItems, but none found in choice for question id (%s)!", q.getId())); static Supplier questionEmptyAnswersTestCase = () -> new QuestionValidationTestCase() - .expectExplanation("This question is missing items.") + .expectExplanation(IsaacDndValidator.FEEDBACK_QUESTION_MISSING_ITEMS) .expectLogMessage(q -> String.format("Expected items in question (%s), but didn't find any!", q.getId())); @DataPoints @@ -309,14 +309,14 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) .setQuestion(answer(choose(item_3cm, "leg_1"))), new QuestionValidationTestCase().setTitle("answer not for a DnD question") .setQuestion(q -> q.setChoices(List.of(new ParsonsChoice() {{correct = true; setItems(List.of(new Item("", ""))); }}))) - .expectExplanation("This question contains at least one invalid answer.") + .expectExplanation(IsaacDndValidator.FEEDBACK_QUESTION_INVALID_ANS) .expectLogMessage(q -> String.format("Expected DndItem in question (%s), instead found class uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidatorTest$1!", q.getId())), answerEmptyItemsTestCase.get().setTitle("answer with empty items").setQuestion(correct()), answerEmptyItemsTestCase.get().setTitle("answer with null items") .setQuestion(q -> q.setChoices(Stream.of(new DndChoice()).peek(c -> c.setCorrect(true)).collect(Collectors.toList()))), new QuestionValidationTestCase().setTitle("answer with non-dnd items") .setQuestion(correct(new DndItemEx("id", "value", "dropZoneId"))) - .expectExplanation("This question contains at least one invalid answer.") + .expectExplanation(IsaacDndValidator.FEEDBACK_QUESTION_INVALID_ANS) .expectLogMessage(q -> String.format("Expected list of DndItems, but something else found in choice for question id (%s)!", q.getId())), itemUnrecognisedFormatCase.get().setTitle("answer with missing item_id") .setQuestion(correct(new DndItem(null, "value", "dropZoneId"))), @@ -332,23 +332,23 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) .tapQuestion(q -> q.setItems(List.of())), new QuestionValidationTestCase().setTitle("has no drop zones") .setChildren(null) - .expectExplanation("This question doesn't have any drop zones.") + .expectExplanation(IsaacDndValidator.FEEDBACK_QUESTION_NO_DZ) .expectLogMessage(q -> String.format("Question does not have any drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile())), new QuestionValidationTestCase().setTitle("has id duplication among drop zones") .setChildren(List.of(new Content("[drop-zone:A1] [drop-zone:A1]"))) - .expectExplanation("This question contains duplicate drop zones.") + .expectExplanation(IsaacDndValidator.FEEDBACK_QUESTION_DUP_DZ) .expectLogMessage(q -> String.format("Question contains duplicate drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile())), new QuestionValidationTestCase().setTitle("correct answers contain a choice for each drop zone") .setChildren(List.of(new Content("[drop-zone:leg_1] [drop-zone:leg_2]"))) .setQuestion( correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2")), correct(choose(item_3cm, "leg_1")) - ).expectExplanation("This question contains a correct answer that doesn't use all drop zones.") + ).expectExplanation(IsaacDndValidator.FEEDBACK_QUESTION_UNUSED_DZ) .expectLogMessage(q -> String.format("Question contains correct answer that doesn't use all drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile())), new QuestionValidationTestCase().setTitle("drop zone references must be valid") .setChildren(List.of(new Content("[drop-zone:leg_1]"))) .setQuestion(correct(choose(item_3cm, "leg_1")), incorrect(choose(item_3cm, "leg_2"))) - .expectExplanation("One of the answers to this question references an invalid drop zone") + .expectExplanation(IsaacDndValidator.FEEDBACK_QUESTION_INVALID_DZ) .expectLogMessage(q -> String.format("Question contains invalid drop zone reference. %s src %s", q.getId(), q.getCanonicalSourceFile())) }; From e80b77d80dd71a5a0c9e7c4f3c25da3c716fabbb Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Mon, 15 Dec 2025 17:57:52 +0000 Subject: [PATCH 59/65] resolve style issues --- .../uk/ac/cam/cl/dtg/isaac/quiz/ValidationUtils.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/ValidationUtils.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/ValidationUtils.java index fcad3d6bee..5ab15ffa06 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/ValidationUtils.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/ValidationUtils.java @@ -284,13 +284,17 @@ public static boolean tooManySignificantFigures(final String valueToCheck, final } public static class BiRuleValidator { - private final List rules = new ArrayList<>(); + private final List> rules = new ArrayList<>(); public BiRuleValidator add(final String key, final BiPredicate rule) { - rules.add(new Rule(key, rule)); + rules.add(new Rule<>(key, rule)); return this; } + /** + * Applies the ruleset and returns the result. If an error was found, a description of the issue is available in + * the optional. If validation has passed, the optional is empty. + */ public Optional check(final T t, final U u) { return rules.stream() .filter(r -> r.predicate.test(t, u)) @@ -298,7 +302,7 @@ public Optional check(final T t, final U u) { .findFirst(); } - private class Rule { + private static class Rule { public final String message; public final BiPredicate predicate; From 64382370a743344a663ea449aaeeb74a17fe2886 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Tue, 16 Dec 2025 09:58:05 +0000 Subject: [PATCH 60/65] resolve copilot autofix warning --- .../ac/cam/cl/dtg/isaac/api/IsaacIntegrationTestWithREST.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/IsaacIntegrationTestWithREST.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/IsaacIntegrationTestWithREST.java index f9e408bdb1..6af3c54fac 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/api/IsaacIntegrationTestWithREST.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/api/IsaacIntegrationTestWithREST.java @@ -164,7 +164,7 @@ void assertError(final String message, final Response.Status status) { } void assertError(final String message, final String status) { - assertEquals(Integer.parseInt(status), response.getStatus()); + assertEquals(status, Integer.toString(response.getStatus())); assertTrue(this.readEntityAsJsonUnchecked().getString("errorMessage").contains(message)); } From 2e11eb86ad3f2f6cf28bbe2a2286074bd6e2f49f Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Tue, 16 Dec 2025 11:38:46 +0000 Subject: [PATCH 61/65] a gentle refactor --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 51 ++++++++----------- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 4 +- 2 files changed, 21 insertions(+), 34 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 643ad511b6..774ed94910 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -30,6 +30,7 @@ import uk.ac.cam.cl.dtg.isaac.dos.content.Question; import uk.ac.cam.cl.dtg.util.FigureRegion; +import java.util.Comparator; import java.util.Date; import java.util.HashSet; import java.util.List; @@ -71,30 +72,22 @@ public final DndValidationResponse validateQuestionResponse(final Question quest private DndValidationResponse mark(final IsaacDndQuestion question, final DndChoice answer) { List sortedAnswers = QuestionHelpers.getChoices(question) - .sorted((rhs, lhs) -> { - int compared = ChoiceHelpers.countPartialMatchesIn(lhs, answer) - - ChoiceHelpers.countPartialMatchesIn(rhs, answer); - if (compared == 0) { - return lhs.isCorrect() && rhs.isCorrect() ? 0 : (lhs.isCorrect() ? 1 : -1); - } - return compared; - }) - .collect(Collectors.toList()); + .sorted(Comparator + .comparingInt((DndChoice choice) -> ChoiceHelpers.matchStrength(choice, answer)) + .thenComparing(DndChoice::isCorrect) + .reversed() + ).collect(Collectors.toList()); var matchedAnswer = sortedAnswers.stream().filter(lhs -> ChoiceHelpers.matches(lhs, answer)).findFirst(); - var closestCorrect = sortedAnswers.stream().filter(DndChoice::isCorrect).findFirst().orElse(null); + var closestCorrect = sortedAnswers.stream().filter(DndChoice::isCorrect).findFirst(); var isCorrect = matchedAnswer.map(DndChoice::isCorrect).orElse(false); var dropZonesCorrect = QuestionHelpers.getDetailedItemFeedback(question) - ? ChoiceHelpers.getDropZonesCorrect(closestCorrect, answer) : null; - var feedback = (Content) matchedAnswer.map(Choice::getExplanation).orElseGet(() -> { - if (isCorrect) { - return null; - } - if (answer.getItems().size() < closestCorrect.getItems().size()) { - return new Content(FEEDBACK_ANSWER_NOT_ENOUGH); - } - return question.getDefaultFeedback(); - }); + ? closestCorrect.map(correct -> ChoiceHelpers.getDropZonesCorrect(correct, answer)).orElse(null) : null; + var feedback = (Content) matchedAnswer.map(Choice::getExplanation) + .or(() -> !isCorrect && answer.getItems().size() < closestCorrect.map(c -> c.getItems().size()).orElse(0) + ? Optional.of(new Content(FEEDBACK_ANSWER_NOT_ENOUGH)) : Optional.empty()) + .or(() -> !isCorrect ? Optional.ofNullable(question.getDefaultFeedback()) : Optional.empty()) + .orElse(null); return new DndValidationResponse(question.getId(), answer, isCorrect, dropZonesCorrect, feedback, new Date()); } @@ -135,7 +128,7 @@ private Optional validate(final Question question, final Choice answer) "Expected list of DndItems, but something else found in choice for question id (%s)!", q.getId() ))) .add(FEEDBACK_QUESTION_UNRECOGNISED_ANS, (q, a) -> QuestionHelpers.getChoices(q).anyMatch(c -> logged( - ChoiceHelpers.getItems(c).anyMatch(i -> + c.getItems().stream().anyMatch(i -> i.getId() == null || i.getDropZoneId() == null || Objects.equals(i.getId(), "") || Objects.equals(i.getDropZoneId(), "") ), "Found item with missing id or drop zone id in answer for question id (%s)!", q.getId() @@ -164,7 +157,7 @@ private Optional validate(final Question question, final Choice answer) // answer .add(Constants.FEEDBACK_NO_ANSWER_PROVIDED, (q, a) -> a.getItems() == null || a.getItems().isEmpty()) - .add(Constants.FEEDBACK_UNRECOGNISED_FORMAT, (q, a) -> ChoiceHelpers.getItems(a).anyMatch(i -> + .add(Constants.FEEDBACK_UNRECOGNISED_FORMAT, (q, a) -> a.getItems().stream().anyMatch(i -> i.getId() == null || i.getDropZoneId() == null )) .add(Constants.FEEDBACK_UNRECOGNISED_ITEMS, (q, a) -> a.getItems().stream().anyMatch(i -> @@ -235,23 +228,19 @@ private static Stream getContentDropZones(final ContentBase content) { } private static class ChoiceHelpers { - public static Stream getItems(final DndChoice choice) { - return choice.getItems().stream().map(i -> (DndItem) i); - } - public static boolean matches(final DndChoice lhs, final DndChoice rhs) { - return getItems(lhs).allMatch(lhsItem -> dropZoneEql(rhs, lhsItem)); + return lhs.getItems().stream().allMatch(lhsItem -> dropZoneEql(rhs, lhsItem)); } - public static int countPartialMatchesIn(final DndChoice lhs, final DndChoice rhs) { - return getItems(lhs) + public static int matchStrength(final DndChoice lhs, final DndChoice rhs) { + return lhs.getItems().stream() .map(lhsItem -> dropZoneEql(rhs, lhsItem) ? 1 : 0) .mapToInt(Integer::intValue) .sum(); } public static Map getDropZonesCorrect(final DndChoice lhs, final DndChoice rhs) { - return getItems(lhs) + return lhs.getItems().stream() .filter(lhsItem -> getItemByDropZone(rhs, lhsItem.getDropZoneId()).isPresent()) .collect(Collectors.toMap( DndItem::getDropZoneId, @@ -266,7 +255,7 @@ private static boolean dropZoneEql(final DndChoice choice, final DndItem item) { } private static Optional getItemByDropZone(final DndChoice choice, final String dropZoneId) { - return getItems(choice) + return choice.getItems().stream() .filter(item -> item.getDropZoneId().equals(dropZoneId)) .findFirst(); } diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index a325e77749..394d64bca9 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -132,7 +132,7 @@ public final void testCorrectness(final CorrectnessTestCase testCase) { incorrect(new Content("leg_2 can't be 6"), choose(item_6cm, "leg_2")) ).setAnswer(answer(choose(item_5cm, "leg_1"), choose(item_6cm, "leg_2"))) .expectCorrect(false) - .expectExplanation("leg_2 can't be 6"), + .expectExplanation("leg_1 can't be 5"), new ExplanationTestCase().setTitle("unMatchedIncorrect_shouldReturnDefaultFeedbackForQuestion") .setChildren(List.of(new Content("[drop-zone:leg_1]"))) .setQuestion(correct(choose(item_3cm, "leg_1"))) @@ -278,8 +278,6 @@ public final void testAnswerValidation(final AnswerValidationTestCase testCase) assertEquals(testCase.dropZonesCorrect, response.getDropZonesCorrect()); } - // TODO: check when a non-existing drop zone was used? (and anything that doesn't exist in a correct answer is invalid?) - static Supplier itemUnrecognisedFormatCase = () -> new QuestionValidationTestCase() .expectExplanation(IsaacDndValidator.FEEDBACK_QUESTION_UNRECOGNISED_ANS) .expectLogMessage(q -> String.format("Found item with missing id or drop zone id in answer for question id (%s)!", q.getId())); From dc54deb65748a0b89247313255414c1d31c1197e Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Tue, 16 Dec 2025 15:58:59 +0000 Subject: [PATCH 62/65] ETL records first content problem for DnD question --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 105 +++++++++--------- .../cl/dtg/isaac/quiz/ValidationUtils.java | 41 +++++-- .../cam/cl/dtg/segue/etl/ContentIndexer.java | 11 ++ .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 25 ++--- .../cl/dtg/segue/etl/ContentIndexerTest.java | 58 ++++++++++ 5 files changed, 169 insertions(+), 71 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 774ed94910..7590b07f47 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -37,14 +37,13 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.function.BiFunction; import java.util.function.Predicate; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; -/** - * Validator that only provides functionality to validate Drag and drop questions. - */ +/** Validator that only provides functionality to validate Drag and drop questions. */ public class IsaacDndValidator implements IValidator { public static final String FEEDBACK_QUESTION_INVALID_ANS = "This question contains at least one invalid answer."; public static final String FEEDBACK_QUESTION_EMPTY_ANS = "This question contains an empty answer."; @@ -71,7 +70,7 @@ public final DndValidationResponse validateQuestionResponse(final Question quest } private DndValidationResponse mark(final IsaacDndQuestion question, final DndChoice answer) { - List sortedAnswers = QuestionHelpers.getChoices(question) + var sortedAnswers = QuestionHelpers.getChoices(question) .sorted(Comparator .comparingInt((DndChoice choice) -> ChoiceHelpers.matchStrength(choice, answer)) .thenComparing(DndChoice::isCorrect) @@ -105,74 +104,80 @@ private Optional validate(final Question question, final Choice answer) "This validator only works with IsaacDndQuestions (%s is not IsaacDndQuestion)", question.getId())); } - return new ValidationUtils.BiRuleValidator() - // question - .add(Constants.FEEDBACK_NO_CORRECT_ANSWERS, (q, a) -> logged( + return ValidationUtils.BiRuleValidator.of( + questionValidator(IsaacDndValidator::logIfTrue) + ) + .add(Constants.FEEDBACK_NO_ANSWER_PROVIDED, (q, a) -> a.getItems() == null || a.getItems().isEmpty()) + .add(Constants.FEEDBACK_UNRECOGNISED_FORMAT, (q, a) -> a.getItems().stream().anyMatch(i -> + i.getId() == null || i.getDropZoneId() == null + )) + .add(Constants.FEEDBACK_UNRECOGNISED_ITEMS, (q, a) -> a.getItems().stream().anyMatch(i -> + !q.getItems().contains(i) + || !QuestionHelpers.getDropZones(q).contains(i.getDropZoneId())) + ) + .add(FEEDBACK_ANSWER_TOO_MUCH, (q, a) -> + a.getItems().size() > QuestionHelpers.getAnyCorrect(q).map(c -> c.getItems().size()).orElse(0) + ) + .check((IsaacDndQuestion) question, (DndChoice) answer); + } + + /** A validator whose .check method determines whether the given question is valid. */ + public static ValidationUtils.RuleValidator questionValidator( + final BiFunction logged + ) { + return new ValidationUtils.RuleValidator() + .add(Constants.FEEDBACK_NO_CORRECT_ANSWERS, q -> logged.apply( q.getChoices() == null || q.getChoices().isEmpty(), - "Question does not have any answers. %s src: %s", q.getId(), q.getCanonicalSourceFile() + String.format("Question does not have any answers. %s src: %s", q.getId(), q.getCanonicalSourceFile()) )) - .add(Constants.FEEDBACK_NO_CORRECT_ANSWERS, (q, a) -> logged( + .add(Constants.FEEDBACK_NO_CORRECT_ANSWERS, q -> logged.apply( q.getChoices().stream().noneMatch(Choice::isCorrect), - "Question does not have any correct answers. %s src: %s", q.getId(), q.getCanonicalSourceFile() + String.format("Question does not have any correct answers. %s src: %s", q.getId(), q.getCanonicalSourceFile()) )) - .add(FEEDBACK_QUESTION_INVALID_ANS, (q, a) -> q.getChoices().stream().anyMatch(c -> logged( + .add(FEEDBACK_QUESTION_INVALID_ANS, q -> q.getChoices().stream().anyMatch(c -> logged.apply( !DndChoice.class.equals(c.getClass()), - "Expected DndItem in question (%s), instead found %s!", q.getId(), c.getClass() + String.format("Expected DndItem in question (%s), instead found %s!", q.getId(), c.getClass()) ))) - .add(FEEDBACK_QUESTION_EMPTY_ANS, (q, a) -> QuestionHelpers.getChoices(q).anyMatch(c -> logged( + .add(FEEDBACK_QUESTION_EMPTY_ANS, q -> QuestionHelpers.getChoices(q).anyMatch(c -> logged.apply( c.getItems() == null || c.getItems().isEmpty(), - "Expected list of DndItems, but none found in choice for question id (%s)!", q.getId() + String.format("Expected list of DndItems, but none found in choice for question id (%s)!", q.getId()) ))) - .add(FEEDBACK_QUESTION_INVALID_ANS, (q, a) -> QuestionHelpers.getChoices(q).anyMatch(c -> logged( + .add(FEEDBACK_QUESTION_INVALID_ANS, q -> QuestionHelpers.getChoices(q).anyMatch(c -> logged.apply( c.getItems().stream().anyMatch(i -> i.getClass() != DndItem.class), - "Expected list of DndItems, but something else found in choice for question id (%s)!", q.getId() + String.format("Expected list of DndItems, but something else found in choice for question id (%s)!", q.getId()) ))) - .add(FEEDBACK_QUESTION_UNRECOGNISED_ANS, (q, a) -> QuestionHelpers.getChoices(q).anyMatch(c -> logged( + .add(FEEDBACK_QUESTION_UNRECOGNISED_ANS, q -> QuestionHelpers.getChoices(q).anyMatch(c -> logged.apply( c.getItems().stream().anyMatch(i -> i.getId() == null || i.getDropZoneId() == null - || Objects.equals(i.getId(), "") || Objects.equals(i.getDropZoneId(), "") - ), "Found item with missing id or drop zone id in answer for question id (%s)!", q.getId() + || Objects.equals(i.getId(), "") || Objects.equals(i.getDropZoneId(), "") + ), String.format("Found item with missing id or drop zone id in answer for question id (%s)!", q.getId()) ))) - .add(FEEDBACK_QUESTION_MISSING_ITEMS, (q, a) -> logged( + .add(FEEDBACK_QUESTION_MISSING_ITEMS, q -> logged.apply( q.getItems() == null || q.getItems().isEmpty(), - "Expected items in question (%s), but didn't find any!", q.getId() + String.format("Expected items in question (%s), but didn't find any!", q.getId()) )) - .add(FEEDBACK_QUESTION_NO_DZ, (q, a) -> logged( + .add(FEEDBACK_QUESTION_NO_DZ, q -> logged.apply( QuestionHelpers.getDropZones(q).isEmpty(), - "Question does not have any drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile() + String.format("Question does not have any drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile()) )) - .add(FEEDBACK_QUESTION_DUP_DZ, (q, a) -> logged( + .add(FEEDBACK_QUESTION_DUP_DZ, q -> logged.apply( QuestionHelpers.getDropZones(q).size() != new HashSet<>(QuestionHelpers.getDropZones(q)).size(), - "Question contains duplicate drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile() + String.format("Question contains duplicate drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile()) )) - .add(FEEDBACK_QUESTION_UNUSED_DZ, (q, a) -> QuestionHelpers.anyCorrectMatch(q, c -> logged( + .add(FEEDBACK_QUESTION_UNUSED_DZ, q -> QuestionHelpers.anyCorrectMatch(q, c -> logged.apply( QuestionHelpers.getDropZones(q).size() != c.getItems().size(), - "Question contains correct answer that doesn't use all drop zones. %s src %s", - q.getId(), q.getCanonicalSourceFile() + String.format("Question contains correct answer that doesn't use all drop zones. %s src %s", + q.getId(), q.getCanonicalSourceFile()) ))) - .add(FEEDBACK_QUESTION_INVALID_DZ, (q, a) -> QuestionHelpers.getChoices(q).anyMatch(c -> logged( + .add(FEEDBACK_QUESTION_INVALID_DZ, q -> QuestionHelpers.getChoices(q).anyMatch(c -> logged.apply( c.getItems().stream().anyMatch(i -> !QuestionHelpers.getDropZones(q).contains(i.getDropZoneId())), - "Question contains invalid drop zone reference. %s src %s", q.getId(), q.getCanonicalSourceFile() - ))) - - // answer - .add(Constants.FEEDBACK_NO_ANSWER_PROVIDED, (q, a) -> a.getItems() == null || a.getItems().isEmpty()) - .add(Constants.FEEDBACK_UNRECOGNISED_FORMAT, (q, a) -> a.getItems().stream().anyMatch(i -> - i.getId() == null || i.getDropZoneId() == null - )) - .add(Constants.FEEDBACK_UNRECOGNISED_ITEMS, (q, a) -> a.getItems().stream().anyMatch(i -> - !q.getItems().contains(i) - || !QuestionHelpers.getDropZones(q).contains(i.getDropZoneId())) - ) - .add(FEEDBACK_ANSWER_TOO_MUCH, (q, a) -> - a.getItems().size() > QuestionHelpers.getAnyCorrect(q).map(c -> c.getItems().size()).orElse(0) - ) - .check((IsaacDndQuestion) question, (DndChoice) answer); + String.format("Question contains invalid drop zone reference. %s src %s", q.getId(), q.getCanonicalSourceFile()) + ))); } - private static boolean logged(final boolean result, final String message, final Object... args) { + private static boolean logIfTrue(final boolean result, final String message) { if (result) { - log.error(String.format(message, args)); + log.error(message); } return result; } @@ -204,11 +209,11 @@ public static List getDropZones(final IsaacDndQuestion question) { return List.of(); } return question.getChildren().stream() - .flatMap(QuestionHelpers::getContentDropZones) + .flatMap(QuestionHelpers::getDropZones) .collect(Collectors.toList()); } - private static Stream getContentDropZones(final ContentBase content) { + private static Stream getDropZones(final ContentBase content) { if (content instanceof Figure && ((Figure) content).getFigureRegions() != null) { var figure = (Figure) content; return figure.getFigureRegions().stream().map(FigureRegion::getId); @@ -220,7 +225,7 @@ private static Stream getContentDropZones(final ContentBase content) { return dndDropZoneRegex.matcher(textContent.getValue()).results().map(mr -> mr.group(1)); } if (content instanceof Content && ((Content) content).getChildren() != null) { - return ((Content) content).getChildren().stream().flatMap(QuestionHelpers::getContentDropZones); + return ((Content) content).getChildren().stream().flatMap(QuestionHelpers::getDropZones); } return Stream.of(); diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/ValidationUtils.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/ValidationUtils.java index 5ab15ffa06..1c9bc23911 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/ValidationUtils.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/ValidationUtils.java @@ -10,6 +10,7 @@ import java.util.Objects; import java.util.Optional; import java.util.function.BiPredicate; +import java.util.function.Predicate; import static java.lang.Math.max; import static java.lang.Math.min; @@ -283,18 +284,29 @@ public static boolean tooManySignificantFigures(final String valueToCheck, final return sigFigsFromUser.sigFigsMin > maxAllowedSigFigs; } + /** Validate a set of rules. Each rule takes two arguments. `.add` some rules, then `.check` whether they held.*/ public static class BiRuleValidator { - private final List> rules = new ArrayList<>(); + protected final List> rules = new ArrayList<>(); + private Logger log = null; + + /** Create a BiRuleValidator from a RuleValidator. */ + public static BiRuleValidator of(final RuleValidator validator) { + BiRuleValidator biValidator = new BiRuleValidator<>(); + validator.rules.forEach(r -> biValidator.add(r.message, (t, u) -> r.predicate.test(t, null))); + return biValidator; + } - public BiRuleValidator add(final String key, final BiPredicate rule) { - rules.add(new Rule<>(key, rule)); + public BiRuleValidator add(final String message, final BiPredicate rule) { + rules.add(new Rule<>(message, rule)); return this; } - /** - * Applies the ruleset and returns the result. If an error was found, a description of the issue is available in - * the optional. If validation has passed, the optional is empty. - */ + public BiRuleValidator setLogger(final Logger log) { + this.log = log; + return this; + } + + /** Apply the validation rules on a set of objects. */ public Optional check(final T t, final U u) { return rules.stream() .filter(r -> r.predicate.test(t, u)) @@ -302,7 +314,8 @@ public Optional check(final T t, final U u) { .findFirst(); } - private static class Rule { + /** A rule used by either a BiRuleValidator or a RuleValidator. */ + protected static class Rule { public final String message; public final BiPredicate predicate; @@ -312,4 +325,16 @@ public Rule(final String message, final BiPredicate predicate) { } } } + + /** A specialized BiRuleValidator whose rules take a single argument. */ + public static class RuleValidator extends BiRuleValidator { + public RuleValidator add(final String message, final Predicate rule) { + super.add(message, (t, ignored) -> rule.test(t)); + return this; + } + + public Optional check(final T t) { + return super.check(t, null); + } + } } diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/etl/ContentIndexer.java b/src/main/java/uk/ac/cam/cl/dtg/segue/etl/ContentIndexer.java index 3204d55092..39b33949bc 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/etl/ContentIndexer.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/etl/ContentIndexer.java @@ -21,6 +21,7 @@ import uk.ac.cam.cl.dtg.isaac.dos.IsaacCardDeck; import uk.ac.cam.cl.dtg.isaac.dos.IsaacClozeQuestion; import uk.ac.cam.cl.dtg.isaac.dos.IsaacCoordinateQuestion; +import uk.ac.cam.cl.dtg.isaac.dos.IsaacDndQuestion; import uk.ac.cam.cl.dtg.isaac.dos.IsaacEventPage; import uk.ac.cam.cl.dtg.isaac.dos.IsaacNumericQuestion; import uk.ac.cam.cl.dtg.isaac.dos.IsaacQuestionBase; @@ -45,6 +46,7 @@ import uk.ac.cam.cl.dtg.isaac.dos.content.Quantity; import uk.ac.cam.cl.dtg.isaac.dos.content.Question; import uk.ac.cam.cl.dtg.isaac.dos.content.Video; +import uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidator; import uk.ac.cam.cl.dtg.segue.api.Constants; import uk.ac.cam.cl.dtg.segue.dao.content.ContentManagerException; import uk.ac.cam.cl.dtg.segue.dao.content.ContentSubclassMapper; @@ -67,6 +69,7 @@ import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiFunction; import java.util.stream.Collectors; import static com.google.common.collect.Maps.immutableEntry; @@ -1153,6 +1156,14 @@ private void recordContentTypeSpecificError(final String sha, final Content cont } } + if (content instanceof IsaacDndQuestion) { + BiFunction noLog = (res, msg) -> res; + IsaacDndValidator.questionValidator(noLog).check((IsaacDndQuestion) content).ifPresent(err -> { + var template = "Drag-and-drop Question: %s has a problem. %s"; + this.registerContentProblem(content, String.format(template, content.getId(), err), indexProblemCache); + }); + } + if (content instanceof IsaacCoordinateQuestion) { IsaacCoordinateQuestion q = (IsaacCoordinateQuestion) content; diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 394d64bca9..5527866024 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -70,20 +70,14 @@ public class IsaacDndValidatorTest { .setQuestion(correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) .setAnswer(answer(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) .expectCorrect(true), - new CorrectnessTestCase().setTitle("singleIncorrectMatch_Incorrect") + new CorrectnessTestCase().setTitle("singleCorrectNotMatch_Incorrect") .setQuestion(correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) .setAnswer(answer(choose(item_4cm, "leg_1"), choose(item_5cm, "leg_2"), choose(item_3cm, "hypothenuse"))) .expectCorrect(false), - new CorrectnessTestCase().setTitle("partialMatchForCorrect_Incorrect") + new CorrectnessTestCase().setTitle("singleCorrectPartialMatch_Incorrect") .setQuestion(correct(choose(item_3cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_5cm, "hypothenuse"))) .setAnswer(answer(choose(item_5cm, "leg_1"), choose(item_4cm, "leg_2"), choose(item_3cm, "hypothenuse"))) .expectCorrect(false), - new CorrectnessTestCase().setTitle("moreSpecificIncorrectMatchOverridesCorrect_Incorrect") - .setQuestion( - correct(choose(item_5cm, "hypothenuse")), - incorrect(answer(choose(item_3cm, "leg_2"), choose(item_5cm, "hypothenuse"))) - ).setAnswer(answer(choose(item_3cm, "leg_2"), choose(item_5cm, "hypothenuse"))) - .expectCorrect(false), new CorrectnessTestCase().setTitle("sameAnswerCorrectAndIncorrect_Correct") .setChildren(List.of(new Content("[drop-zone:leg_1]"))) .setQuestion(incorrect(choose(item_3cm, "leg_1")), correct(choose(item_3cm, "leg_1"))) @@ -164,7 +158,7 @@ public final void testCorrectness(final CorrectnessTestCase testCase) { @Theory - public final void testExplanation(ExplanationTestCase testCase) { + public final void testExplanation(final ExplanationTestCase testCase) { var response = testValidate(testCase.question, testCase.answer); assertEquals(response.isCorrect(), testCase.correct); if (testCase.feedback != null) { @@ -445,6 +439,7 @@ private static TestAppender testValidateWithLogs(final IsaacDndQuestion question } } + @SuppressWarnings("checkstyle:MissingJavadocMethod") public static DndChoice answer(final DndItem... list) { var c = new DndChoice(); c.setItems(List.of(list)); @@ -452,12 +447,14 @@ public static DndChoice answer(final DndItem... list) { return c; } + @SuppressWarnings("checkstyle:MissingJavadocMethod") public static DndItem choose(final Item item, final String dropZoneId) { var value = new DndItem(item.getId(), item.getValue(), dropZoneId); value.setType("dndItem"); return value; } + @SuppressWarnings("checkstyle:MissingJavadocMethod") public static IsaacDndQuestion createQuestion(final DndChoice... answers) { var question = new IsaacDndQuestion(); question.setId(UUID.randomUUID().toString()); @@ -468,31 +465,33 @@ public static IsaacDndQuestion createQuestion(final DndChoice... answers) { return question; } + @SuppressWarnings("checkstyle:MissingJavadocMethod") public static DndChoice correct(final DndItem... list) { var choice = answer(list); choice.setCorrect(true); return choice; } + @SuppressWarnings("checkstyle:MissingJavadocMethod") public static DndChoice correct(final ContentBase explanation, final DndItem... list) { var choice = correct(list); choice.setExplanation(explanation); return choice; } - public static DndChoice incorrect(final DndItem... list) { + private static DndChoice incorrect(final DndItem... list) { var choice = answer(list); choice.setCorrect(false); return choice; } - public static DndChoice incorrect(final ContentBase explanation, final DndItem... list) { + private static DndChoice incorrect(final ContentBase explanation, final DndItem... list) { var choice = incorrect(list); choice.setExplanation(explanation); return choice; } - public static Item item(final String id, final String value) { + private static Item item(final String id, final String value) { Item item = new Item(id, value); item.setType("item"); return item; @@ -521,7 +520,7 @@ public Map build() { } } - public static Figure createFigure(final String... dropZones) { + private static Figure createFigure(final String... dropZones) { var figure = new Figure(); figure.setFigureRegions(new ArrayList<>(List.of())); List.of(dropZones).forEach(dropZoneId -> { diff --git a/src/test/java/uk/ac/cam/cl/dtg/segue/etl/ContentIndexerTest.java b/src/test/java/uk/ac/cam/cl/dtg/segue/etl/ContentIndexerTest.java index ff8912e50c..90887e5524 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/segue/etl/ContentIndexerTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/segue/etl/ContentIndexerTest.java @@ -19,6 +19,7 @@ import static org.junit.Assert.*; import java.util.*; +import java.util.stream.Collectors; import com.google.api.client.util.Maps; import com.google.api.client.util.Sets; @@ -31,6 +32,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import uk.ac.cam.cl.dtg.isaac.dos.IsaacNumericQuestion; +import uk.ac.cam.cl.dtg.isaac.quiz.IsaacDndValidatorTest; import uk.ac.cam.cl.dtg.segue.api.Constants; import uk.ac.cam.cl.dtg.segue.dao.content.ContentSubclassMapper; import uk.ac.cam.cl.dtg.segue.dao.content.GitContentManager; @@ -398,6 +400,62 @@ public void recordContentTypeSpecificError_disregardSigFigsSet_checkNoError() } } + @Test + public void recordContentTypeSpecificError_dndQuestionCorrect_checkNoError() throws Exception { + // ARRANGE + final Map> indexProblemCache = new HashMap<>(); + final List contents = new LinkedList<>(); + var dndQuestion = IsaacDndValidatorTest.createQuestion( + IsaacDndValidatorTest.correct(IsaacDndValidatorTest.choose(IsaacDndValidatorTest.item_3cm, "leg_1")) + ); + dndQuestion.setChildren(List.of(new Content("[drop-zone:leg_1]"))); + contents.add(dndQuestion); + + // ACT + for (Content content : contents) { + Whitebox.invokeMethod( + defaultContentIndexer, + "recordContentTypeSpecificError", + "", + content, + indexProblemCache + ); + } + + // ASSERT + assertEquals(0, indexProblemCache.size()); + } + + @Test + public void recordContentTypeSpecificError_dndQuestionInCorrect_checkErrorIsCorrect() throws Exception { + // ARRANGE + final Map> indexProblemCache = new HashMap<>(); + final List contents = new LinkedList<>(); + var dndQuestion = IsaacDndValidatorTest.createQuestion( + IsaacDndValidatorTest.correct(IsaacDndValidatorTest.choose(IsaacDndValidatorTest.item_3cm, "leg_1")) + ); + dndQuestion.setChildren(null); + dndQuestion.setCanonicalSourceFile(""); + contents.add(dndQuestion); + + // ACT + for (Content content : contents) { + Whitebox.invokeMethod( + defaultContentIndexer, + "recordContentTypeSpecificError", + "", + content, + indexProblemCache + ); + } + + // ASSERT + Collection> expected = List.of(List.of( + String.format("Drag-and-drop Question: %s has a problem. This question doesn't have any drop zones.", + dndQuestion.getId()))); + assertEquals(expected, List.copyOf(indexProblemCache.values())); + } + private Content createContentHierarchy(final int numLevels, final Set flatSet) { List children = new LinkedList(); From 0af5ec4b7a94f9474a2ca83512ddb70d7432defc Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Tue, 16 Dec 2025 16:02:51 +0000 Subject: [PATCH 63/65] clean up unused experiment from BiRuleValidator --- .../java/uk/ac/cam/cl/dtg/isaac/quiz/ValidationUtils.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/ValidationUtils.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/ValidationUtils.java index 1c9bc23911..d1cda06ea2 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/ValidationUtils.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/ValidationUtils.java @@ -287,7 +287,6 @@ public static boolean tooManySignificantFigures(final String valueToCheck, final /** Validate a set of rules. Each rule takes two arguments. `.add` some rules, then `.check` whether they held.*/ public static class BiRuleValidator { protected final List> rules = new ArrayList<>(); - private Logger log = null; /** Create a BiRuleValidator from a RuleValidator. */ public static BiRuleValidator of(final RuleValidator validator) { @@ -301,11 +300,6 @@ public BiRuleValidator add(final String message, final BiPredicate r return this; } - public BiRuleValidator setLogger(final Logger log) { - this.log = log; - return this; - } - /** Apply the validation rules on a set of objects. */ public Optional check(final T t, final U u) { return rules.stream() From 3c48992545bae406fc9ed2d028ef386f7cfaa915 Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Tue, 16 Dec 2025 17:30:02 +0000 Subject: [PATCH 64/65] add ability to deserialise DndValidationResponse this is needed when a previous question attempt is available for a question --- .../java/uk/ac/cam/cl/dtg/isaac/dos/DndValidationResponse.java | 3 +++ .../dao/users/QuestionValidationResponseDeserializer.java | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/DndValidationResponse.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/DndValidationResponse.java index f0732bc8e1..5ec85f779e 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/DndValidationResponse.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/dos/DndValidationResponse.java @@ -17,6 +17,8 @@ import uk.ac.cam.cl.dtg.isaac.dos.content.Choice; import uk.ac.cam.cl.dtg.isaac.dos.content.Content; +import uk.ac.cam.cl.dtg.isaac.dos.content.DTOMapping; +import uk.ac.cam.cl.dtg.isaac.dto.DndValidationResponseDTO; import java.util.Date; import java.util.Map; @@ -25,6 +27,7 @@ /** * Class for providing correctness feedback about drag and drop questions in a submitted Choice. */ +@DTOMapping(DndValidationResponseDTO.class) public class DndValidationResponse extends QuestionValidationResponse { private Map dropZonesCorrect; diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/QuestionValidationResponseDeserializer.java b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/QuestionValidationResponseDeserializer.java index de52a0b50d..137224cb63 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/QuestionValidationResponseDeserializer.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/QuestionValidationResponseDeserializer.java @@ -22,6 +22,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.node.ObjectNode; +import uk.ac.cam.cl.dtg.isaac.dos.DndValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.ItemValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.LLMFreeTextQuestionValidationResponse; import uk.ac.cam.cl.dtg.isaac.dos.QuantityValidationResponse; @@ -87,6 +88,8 @@ public QuestionValidationResponse deserialize(final JsonParser jsonParser, return mapper.readValue(jsonString, ItemValidationResponse.class); } else if (questionResponseType.equals("llmFreeTextChoice")) { return mapper.readValue(jsonString, LLMFreeTextQuestionValidationResponse.class); + } else if (questionResponseType.equals("dndChoice")) { + return mapper.readValue(jsonString, DndValidationResponse.class); } else { return mapper.readValue(jsonString, QuestionValidationResponse.class); } From a3f503815a39d323c57eeb6c387b1cfd32ccaf9f Mon Sep 17 00:00:00 2001 From: Barna Magyarkuti Date: Tue, 16 Dec 2025 17:49:39 +0000 Subject: [PATCH 65/65] resolve github copilot warning --- .../cl/dtg/isaac/quiz/IsaacDndValidator.java | 18 +++++++++--------- .../dtg/isaac/quiz/IsaacDndValidatorTest.java | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java index 7590b07f47..789a0d5b1d 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java +++ b/src/main/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidator.java @@ -113,7 +113,7 @@ private Optional validate(final Question question, final Choice answer) )) .add(Constants.FEEDBACK_UNRECOGNISED_ITEMS, (q, a) -> a.getItems().stream().anyMatch(i -> !q.getItems().contains(i) - || !QuestionHelpers.getDropZones(q).contains(i.getDropZoneId())) + || !QuestionHelpers.getDropZonesFromContent(q).contains(i.getDropZoneId())) ) .add(FEEDBACK_ANSWER_TOO_MUCH, (q, a) -> a.getItems().size() > QuestionHelpers.getAnyCorrect(q).map(c -> c.getItems().size()).orElse(0) @@ -157,20 +157,20 @@ public static ValidationUtils.RuleValidator questionValidator( String.format("Expected items in question (%s), but didn't find any!", q.getId()) )) .add(FEEDBACK_QUESTION_NO_DZ, q -> logged.apply( - QuestionHelpers.getDropZones(q).isEmpty(), + QuestionHelpers.getDropZonesFromContent(q).isEmpty(), String.format("Question does not have any drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile()) )) .add(FEEDBACK_QUESTION_DUP_DZ, q -> logged.apply( - QuestionHelpers.getDropZones(q).size() != new HashSet<>(QuestionHelpers.getDropZones(q)).size(), + QuestionHelpers.getDropZonesFromContent(q).size() != new HashSet<>(QuestionHelpers.getDropZonesFromContent(q)).size(), String.format("Question contains duplicate drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile()) )) .add(FEEDBACK_QUESTION_UNUSED_DZ, q -> QuestionHelpers.anyCorrectMatch(q, c -> logged.apply( - QuestionHelpers.getDropZones(q).size() != c.getItems().size(), + QuestionHelpers.getDropZonesFromContent(q).size() != c.getItems().size(), String.format("Question contains correct answer that doesn't use all drop zones. %s src %s", q.getId(), q.getCanonicalSourceFile()) ))) .add(FEEDBACK_QUESTION_INVALID_DZ, q -> QuestionHelpers.getChoices(q).anyMatch(c -> logged.apply( - c.getItems().stream().anyMatch(i -> !QuestionHelpers.getDropZones(q).contains(i.getDropZoneId())), + c.getItems().stream().anyMatch(i -> !QuestionHelpers.getDropZonesFromContent(q).contains(i.getDropZoneId())), String.format("Question contains invalid drop zone reference. %s src %s", q.getId(), q.getCanonicalSourceFile()) ))); } @@ -204,16 +204,16 @@ public static boolean getDetailedItemFeedback(final IsaacDndQuestion question) { /** * Collects the drop zone ids from any content within the question. */ - public static List getDropZones(final IsaacDndQuestion question) { + public static List getDropZonesFromContent(final IsaacDndQuestion question) { if (question.getChildren() == null) { return List.of(); } return question.getChildren().stream() - .flatMap(QuestionHelpers::getDropZones) + .flatMap(QuestionHelpers::getDropZonesFromContent) .collect(Collectors.toList()); } - private static Stream getDropZones(final ContentBase content) { + private static Stream getDropZonesFromContent(final ContentBase content) { if (content instanceof Figure && ((Figure) content).getFigureRegions() != null) { var figure = (Figure) content; return figure.getFigureRegions().stream().map(FigureRegion::getId); @@ -225,7 +225,7 @@ private static Stream getDropZones(final ContentBase content) { return dndDropZoneRegex.matcher(textContent.getValue()).results().map(mr -> mr.group(1)); } if (content instanceof Content && ((Content) content).getChildren() != null) { - return ((Content) content).getChildren().stream().flatMap(QuestionHelpers::getDropZones); + return ((Content) content).getChildren().stream().flatMap(QuestionHelpers::getDropZonesFromContent); } return Stream.of(); diff --git a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java index 5527866024..bb3c163209 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/isaac/quiz/IsaacDndValidatorTest.java @@ -417,7 +417,7 @@ public final void testQuestionValidation(final QuestionValidationTestCase testCa @Theory public final void testGetDropZones(final GetDropZonesTestCase testCase) { - var dropZones = IsaacDndValidator.QuestionHelpers.getDropZones(testCase.question); + var dropZones = IsaacDndValidator.QuestionHelpers.getDropZonesFromContent(testCase.question); assertEquals(testCase.dropZones, dropZones); }