Skip to content

Commit

Permalink
feat: calculated expression for dynamic table size (#1148)
Browse files Browse the repository at this point in the history
  • Loading branch information
nsenave authored Nov 12, 2024
1 parent 21caa8a commit 10ded46
Show file tree
Hide file tree
Showing 9 changed files with 1,197 additions and 41 deletions.
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ java {

allprojects {
group = "fr.insee.eno"
version = "3.28.0"
version = "3.29.0"
}

subprojects {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package fr.insee.eno.core.model.question;

import fr.insee.ddi.lifecycle33.datacollection.QuestionGridType;
import fr.insee.ddi.lifecycle33.reusable.CommandCodeType;
import fr.insee.ddi.lifecycle33.reusable.CommandType;
import fr.insee.eno.core.annotations.Contexts.Context;
import fr.insee.eno.core.annotations.DDI;
import fr.insee.eno.core.annotations.Lunatic;
import fr.insee.eno.core.exceptions.business.IllegalDDIElementException;
import fr.insee.eno.core.model.calculated.CalculatedExpression;
import fr.insee.eno.core.model.code.CodeList;
import fr.insee.eno.core.model.navigation.Binding;
import fr.insee.eno.core.model.question.table.CellLabel;
Expand All @@ -21,8 +25,8 @@
/**
* Eno model class to represent dynamic table questions.
* A dynamic table question is a table question where lines can be dynamically added/removed during data collection.
* In DDI, it corresponds to a QuestionGrid similar to table questions (to be verified).
* In Lunatic, it corresponds to the RosterForLoop component (to be verified).
* In DDI, it corresponds to a QuestionGrid similar to table questions.
* In Lunatic, it corresponds to the RosterForLoop component.
*/
@Getter
@Setter
Expand Down Expand Up @@ -50,14 +54,25 @@ public class DynamicTableQuestion extends MultipleResponseQuestion implements En
@Lunatic("setMandatory(#param)")
boolean mandatory;

/** Maximum number of lines of the dynamic table.
* Note: In DDI, for some reason, if this information is missing in the xml file, the default value is 1.
* In Lunatic, this property is set in a processing class.
* @see fr.insee.eno.core.processing.out.steps.lunatic.table.DynamicTableQuestionProcessing */
@DDI("getGridDimensionList().?[#this.getRank().intValue() == 1].get(0)" +
".getRoster().getMinimumRequired()")
BigInteger minLines;

/** Minimum number of lines of the dynamic table.
* In Lunatic, this property is set in a processing class.
* @see fr.insee.eno.core.processing.out.steps.lunatic.table.DynamicTableQuestionProcessing */
@DDI("getGridDimensionList().?[#this.getRank().intValue() == 1].get(0)" +
".getRoster().getMaximumAllowed()")
BigInteger maxLines;

/** VTL expression that defines the size the dynamic table. */
@DDI("T(fr.insee.eno.core.model.question.DynamicTableQuestion).mapDDISizeExpression(#this)")
CalculatedExpression sizeExpression;

@DDI("getOutParameterList().![#this.getParameterNameArray(0).getStringArray(0).getStringValue()]")
List<String> variableNames = new ArrayList<>();

Expand All @@ -76,4 +91,23 @@ public class DynamicTableQuestion extends MultipleResponseQuestion implements En
@DDI("getCellLabelList()")
List<CellLabel> cellLabels = new ArrayList<>();

public static CommandType mapDDISizeExpression(QuestionGridType ddiDynamicTableQuestion) {
checkRank1Dimension(ddiDynamicTableQuestion);
CommandCodeType conditionForContinuation = ddiDynamicTableQuestion.getGridDimensionArray(0).getRoster()
.getConditionForContinuation();
if (conditionForContinuation == null)
return null;
return conditionForContinuation.getCommandArray(0);
}

private static void checkRank1Dimension(QuestionGridType ddiDynamicTableQuestion) {
boolean hasRank1Dimension = ddiDynamicTableQuestion.getGridDimensionList().stream()
.filter(gridDimensionType -> gridDimensionType.getRank() != null)
.anyMatch(gridDimensionType -> 1 == gridDimensionType.getRank().intValue());
if (! hasRank1Dimension)
throw new IllegalDDIElementException(
"DDI dynamic table question '" + ddiDynamicTableQuestion.getIDArray(0).getStringValue() +
"' has no rank 1 dimension.");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,12 @@
import fr.insee.eno.core.model.calculated.BindingReference;
import fr.insee.eno.core.model.calculated.CalculatedExpression;
import fr.insee.eno.core.model.navigation.StandaloneLoop;
import fr.insee.eno.core.model.question.DynamicTableQuestion;
import fr.insee.eno.core.model.variable.CalculatedVariable;
import fr.insee.eno.core.model.variable.Variable;
import fr.insee.eno.core.processing.ProcessingStep;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.*;

public class DDIResolveVariableReferencesInExpressions implements ProcessingStep<EnoQuestionnaire> {

Expand All @@ -35,6 +33,12 @@ public void apply(EnoQuestionnaire enoQuestionnaire) {
.filter(StandaloneLoop.class::isInstance)
.map(StandaloneLoop.class::cast)
.forEach(this::resolveExpression);
// Dynamic tables with size expression
enoQuestionnaire.getMultipleResponseQuestions().stream()
.filter(DynamicTableQuestion.class::isInstance).map(DynamicTableQuestion.class::cast)
.map(DynamicTableQuestion::getSizeExpression)
.filter(Objects::nonNull)
.forEach(this::resolveExpression);
}

/**
Expand All @@ -50,7 +54,7 @@ private void resolveExpression(StandaloneLoop standaloneLoop) {
resolveExpression(standaloneLoop.getMaxIteration());
}

private static void resolveExpression(CalculatedExpression expression) {
private void resolveExpression(CalculatedExpression expression) {
String value = expression.getValue();
List<BindingReference> orderedBindingReferences = orderById(expression.getBindingReferences());
// Iterate on the reverse order, so that if some references overlap, the longer one is replaced first
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,7 @@ public static void process(RosterForLoop lunaticRoster, DynamicTableQuestion eno
// Header
lunaticRoster.getHeader().addAll(HeaderCellsProcessing.from(enoTable, 0));

LinesRoster lines = new LinesRoster();
LabelType minLabel = new LabelType();
minLabel.setType(LabelTypeEnum.VTL);
minLabel.setValue(Integer.toString(enoTable.getMinLines().intValue()));
lines.setMin(minLabel);

LabelType maxLabel = new LabelType();
maxLabel.setType(LabelTypeEnum.VTL);
maxLabel.setValue(Integer.toString(enoTable.getMaxLines().intValue()));
lines.setMax(maxLabel);
lunaticRoster.setLines(lines);
setRosterSize(lunaticRoster, enoTable);

List<TableCell> enoTableCells = new ArrayList<>();
enoTableCells.addAll(enoTable.getResponseCells());
Expand All @@ -46,4 +36,41 @@ public static void process(RosterForLoop lunaticRoster, DynamicTableQuestion eno
lunaticRoster.getComponents().add(lunaticCell);
}
}

private static void setRosterSize(RosterForLoop lunaticRoster, DynamicTableQuestion enoTable) {
LinesRoster lines = new LinesRoster();
if (enoTable.getMinLines() != null && enoTable.getMaxLines() != null)
setMinMaxProperties(enoTable, lines);
else if (enoTable.getSizeExpression() != null)
setSizeExpression(enoTable, lines);
else
throw new IllegalStateException(
"Table question '" + enoTable.getId() + "' has neither a min/max nor an expression size.");
lunaticRoster.setLines(lines);
}

private static void setMinMaxProperties(DynamicTableQuestion enoTable, LinesRoster lines) {
LabelType minLabel = new LabelType();
minLabel.setType(LabelTypeEnum.VTL);
minLabel.setValue(Integer.toString(enoTable.getMinLines().intValue()));
lines.setMin(minLabel);

LabelType maxLabel = new LabelType();
maxLabel.setType(LabelTypeEnum.VTL);
maxLabel.setValue(Integer.toString(enoTable.getMaxLines().intValue()));
lines.setMax(maxLabel);
}

/** Business rule: if the size of the roster for loop (dynamic table) is defined by an expression, the expression
* cannot be different between min and max.
* Note: in this case, we could have chosen to create an 'iterations' property in the Lunatic model to have the
* same property as in loop objects, but the min=max solution does the job. */
private static void setSizeExpression(DynamicTableQuestion enoTable, LinesRoster lines) {
LabelType sizeLabel = new LabelType();
sizeLabel.setType(LabelTypeEnum.VTL);
sizeLabel.setValue(enoTable.getSizeExpression().getValue());
lines.setMin(sizeLabel);
lines.setMax(sizeLabel);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package fr.insee.eno.core.mapping.in.ddi;

import fr.insee.eno.core.exceptions.business.DDIParsingException;
import fr.insee.eno.core.mappers.DDIMapper;
import fr.insee.eno.core.model.EnoQuestionnaire;
import fr.insee.eno.core.model.question.DynamicTableQuestion;
import fr.insee.eno.core.serialize.DDIDeserializer;
import org.junit.jupiter.api.Test;

import java.math.BigInteger;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

class DynamicTableQuestionTest {

@Test
void integrationTest_tableSize() throws DDIParsingException {
// Given + When
EnoQuestionnaire enoQuestionnaire = new EnoQuestionnaire();
new DDIMapper().mapDDI(
DDIDeserializer.deserialize(this.getClass().getClassLoader().getResourceAsStream(
"integration/ddi/ddi-dynamic-table-size.xml")),
enoQuestionnaire);

// Then
List<DynamicTableQuestion> dynamicTableQuestions = enoQuestionnaire.getMultipleResponseQuestions().stream()
.filter(DynamicTableQuestion.class::isInstance).map(DynamicTableQuestion.class::cast).toList();
assertEquals(2, dynamicTableQuestions.size());
DynamicTableQuestion dynamicTableQuestion1 = dynamicTableQuestions.get(0);
DynamicTableQuestion dynamicTableQuestion2 = dynamicTableQuestions.get(1);
//
assertEquals(BigInteger.valueOf(1), dynamicTableQuestion1.getMinLines());
assertEquals(BigInteger.valueOf(5), dynamicTableQuestion1.getMaxLines());
assertNull(dynamicTableQuestion1.getSizeExpression());
//
assertEquals(BigInteger.valueOf(1), dynamicTableQuestion2.getMinLines());
assertNull(dynamicTableQuestion2.getMaxLines());
assertNotNull(dynamicTableQuestion2.getSizeExpression());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,18 @@
import fr.insee.lunatic.model.flat.ComponentTypeEnum;
import fr.insee.lunatic.model.flat.LabelTypeEnum;
import fr.insee.lunatic.model.flat.RosterForLoop;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;

import java.util.List;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DynamicTableQuestionProcessingTest {

private RosterForLoop lunaticDynamicTable;

@BeforeAll
void complexMCQ_integrationTestFromDDI() throws DDIParsingException {
@Test
void integrationTestFromDDI() throws DDIParsingException {
// Given
EnoQuestionnaire enoQuestionnaire = DDIToEno.transform(
DynamicTableQuestionProcessingTest.class.getClassLoader().getResourceAsStream(
Expand All @@ -37,32 +33,49 @@ void complexMCQ_integrationTestFromDDI() throws DDIParsingException {
assertTrue(enoDynamicTable.isPresent());

// When
lunaticDynamicTable = new RosterForLoop();
RosterForLoop lunaticDynamicTable = new RosterForLoop();
DynamicTableQuestionProcessing.process(lunaticDynamicTable, enoDynamicTable.get());

// Then
// -> tests
}

@Test
void minAndMaxIterations() {
// Min and max
assertEquals("1", lunaticDynamicTable.getLines().getMin().getValue());
assertEquals("5", lunaticDynamicTable.getLines().getMax().getValue());
assertEquals(LabelTypeEnum.VTL, lunaticDynamicTable.getLines().getMin().getType());
assertEquals(LabelTypeEnum.VTL, lunaticDynamicTable.getLines().getMax().getType());
}

@Test
void dynamicTableHeader() {
// Header
assertEquals(3, lunaticDynamicTable.getHeader().size());
}

@Test
void dynamicTableCells() {
// Cells
assertEquals(3, lunaticDynamicTable.getComponents().size());
assertEquals(ComponentTypeEnum.INPUT, lunaticDynamicTable.getComponents().get(0).getComponentType());
assertEquals(ComponentTypeEnum.INPUT_NUMBER, lunaticDynamicTable.getComponents().get(1).getComponentType());
assertEquals(ComponentTypeEnum.RADIO, lunaticDynamicTable.getComponents().get(2).getComponentType());
}

@Test
void integrationTestFromDDI_sizeExpression() throws DDIParsingException {
// Given
EnoQuestionnaire enoQuestionnaire = DDIToEno.transform(
DynamicTableQuestionProcessingTest.class.getClassLoader().getResourceAsStream(
"integration/ddi/ddi-dynamic-table-size.xml"),
EnoParameters.of(EnoParameters.Context.DEFAULT, EnoParameters.ModeParameter.CAWI));
//
List<DynamicTableQuestion> enoDynamicTable = enoQuestionnaire.getMultipleResponseQuestions().stream()
.filter(DynamicTableQuestion.class::isInstance)
.map(DynamicTableQuestion.class::cast)
.toList();
assertEquals(2, enoDynamicTable.size());

// When
RosterForLoop lunaticDynamicTable1 = new RosterForLoop();
RosterForLoop lunaticDynamicTable2 = new RosterForLoop();
DynamicTableQuestionProcessing.process(lunaticDynamicTable1, enoDynamicTable.get(0));
DynamicTableQuestionProcessing.process(lunaticDynamicTable2, enoDynamicTable.get(1));

// Then
assertEquals("1", lunaticDynamicTable1.getLines().getMin().getValue());
assertEquals("5", lunaticDynamicTable1.getLines().getMax().getValue());
assertEquals("cast(HOW_MANY, integer)", lunaticDynamicTable2.getLines().getMin().getValue());
assertEquals("cast(HOW_MANY, integer)", lunaticDynamicTable2.getLines().getMax().getValue());
}

}
Loading

0 comments on commit 10ded46

Please sign in to comment.