Repository: InseeFr/Eno
Pull request #1229: feat(lunatic): format control for duration
Author: login: RemiVerriez
Created: 2025-02-28T14:02:34Z | Merged: 2025-03-13T13:14:30Z

- Ajout des contrôles sur les bornes min/max et le format des durées selon le processus décrit ici : https://codimd.dev.kube.insee.fr/4KrlwEfqQF6_2US_qH-OvA#

- Ajout de tests et de Javadoc.

- Modification d'un DDI et d'un Json pour que certains tests passent.




Diff:
diff --git a/build.gradle.kts b/build.gradle.kts
index 99c87e626..dec9d6a01 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -16,7 +16,7 @@ java {
 
 allprojects {
     group = "fr.insee.eno"
-    version = "3.34.4"
+    version = "3.36.0"
 }
 
 subprojects {
diff --git a/eno-core/src/main/java/fr/insee/eno/core/model/question/DurationQuestion.java b/eno-core/src/main/java/fr/insee/eno/core/model/question/DurationQuestion.java
index a33666186..a384139ba 100644
--- a/eno-core/src/main/java/fr/insee/eno/core/model/question/DurationQuestion.java
+++ b/eno-core/src/main/java/fr/insee/eno/core/model/question/DurationQuestion.java
@@ -31,6 +31,7 @@ public class DurationQuestion extends SingleResponseQuestion {
             "getResponseDomain().getRangeArray(0)?.getMinimumValue()?.getStringValue() : " +
             "#index.get(#this.getResponseDomainReference().getIDArray(0).getStringValue())" +
             ".getRangeArray(0)?.getMinimumValue()?.getStringValue()")
+    @Lunatic("setMin(#param)")
     private String minValue;
 
     /**
@@ -42,6 +43,7 @@ public class DurationQuestion extends SingleResponseQuestion {
             "getResponseDomain().getRangeArray(0)?.getMaximumValue()?.getStringValue() : " +
             "#index.get(#this.getResponseDomainReference().getIDArray(0).getStringValue())" +
             ".getRangeArray(0)?.getMaximumValue()?.getStringValue()")
+    @Lunatic("setMax(#param)")
     private String maxValue;
 
     /**
diff --git a/eno-core/src/main/java/fr/insee/eno/core/processing/out/LunaticProcessing.java b/eno-core/src/main/java/fr/insee/eno/core/processing/out/LunaticProcessing.java
index f6897e2a4..fba8e3cc4 100644
--- a/eno-core/src/main/java/fr/insee/eno/core/processing/out/LunaticProcessing.java
+++ b/eno-core/src/main/java/fr/insee/eno/core/processing/out/LunaticProcessing.java
@@ -58,6 +58,7 @@ public void applyProcessing(Questionnaire lunaticQuestionnaire, EnoQuestionnaire
                 .then(new LunaticShapeFrom())
                 .thenIf(enoParameters.getModeParameter().equals(EnoParameters.ModeParameter.CAWI), new LunaticSequenceDescription())
                 .then(new LunaticInputNumberDescription(enoParameters.getLanguage()))
+                .then(new LunaticDurationDescription())
                 .then(new LunaticQuestionComponent())
                 .then(new LunaticRoundaboutLoops(enoQuestionnaire));
     }
diff --git a/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticAddControlFormat.java b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticAddControlFormat.java
index ea8e0f4ce..f39d35a1e 100644
--- a/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticAddControlFormat.java
+++ b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticAddControlFormat.java
@@ -1,21 +1,12 @@
 package fr.insee.eno.core.processing.out.steps.lunatic;
 
-import fr.insee.eno.core.exceptions.business.InvalidValueException;
-import fr.insee.eno.core.exceptions.business.RequiredPropertyException;
-import fr.insee.eno.core.i18n.date.DateFormatter;
-import fr.insee.eno.core.model.question.DateQuestion;
 import fr.insee.eno.core.parameter.EnoParameters;
 import fr.insee.eno.core.processing.ProcessingStep;
+import fr.insee.eno.core.processing.out.steps.lunatic.control.*;
 import fr.insee.lunatic.model.flat.*;
-import lombok.NonNull;
 import lombok.extern.slf4j.Slf4j;
 
-import java.math.BigDecimal;
-import java.math.RoundingMode;
-import java.util.ArrayList;
 import java.util.List;
-import java.util.Objects;
-import java.util.Optional;
 
 /**
  * Processing adding format controls to components
@@ -24,15 +15,16 @@
 @Slf4j
 public class LunaticAddControlFormat implements ProcessingStep<Questionnaire> {
 
-    private final DateFormatter dateFormatter;
+    private final EnoParameters.Language language;
 
-    /** Using french as default value for date formatting. */
+    /** Using french as default value.
+     * Note: for the moment, not all messages are internationalised. */
     public LunaticAddControlFormat() {
-        dateFormatter = DateFormatter.Factory.forLanguage(EnoParameters.Language.FR);
+        this.language = EnoParameters.Language.FR;
     }
 
     public LunaticAddControlFormat(EnoParameters.Language language) {
-        dateFormatter = DateFormatter.Factory.forLanguage(language);
+        this.language = language;
     }
 
     /**
@@ -47,276 +39,34 @@ private void processComponents(List<ComponentType> components) {
         components
                 .forEach(componentType -> {
                     if(componentType instanceof ComponentNestingType componentNesting) {
-                        processComponents(componentNesting.getComponents());
+                        processComponents(componentNesting.getComponents()); // recursive call
                         return;
                     }
 
                     if(componentType instanceof Table table) {
-                        createFormatControlsForTable(table);
+                        new LunaticTableControls().insertFormatControls(table);
                         return;
                     }
 
                     if(componentType instanceof RosterForLoop roster) {
-                        createFormatControlsForRoster(roster);
+                        new LunaticRosterControl().insertFormatControls(roster);
                         return;
                     }
 
                     if(componentType instanceof InputNumber number) {
-                        createFormatControlsForInputNumber(number);
+                        new LunaticInputNumberControl().insertFormatControls(number);
                         return;
                     }
 
                     if(componentType instanceof Datepicker datepicker) {
-                        createFormatControlsForDatepicker(datepicker);
+                        new LunaticDatepickerControl(language).insertFormatControls(datepicker);
+                        return;
                     }
-                });
-    }
-
-    private void createFormatControlsForTable(Table table) {
-        List<ControlType> controls = table.getControls();
-        for(BodyLine bodyLine : table.getBodyLines()) {
-            controls.addAll(0, getFormatControlsForBodyCells(bodyLine.getBodyCells()));
-        }
-    }
-
-    private void createFormatControlsForRoster(RosterForLoop roster) {
-        List<ControlType> controls = getFormatControlsForBodyCells(roster.getComponents());
 
-        // The format controls of a roster for loop (dynamic table) are row-level controls
-        controls.forEach(control -> control.setType(ControlContextType.ROW));
-
-        roster.getControls().addAll(0, controls);
-    }
-
-    private List<ControlType> getFormatControlsForBodyCells(List<BodyCell> bodyCells) {
-        List<ControlType> controls = new ArrayList<>();
-
-        bodyCells.stream()
-                .filter(Objects::nonNull)
-                .forEach(bodyCell -> {
-                    if(ComponentTypeEnum.INPUT_NUMBER.equals(bodyCell.getComponentType())) {
-                        controls.addAll(
-                            getFormatControlsFromInputNumberAttributes(bodyCell.getId(), bodyCell.getMin(), bodyCell.getMax(),
-                                    bodyCell.getDecimals().intValue(), bodyCell.getResponse().getName())
-                        );
+                    if (componentType instanceof Duration duration){
+                        new LunaticDurationControl().insertFormatControls(duration);
                     }
-
-                    // TODO: Implements date pickers components correctly in tables/rosters
-                    /*
-                    if(ComponentTypeEnum.DATEPICKER.equals(bodyCell.getComponentType())) {
-                        controls.add(
-                                getFormatControlFromDatepickerAttributes(bodyCell.getId(), bodyCell.getMin(), bodyCell.getMax(),
-                                        bodyCell.getDecimals().intValue(), bodyCell.getResponse().getName()));
-                    }*/
-        });
-        return controls;
-    }
-
-    // TODO: internationalization of the generated messages
-
-    /**
-     * Create controls for an input number component
-     * @param number input number to process
-     */
-    private void createFormatControlsForInputNumber(InputNumber number) {
-        number.getControls().addAll(0, getFormatControlsFromInputNumberAttributes(number.getId(),
-                number.getMin(), number.getMax(), number.getDecimals().intValue(), number.getResponse().getName()));
-    }
-
-    /**
-     * Create controls from input number attributes
-     * @param id input number id
-     * @param min min value
-     * @param max max value
-     * @param decimalsCount number of decimals allowed
-     * @param responseName input number reponse attribute
-     */
-    private List<ControlType> getFormatControlsFromInputNumberAttributes(String id, Double min, Double max, int decimalsCount, String responseName) {
-        String controlIdPrefix = id + "-format";
-        List<ControlType> controls = new ArrayList<>();
-
-        if(min != null && max != null) {
-            String minValue = formatDoubleValue(min, decimalsCount);
-            String maxValue = formatDoubleValue(max, decimalsCount);
-            String controlExpression = String.format("not(not(isnull(%s)) and (%s>%s or %s<%s))", responseName, minValue, responseName, maxValue, responseName);
-            String controlErrorMessage = String.format("\" La valeur doit être comprise entre %s et %s.\"", minValue, maxValue);
-            controls.addFirst(createFormatControl(controlIdPrefix+"-borne-inf-sup", controlExpression, controlErrorMessage));
-        }
-
-        if(min == null && max != null) {
-            String maxValue = formatDoubleValue(max, decimalsCount);
-            String controlExpression = String.format("not(not(isnull(%s)) and %s<%s)", responseName, maxValue, responseName);
-            String controlErrorMessage = String.format("\" La valeur doit être inférieure à %s.\"", maxValue);
-            controls.addFirst(createFormatControl(controlIdPrefix+"-borne-sup", controlExpression, controlErrorMessage));
-        }
-
-        if(min != null && max == null) {
-            String minValue = formatDoubleValue(min, decimalsCount);
-            String controlExpression = String.format("not(not(isnull(%s)) and %s>%s)", responseName, minValue, responseName);
-            String controlErrorMessage = String.format("\" La valeur doit être supérieure à %s.\"", minValue);
-            controls.addFirst(createFormatControl(controlIdPrefix+"-borne-inf", controlExpression, controlErrorMessage));
-        }
-
-        controls.add(createDecimalsFormatControl(controlIdPrefix, responseName, decimalsCount));
-        return controls;
-    }
-
-    /**
-     * Create controls for a date picker component
-     * @param datepicker date picker to process
-     */
-    private void createFormatControlsForDatepicker(Datepicker datepicker) {
-        String id = datepicker.getId();
-        String minValue = datepicker.getMin();
-        String maxValue = datepicker.getMax();
-        String format = datepicker.getDateFormat();
-        String responseName = datepicker.getResponse().getName();
-
-        List<ControlType> controls = datepicker.getControls();
-
-        Optional<ControlType> controlYearFormat = generateDatepickerYearControl(id, format, responseName);
-        Optional<ControlType> controlBounds = getFormatControlFromDatepickerAttributes(id, minValue, maxValue, format, responseName);
-        controlBounds.ifPresent(controls::addFirst);
-        controlYearFormat.ifPresent(controls::addFirst);
-        // Note: it's important that the year format is added in first position, since in some cases only the message
-        // of the first control is displayed.
-    }
-
-    private Optional<DateFormatter.Result> validateAndConvertDate(String date, @NonNull String format) {
-        if (date == null)
-            return Optional.empty(); // The min/max date properties can eventually be null (not 'required' in Pogues)
-        DateFormatter.Result result = switch (format) {
-            case DateQuestion.YEAR_MONTH_DAY_FORMAT -> dateFormatter.convertYearMontDayDate(date);
-            case DateQuestion.YEAR_MONTH_FORMAT -> dateFormatter.convertYearMontDate(date);
-            case DateQuestion.YEAR_FORMAT -> dateFormatter.convertYearDate(date);
-            default -> throw new InvalidValueException("Date format '" + format + "' is invalid.");
-        };
-        return Optional.of(result);
-    }
-
-    /**
-     * Create controls from date picker attributes
-     * @param id date picker id
-     * @param minValue min value
-     * @param maxValue max value
-     * @param format format string
-     * @param responseName date picker response attribute
-     */
-    private Optional<ControlType> getFormatControlFromDatepickerAttributes(String id, String minValue, String maxValue, String format, String responseName) {
-        if (format == null)
-            throw new RequiredPropertyException("Format is missing in date question '" + id + "'");
-
-        String controlIdPrefix = id + "-format-date";
-
-        Optional<DateFormatter.Result> formattedMinValue = validateAndConvertDate(minValue, format);
-        if (formattedMinValue.isPresent() && !formattedMinValue.get().isValid()) {
-            String message = "Invalid value for min date of question '" + id + "': " + formattedMinValue.get().errorMessage();
-            log.error(message);
-            throw new InvalidValueException(message);
-        }
-        Optional<DateFormatter.Result> formattedMaxValue = validateAndConvertDate(maxValue, format);
-        if (formattedMaxValue.isPresent() && !formattedMaxValue.get().isValid()) {
-            // Due to a bug in the Pogues -> DDI transformation, an invalid max date doesn't throw an exception for now
-            log.warn("Invalid value for max date of question '" + id + "': " + maxValue);
-        }
-
-        boolean generateMin = formattedMinValue.isPresent(); // always valid when reached
-        boolean generateMax = formattedMaxValue.isPresent() && formattedMaxValue.get().isValid(); // can be invalid
-
-        if (generateMin && generateMax) {
-            String controlExpression = String.format(
-                    "not(not(isnull(%s)) and " +
-                            "(cast(%s, date, \"%s\")<cast(\"%s\", date, \"%s\") or " +
-                            "cast(%s, date, \"%s\")>cast(\"%s\", date, \"%s\")))",
-                    responseName, responseName, format, minValue, format, responseName, format, maxValue, format
-            );
-            String controlErrorMessage = String.format(
-                    "\"La date saisie doit être comprise entre %s et %s.\"",
-                    formattedMinValue.get().value(), formattedMaxValue.get().value()
-            );
-            return Optional.of(createFormatControl(controlIdPrefix + "-borne-inf-sup", controlExpression, controlErrorMessage));
-        }
-
-        if (generateMax) {
-            String controlExpression = String.format(
-                    "not(not(isnull(%s)) and (cast(%s, date, \"%s\")>cast(\"%s\", date, \"%s\")))",
-                    responseName, responseName, format, maxValue, format
-            );
-            String controlErrorMessage = String.format(
-                    "\"La date saisie doit être antérieure à %s.\"",
-                    formattedMaxValue.get().value()
-            );
-            return Optional.of(createFormatControl(controlIdPrefix + "-borne-sup", controlExpression, controlErrorMessage));
-        }
-
-        if (generateMin) {
-            String controlExpression = String.format(
-                    "not(not(isnull(%s)) and (cast(%s, date, \"%s\")<cast(\"%s\", date, \"%s\")))",
-                    responseName, responseName, format, minValue, format
-            );
-            String controlErrorMessage = String.format(
-                    "\"La date saisie doit être postérieure à %s.\"",
-                    formattedMinValue.get().value()
-            );
-            return Optional.of(createFormatControl(controlIdPrefix + "-borne-inf", controlExpression, controlErrorMessage));
-        }
-
-        return Optional.empty();
-    }
-    private Optional<ControlType> generateDatepickerYearControl(String id, String format, String responseName) {
-        if (format == null || !format.contains("YYYY")) {
-            log.warn("Datepicker '{}' (id={}) format is {} which doesn't have the year (YYYY).",
-                    responseName, id, format);
-            return Optional.empty();
-        }
-        String controlId = id + "-format-year";
-        String expression = String.format("not(not(isnull(%s)) and (" +
-                        "cast(cast(cast(%s, date, \"%s\"), string, \"YYYY\"), integer) <= 999 or " +
-                        "cast(cast(cast(%s, date, \"%s\"), string, \"YYYY\"), integer) > 9999))",
-                responseName, responseName, format, responseName, format);
-        String message = "\"L'année doit être saisie avec 4 chiffres.\"";
-        return Optional.of(createFormatControl(controlId, expression, message));
-    }
-
-    /**
-     *
-     * @param controlId control id
-     * @param responseName component response name
-     * @param decimalsCount decimals count allowed after semicolon
-     * @return control for a decimal count
-     */
-    private ControlType createDecimalsFormatControl(String controlId, String responseName, int decimalsCount) {
-        String controlExpression = String.format("not(not(isnull(%s))  and round(%s,%d)<>%s)", responseName, responseName, decimalsCount, responseName);
-        String controlErrorMessage = String.format("\"Le nombre doit comporter au maximum %d chiffre(s) après la virgule.\"", decimalsCount);
-        return createFormatControl(controlId+"-decimal", controlExpression, controlErrorMessage);
+                });
     }
 
-    /**
-     *
-     * @param id control id
-     * @param controlExpression vtl expression for the control
-     * @param controlErrorMessage error message for the control
-     * @return a control format
-     */
-    private ControlType createFormatControl(String id, String controlExpression, String controlErrorMessage) {
-        ControlType control = new ControlType();
-        control.setTypeOfControl(ControlTypeEnum.FORMAT);
-        control.setId(id);
-        control.setCriticality(ControlCriticalityEnum.ERROR);
-
-        LabelType controlLabel = new LabelType();
-        controlLabel.setType(LabelTypeEnum.VTL);
-        controlLabel.setValue(controlExpression);
-        control.setControl(controlLabel);
-
-        LabelType controlErrorLabel = new LabelType();
-        controlErrorLabel.setType(LabelTypeEnum.VTL_MD);
-        controlErrorLabel.setValue(controlErrorMessage);
-        control.setErrorMessage(controlErrorLabel);
-        return control;
-    }
-    
-    private String formatDoubleValue(Double value, int decimalCount) {
-        return BigDecimal.valueOf(value).setScale(decimalCount, RoundingMode.CEILING).toPlainString();
-    }
 }
diff --git a/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticDurationDescription.java b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticDurationDescription.java
new file mode 100644
index 000000000..5d6eb7fd2
--- /dev/null
+++ b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticDurationDescription.java
@@ -0,0 +1,73 @@
+package fr.insee.eno.core.processing.out.steps.lunatic;
+
+import fr.insee.eno.core.processing.ProcessingStep;
+import fr.insee.eno.core.processing.out.steps.lunatic.control.DurationControlMessage;
+import fr.insee.eno.core.processing.out.steps.lunatic.control.LunaticDurationControl;
+import fr.insee.eno.core.processing.out.steps.lunatic.control.LunaticDurationControl.DurationValue;
+import fr.insee.eno.core.processing.out.steps.lunatic.control.LunaticDurationControl.HourMinuteValue;
+import fr.insee.eno.core.processing.out.steps.lunatic.control.LunaticDurationControl.YearMonthValue;
+import fr.insee.lunatic.model.flat.*;
+
+import java.util.List;
+
+import static fr.insee.eno.core.processing.out.steps.lunatic.control.DurationControlMessage.HourMinuteControlMessage;
+import static fr.insee.eno.core.processing.out.steps.lunatic.control.DurationControlMessage.YearMonthControlMessage;
+
+/**
+ * Generate a description for duration input components.
+ */
+public class LunaticDurationDescription implements ProcessingStep<Questionnaire> {
+
+    @Override
+    public void apply(Questionnaire lunaticQuestionnaire) {
+        generateDurationDescription(lunaticQuestionnaire.getComponents());
+        lunaticQuestionnaire.getComponents().stream()
+                .filter(Loop.class::isInstance).map(Loop.class::cast)
+                .map(Loop::getComponents)
+                .forEach(this::generateDurationDescription);
+    }
+
+    private void generateDurationDescription(List<ComponentType> components) {
+        components.stream()
+                .filter(Duration.class::isInstance).map(Duration.class::cast)
+                .forEach(this::generateDurationDescription);
+    }
+
+    private void generateDurationDescription(Duration duration) {
+        String min = duration.getMin();
+        String max = duration.getMax();
+        DurationFormat format = duration.getFormat();
+
+        String generatedDescription;
+        switch (format) {
+            case DurationFormat.YEARS_MONTHS -> generatedDescription = generateYearMonthDescription(min, max);
+            case DurationFormat.HOURS_MINUTES -> generatedDescription = generateHourMinuteDescription(min, max);
+            default -> throw new IllegalArgumentException("Unknown duration format: " + format);
+        }
+
+        LabelType description = new LabelType();
+        description.setValue(generatedDescription);
+        description.setType(LabelTypeEnum.TXT);
+        duration.setDescription(description);
+    }
+
+    private String generateYearMonthDescription(String min, String max) {
+        YearMonthValue minValue = LunaticDurationControl.parseYearMonth(min);
+        YearMonthValue maxValue = LunaticDurationControl.parseYearMonth(max);
+        return durationDescriptionValue(minValue, maxValue, new YearMonthControlMessage());
+    }
+
+    private String generateHourMinuteDescription(String min, String max) {
+        HourMinuteValue minValue = LunaticDurationControl.parseHourMinute(min);
+        HourMinuteValue maxValue = LunaticDurationControl.parseHourMinute(max);
+        return durationDescriptionValue(minValue, maxValue, new HourMinuteControlMessage());
+    }
+
+    private static <T extends DurationValue> String durationDescriptionValue(T minValue, T maxValue, DurationControlMessage<T> durationFormatter) {
+        if (durationFormatter.isZeroDuration(minValue)) {
+            return String.format("Jusqu'à %s.", durationFormatter.formatDuration(maxValue));
+        }
+        return String.format("De %s à %s.", durationFormatter.formatDuration(minValue), durationFormatter.formatDuration(maxValue));
+    }
+
+}
diff --git a/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/DurationControlExpression.java b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/DurationControlExpression.java
new file mode 100644
index 000000000..a1cb8a844
--- /dev/null
+++ b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/DurationControlExpression.java
@@ -0,0 +1,53 @@
+package fr.insee.eno.core.processing.out.steps.lunatic.control;
+
+/**
+ * Class that contains the logic of VTL duration format control generation.
+ * See Eno documentation for details about the design of VTL expression.
+ * */
+public class DurationControlExpression {
+
+    private DurationControlExpression() {}
+
+    static String generateYearVTLCast(String responseName) {
+        return String.format("cast(substr(%s, 2, instr(%s, \"Y\") - 2), integer)", responseName, responseName);
+    }
+
+    static String generateMonthVTLCast(String responseName) {
+        return String.format(
+                "cast(substr(%s, instr(%s, \"Y\") + 1, instr(%s, \"M\") - instr(%s, \"Y\") - 1), integer)",
+                responseName, responseName, responseName, responseName);
+    }
+
+    static String generateHourVTLCast(String responseName) {
+        return String.format("cast(substr(%s, 3, instr(%s, \"H\") - 3), integer)", responseName, responseName);
+    }
+
+    static String generateMinuteVTLCast(String responseName) {
+        return String.format(
+                "cast(substr(%s, instr(%s, \"H\") + 1, instr(%s, \"M\") - instr(%s, \"H\") - 1), integer)",
+                responseName, responseName, responseName, responseName);
+    }
+
+    static String generateControlExpression(String responseName,
+            LunaticDurationControl.YearMonthValue minValue, LunaticDurationControl.YearMonthValue maxValue) {
+        String yearVTLCast = generateYearVTLCast(responseName);
+        String monthVTLCast = generateMonthVTLCast(responseName);
+        String minCondition = String.format("%s < %d or (%s = %d and %s < %d)",
+                yearVTLCast, minValue.years(), yearVTLCast, minValue.years(), monthVTLCast, minValue.months());
+        String maxCondition = String.format("%s > %d or (%s = %d and %s > %d)",
+                yearVTLCast, maxValue.years(), yearVTLCast, maxValue.years(), monthVTLCast, maxValue.months());
+        return String.format("not(not(isnull(%s)) and (%s or %s))", responseName, minCondition, maxCondition);
+    }
+
+    static String generateControlExpression(String responseName,
+            LunaticDurationControl.HourMinuteValue minValue, LunaticDurationControl.HourMinuteValue maxValue) {
+        String yearVTLCast = generateHourVTLCast(responseName);
+        String monthVTLCast = generateMinuteVTLCast(responseName);
+        String minCondition = String.format("%s < %d or (%s = %d and %s < %d)",
+                yearVTLCast, minValue.hours(), yearVTLCast, minValue.hours(), monthVTLCast, minValue.minutes());
+        String maxCondition = String.format("%s > %d or (%s = %d and %s > %d)",
+                yearVTLCast, maxValue.hours(), yearVTLCast, maxValue.hours(), monthVTLCast, maxValue.minutes());
+        return String.format("not(not(isnull(%s)) and (%s or %s))", responseName, minCondition, maxCondition);
+    }
+
+}
diff --git a/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/DurationControlMessage.java b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/DurationControlMessage.java
new file mode 100644
index 000000000..9be235188
--- /dev/null
+++ b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/DurationControlMessage.java
@@ -0,0 +1,96 @@
+package fr.insee.eno.core.processing.out.steps.lunatic.control;
+
+import fr.insee.eno.core.processing.out.steps.lunatic.control.LunaticDurationControl.DurationValue;
+import fr.insee.eno.core.processing.out.steps.lunatic.control.LunaticDurationControl.YearMonthValue;
+
+/**
+ * Class that contains the logic for generating error messages for duration format controls.
+ * Template method for different durations formats.
+ * */
+public abstract class DurationControlMessage<T extends DurationValue> {
+
+    String generateControlMessage(T minValue, T maxValue) {
+        if (isZeroDuration(maxValue)) {
+            throw new IllegalArgumentException("A duration question maximum value cannot be zero.");
+        }
+        if (isZeroDuration(minValue)) {
+            return String.format("\"La durée saisie doit être inférieure à %s.\"", formatDuration(maxValue));
+        }
+        if (isGreaterThan(minValue, maxValue)) {
+            throw new IllegalArgumentException("The minimum duration cannot be greater than the maximum duration.");
+        }
+        return String.format("\"La durée saisie doit être comprise entre %s minimum et %s maximum.\"",
+                formatDuration(minValue), formatDuration(maxValue));
+    }
+
+    abstract boolean isGreaterThan(T minValue, T maxValue);
+    public abstract boolean isZeroDuration(T durationValue);
+    public abstract String formatDuration(T durationValue);
+
+    public static class YearMonthControlMessage extends DurationControlMessage<YearMonthValue> {
+
+        @Override
+        public boolean isZeroDuration(YearMonthValue durationValue) {
+            return durationValue.years() == 0 && durationValue.months() == 0;
+        }
+
+        @Override
+        boolean isGreaterThan(YearMonthValue min, YearMonthValue max) {
+            if (min.years() > max.years()) return true;
+            return min.years().equals(max.years()) && min.months() > max.months();
+        }
+
+        @Override
+        public String formatDuration(YearMonthValue durationValue) {
+            int years = durationValue.years();
+            int months = durationValue.months();
+            if (years > 0 && months > 0)
+                return formatYears(years) + " et " + formatMonths(months);
+            if (years > 0)
+                return formatYears(years);
+            if (months > 0)
+                return formatMonths(months);
+            throw new IllegalArgumentException("Invalid duration: " + durationValue);
+        }
+        private static String formatYears(int years) {
+            return years == 1 ? "1 an" : String.format("%d ans", years);
+        }
+        private static String formatMonths(int months) {
+            return String.format("%d mois", months);
+        }
+    }
+
+    public static class HourMinuteControlMessage extends DurationControlMessage<LunaticDurationControl.HourMinuteValue> {
+
+        @Override
+        public boolean isZeroDuration(LunaticDurationControl.HourMinuteValue durationValue) {
+            return durationValue.hours() == 0 && durationValue.minutes() == 0;
+        }
+
+        @Override
+        boolean isGreaterThan(LunaticDurationControl.HourMinuteValue min, LunaticDurationControl.HourMinuteValue max) {
+            if (min.hours() > max.hours()) return true;
+            return min.hours().equals(max.hours()) && min.minutes() > max.minutes();
+        }
+
+        @Override
+        public String formatDuration(LunaticDurationControl.HourMinuteValue durationValue) {
+            int hours = durationValue.hours();
+            int minutes = durationValue.minutes();
+            if (hours > 0 && minutes > 0)
+                return formatHours(hours) + " et " + formatMinutes(minutes);
+            if (hours > 0)
+                return formatHours(hours);
+            if (minutes > 0)
+                return formatMinutes(minutes);
+            throw new IllegalArgumentException("Invalid duration: " + durationValue);
+        }
+        private static String formatMinutes(int minutes) {
+            return minutes == 1 ? "1 minute" : String.format("%d minutes", minutes);
+        }
+        private static String formatHours(int hours) {
+            return hours == 1 ? "1 heure" : String.format("%d heures", hours);
+        }
+    }
+
+}
diff --git a/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticDatepickerControl.java b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticDatepickerControl.java
new file mode 100644
index 000000000..88dc51caa
--- /dev/null
+++ b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticDatepickerControl.java
@@ -0,0 +1,149 @@
+package fr.insee.eno.core.processing.out.steps.lunatic.control;
+
+import fr.insee.eno.core.exceptions.business.InvalidValueException;
+import fr.insee.eno.core.exceptions.business.RequiredPropertyException;
+import fr.insee.eno.core.i18n.date.DateFormatter;
+import fr.insee.eno.core.model.question.DateQuestion;
+import fr.insee.eno.core.parameter.EnoParameters;
+import fr.insee.lunatic.model.flat.ControlType;
+import fr.insee.lunatic.model.flat.Datepicker;
+import lombok.NonNull;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+@Slf4j
+public class LunaticDatepickerControl implements LunaticFormatControl<Datepicker>  {
+
+    private final DateFormatter dateFormatter;
+
+    public LunaticDatepickerControl(EnoParameters.Language language) {
+        dateFormatter = DateFormatter.Factory.forLanguage(language);
+    }
+
+    /**
+     * Create controls for a date picker component
+     * @param datepicker date picker to process
+     */
+    @Override
+    public List<ControlType> generateFormatControls(Datepicker datepicker) {
+
+        String id = datepicker.getId();
+        String minValue = datepicker.getMin();
+        String maxValue = datepicker.getMax();
+        String format = datepicker.getDateFormat();
+        String responseName = datepicker.getResponse().getName();
+
+        List<ControlType> controls = new ArrayList<>();
+
+        Optional<ControlType> controlYearFormat = generateDatepickerYearControl(id, format, responseName);
+        Optional<ControlType> controlBounds = getFormatControlFromDatepickerAttributes(id, minValue, maxValue, format, responseName);
+        controlBounds.ifPresent(controls::addFirst);
+        controlYearFormat.ifPresent(controls::addFirst);
+        // Note: it's important that the year format is added in first position, since in some cases only the message
+        // of the first control is displayed.
+        return controls;
+    }
+
+    private Optional<DateFormatter.Result> validateAndConvertDate(String date, @NonNull String format) {
+        if (date == null)
+            return Optional.empty(); // The min/max date properties can eventually be null (not 'required' in Pogues)
+        DateFormatter.Result result = switch (format) {
+            case DateQuestion.YEAR_MONTH_DAY_FORMAT -> dateFormatter.convertYearMontDayDate(date);
+            case DateQuestion.YEAR_MONTH_FORMAT -> dateFormatter.convertYearMontDate(date);
+            case DateQuestion.YEAR_FORMAT -> dateFormatter.convertYearDate(date);
+            default -> throw new InvalidValueException("Date format '" + format + "' is invalid.");
+        };
+        return Optional.of(result);
+    }
+
+    /**
+     * Create controls from date picker attributes
+     * @param id date picker id
+     * @param minValue min value
+     * @param maxValue max value
+     * @param format format string
+     * @param responseName date picker response attribute
+     */
+    private Optional<ControlType> getFormatControlFromDatepickerAttributes(String id, String minValue, String maxValue, String format, String responseName) {
+        if (format == null)
+            throw new RequiredPropertyException("Format is missing in date question '" + id + "'");
+
+        String controlIdPrefix = id + "-format-date";
+
+        Optional<DateFormatter.Result> formattedMinValue = validateAndConvertDate(minValue, format);
+        if (formattedMinValue.isPresent() && !formattedMinValue.get().isValid()) {
+            String message = "Invalid value for min date of question '" + id + "': " + formattedMinValue.get().errorMessage();
+            log.error(message);
+            throw new InvalidValueException(message);
+        }
+        Optional<DateFormatter.Result> formattedMaxValue = validateAndConvertDate(maxValue, format);
+        if (formattedMaxValue.isPresent() && !formattedMaxValue.get().isValid()) {
+            // Due to a bug in the Pogues -> DDI transformation, an invalid max date doesn't throw an exception for now
+            log.warn("Invalid value for max date of question '" + id + "': " + maxValue);
+        }
+
+        boolean generateMin = formattedMinValue.isPresent(); // always valid when reached
+        boolean generateMax = formattedMaxValue.isPresent() && formattedMaxValue.get().isValid(); // can be invalid
+
+        if (generateMin && generateMax) {
+            String controlExpression = String.format(
+                    "not(not(isnull(%s)) and " +
+                            "(cast(%s, date, \"%s\")<cast(\"%s\", date, \"%s\") or " +
+                            "cast(%s, date, \"%s\")>cast(\"%s\", date, \"%s\")))",
+                    responseName, responseName, format, minValue, format, responseName, format, maxValue, format
+            );
+            String controlErrorMessage = String.format(
+                    "\"La date saisie doit être comprise entre %s et %s.\"",
+                    formattedMinValue.get().value(), formattedMaxValue.get().value()
+            );
+            return Optional.of(LunaticFormatControl.createFormatControl(
+                    controlIdPrefix + "-borne-inf-sup", controlExpression, controlErrorMessage));
+        }
+
+        if (generateMax) {
+            String controlExpression = String.format(
+                    "not(not(isnull(%s)) and (cast(%s, date, \"%s\")>cast(\"%s\", date, \"%s\")))",
+                    responseName, responseName, format, maxValue, format
+            );
+            String controlErrorMessage = String.format(
+                    "\"La date saisie doit être antérieure à %s.\"",
+                    formattedMaxValue.get().value()
+            );
+            return Optional.of(LunaticFormatControl.createFormatControl(
+                    controlIdPrefix + "-borne-sup", controlExpression, controlErrorMessage));
+        }
+
+        if (generateMin) {
+            String controlExpression = String.format(
+                    "not(not(isnull(%s)) and (cast(%s, date, \"%s\")<cast(\"%s\", date, \"%s\")))",
+                    responseName, responseName, format, minValue, format
+            );
+            String controlErrorMessage = String.format(
+                    "\"La date saisie doit être postérieure à %s.\"",
+                    formattedMinValue.get().value()
+            );
+            return Optional.of(LunaticFormatControl.createFormatControl(
+                    controlIdPrefix + "-borne-inf", controlExpression, controlErrorMessage));
+        }
+
+        return Optional.empty();
+    }
+    private Optional<ControlType> generateDatepickerYearControl(String id, String format, String responseName) {
+        if (format == null || !format.contains("YYYY")) {
+            log.warn("Datepicker '{}' (id={}) format is {} which doesn't have the year (YYYY).",
+                    responseName, id, format);
+            return Optional.empty();
+        }
+        String controlId = id + "-format-year";
+        String expression = String.format("not(not(isnull(%s)) and (" +
+                        "cast(cast(cast(%s, date, \"%s\"), string, \"YYYY\"), integer) <= 999 or " +
+                        "cast(cast(cast(%s, date, \"%s\"), string, \"YYYY\"), integer) > 9999))",
+                responseName, responseName, format, responseName, format);
+        String message = "\"L'année doit être saisie avec 4 chiffres.\"";
+        return Optional.of(LunaticFormatControl.createFormatControl(controlId, expression, message));
+    }
+
+}
diff --git a/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticDurationControl.java b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticDurationControl.java
new file mode 100644
index 000000000..1744c79c3
--- /dev/null
+++ b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticDurationControl.java
@@ -0,0 +1,98 @@
+package fr.insee.eno.core.processing.out.steps.lunatic.control;
+
+import fr.insee.eno.core.exceptions.business.RequiredPropertyException;
+import fr.insee.eno.core.processing.out.steps.lunatic.control.DurationControlMessage.HourMinuteControlMessage;
+import fr.insee.eno.core.processing.out.steps.lunatic.control.DurationControlMessage.YearMonthControlMessage;
+import fr.insee.lunatic.model.flat.*;
+import lombok.NonNull;
+
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A class responsible for controlling the validity of durations, ensuring that they fall within specified minimum and maximum values.
+ * This class handles durations in two formats: years and months, or hours and minutes.
+ */
+public class LunaticDurationControl implements LunaticFormatControl<Duration> {
+
+    private static final Pattern LUNATIC_YEAR_MONTH_PATTERN = Pattern.compile("P(\\d+)Y(\\d+)M");
+    private static final Pattern LUNATIC_HOURS_MINUTES_PATTERN = Pattern.compile("PT(\\d+)H(\\d+)M");
+
+    @Override
+    public List<ControlType> generateFormatControls(Duration lunaticDuration) {
+        return List.of(generateDurationFormatControl(lunaticDuration));
+    }
+
+    static ControlType generateDurationFormatControl(@NonNull Duration lunaticDurationComponent) {
+
+        checkNonNullProperties(lunaticDurationComponent);
+        String min = lunaticDurationComponent.getMin();
+        String max = lunaticDurationComponent.getMax();
+        DurationFormat format = lunaticDurationComponent.getFormat();
+        String responseName = lunaticDurationComponent.getResponse().getName();
+
+        String id = lunaticDurationComponent.getId() + "-format";
+
+        String controlExpression;
+        String controlMessage;
+
+        if (format == DurationFormat.YEARS_MONTHS) {
+            YearMonthValue minValue = parseYearMonth(min);
+            YearMonthValue maxValue = parseYearMonth(max);
+
+            controlExpression = DurationControlExpression.generateControlExpression(responseName, minValue, maxValue);
+            controlMessage = new YearMonthControlMessage().generateControlMessage(minValue, maxValue);
+        }
+        else if (format == DurationFormat.HOURS_MINUTES) {
+            HourMinuteValue minValue = parseHourMinute(min);
+            HourMinuteValue maxValue = parseHourMinute(max);
+
+            controlExpression = DurationControlExpression.generateControlExpression(responseName, minValue, maxValue);
+            controlMessage = new HourMinuteControlMessage().generateControlMessage(minValue, maxValue);
+        }
+        else {
+            throw new IllegalArgumentException("Unknown duration format: " + format);
+        }
+
+        return LunaticFormatControl.createFormatControl(id, controlExpression, controlMessage);
+    }
+
+    private static void checkNonNullProperties(@NonNull Duration lunaticDurationComponent) {
+        String exceptionMessage = "Duration component '%s' has a null %s.";
+        String id = lunaticDurationComponent.getId();
+        if (lunaticDurationComponent.getFormat() == null)
+            throw new RequiredPropertyException(String.format(exceptionMessage, id, "format"));
+        if (lunaticDurationComponent.getMin() == null)
+            throw new RequiredPropertyException(String.format(exceptionMessage, id, "min"));
+        if (lunaticDurationComponent.getMax() == null)
+            throw new RequiredPropertyException(String.format(exceptionMessage, id, "max"));
+    }
+
+    public interface DurationValue {}
+    public record YearMonthValue(Integer years, Integer months) implements DurationValue {}
+    public record HourMinuteValue(Integer hours, Integer minutes) implements DurationValue {}
+
+    public static YearMonthValue parseYearMonth(String yearMonthString) {
+        Matcher matcher = LUNATIC_YEAR_MONTH_PATTERN.matcher(yearMonthString);
+
+        if (!(matcher.find() && matcher.groupCount() == 2))
+            throw new IllegalArgumentException("Invalid year/month duration: " + yearMonthString);
+
+        Integer year = Integer.parseInt(matcher.group(1));
+        Integer month = Integer.parseInt(matcher.group(2));
+        return new YearMonthValue(year, month);
+    }
+
+    public static HourMinuteValue parseHourMinute(String hourMinuteString) {
+        Matcher matcher = LUNATIC_HOURS_MINUTES_PATTERN.matcher(hourMinuteString);
+
+        if (!(matcher.find() && matcher.groupCount() == 2))
+            throw new IllegalArgumentException("Invalid hour/minute duration: " + hourMinuteString);
+
+        Integer hour = Integer.parseInt(matcher.group(1));
+        Integer minute = Integer.parseInt(matcher.group(2));
+        return new HourMinuteValue(hour, minute);
+    }
+
+}
diff --git a/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticFormatControl.java b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticFormatControl.java
new file mode 100644
index 000000000..0b47e45dc
--- /dev/null
+++ b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticFormatControl.java
@@ -0,0 +1,41 @@
+package fr.insee.eno.core.processing.out.steps.lunatic.control;
+
+import fr.insee.lunatic.model.flat.*;
+
+import java.util.List;
+
+public interface LunaticFormatControl<T extends ComponentType> {
+
+    default void insertFormatControls(T lunaticComponent) {
+        // Format controls are inserted at index 0 (=> to be the firsts in the controls list)
+        lunaticComponent.getControls().addAll(0, generateFormatControls(lunaticComponent));
+    }
+
+    List<ControlType> generateFormatControls(T lunaticComponent);
+
+    /**
+     * Creates a Lunatic format control object.
+     * @param id Identifier of the control object.
+     * @param expression VTL expression.
+     * @param message Error message of the control.
+     * @return Lunatic format control object.
+     */
+    static ControlType createFormatControl(String id, String expression, String message) {
+        ControlType control = new ControlType();
+        control.setTypeOfControl(ControlTypeEnum.FORMAT);
+        control.setId(id);
+        control.setCriticality(ControlCriticalityEnum.ERROR);
+
+        LabelType controlLabel = new LabelType();
+        controlLabel.setType(LabelTypeEnum.VTL);
+        controlLabel.setValue(expression);
+        control.setControl(controlLabel);
+
+        LabelType controlErrorLabel = new LabelType();
+        controlErrorLabel.setType(LabelTypeEnum.VTL_MD);
+        controlErrorLabel.setValue(message);
+        control.setErrorMessage(controlErrorLabel);
+        return control;
+    }
+
+}
diff --git a/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticInputNumberControl.java b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticInputNumberControl.java
new file mode 100644
index 000000000..f8e00b54b
--- /dev/null
+++ b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticInputNumberControl.java
@@ -0,0 +1,86 @@
+package fr.insee.eno.core.processing.out.steps.lunatic.control;
+
+import fr.insee.lunatic.model.flat.ControlType;
+import fr.insee.lunatic.model.flat.InputNumber;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.List;
+
+public class LunaticInputNumberControl implements LunaticFormatControl<InputNumber> {
+
+    /**
+     * Create controls for an input number component
+     * @param inputNumber input number to process
+     */
+    @Override
+    public List<ControlType> generateFormatControls(InputNumber inputNumber) {
+        return getFormatControlsFromInputNumberAttributes(
+                inputNumber.getId(),
+                inputNumber.getMin(),
+                inputNumber.getMax(),
+                inputNumber.getDecimals().intValue(),
+                inputNumber.getResponse().getName());
+    }
+
+    /**
+     * Create controls from input number attributes
+     * @param id input number id
+     * @param min min value
+     * @param max max value
+     * @param decimalsCount number of decimals allowed
+     * @param responseName input number response attribute
+     */
+    static List<ControlType> getFormatControlsFromInputNumberAttributes(String id, Double min, Double max, int decimalsCount, String responseName) {
+        String controlIdPrefix = id + "-format";
+        List<ControlType> controls = new ArrayList<>();
+
+        if(min != null && max != null) {
+            String minValue = formatDoubleValue(min, decimalsCount);
+            String maxValue = formatDoubleValue(max, decimalsCount);
+            String controlExpression = String.format("not(not(isnull(%s)) and (%s>%s or %s<%s))", responseName, minValue, responseName, maxValue, responseName);
+            String controlErrorMessage = String.format("\" La valeur doit être comprise entre %s et %s.\"", minValue, maxValue);
+            controls.addFirst(LunaticFormatControl.createFormatControl(
+                    controlIdPrefix+"-borne-inf-sup", controlExpression, controlErrorMessage));
+        }
+
+        if(min == null && max != null) {
+            String maxValue = formatDoubleValue(max, decimalsCount);
+            String controlExpression = String.format("not(not(isnull(%s)) and %s<%s)", responseName, maxValue, responseName);
+            String controlErrorMessage = String.format("\" La valeur doit être inférieure à %s.\"", maxValue);
+            controls.addFirst(LunaticFormatControl.createFormatControl(
+                    controlIdPrefix+"-borne-sup", controlExpression, controlErrorMessage));
+        }
+
+        if(min != null && max == null) {
+            String minValue = formatDoubleValue(min, decimalsCount);
+            String controlExpression = String.format("not(not(isnull(%s)) and %s>%s)", responseName, minValue, responseName);
+            String controlErrorMessage = String.format("\" La valeur doit être supérieure à %s.\"", minValue);
+            controls.addFirst(LunaticFormatControl.createFormatControl(
+                    controlIdPrefix+"-borne-inf", controlExpression, controlErrorMessage));
+        }
+
+        controls.add(createDecimalsFormatControl(controlIdPrefix, responseName, decimalsCount));
+        return controls;
+    }
+
+    /**
+     *
+     * @param controlId control id
+     * @param responseName component response name
+     * @param decimalsCount decimals count allowed after semicolon
+     * @return control for a decimal count
+     */
+    private static ControlType createDecimalsFormatControl(String controlId, String responseName, int decimalsCount) {
+        String controlExpression = String.format("not(not(isnull(%s))  and round(%s,%d)<>%s)", responseName, responseName, decimalsCount, responseName);
+        String controlErrorMessage = String.format("\"Le nombre doit comporter au maximum %d chiffre(s) après la virgule.\"", decimalsCount);
+        return LunaticFormatControl.createFormatControl(
+                controlId+"-decimal", controlExpression, controlErrorMessage);
+    }
+
+    private static String formatDoubleValue(Double value, int decimalCount) {
+        return BigDecimal.valueOf(value).setScale(decimalCount, RoundingMode.CEILING).toPlainString();
+    }
+
+}
diff --git a/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticRosterControl.java b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticRosterControl.java
new file mode 100644
index 000000000..2d209580d
--- /dev/null
+++ b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticRosterControl.java
@@ -0,0 +1,21 @@
+package fr.insee.eno.core.processing.out.steps.lunatic.control;
+
+import fr.insee.lunatic.model.flat.ControlContextType;
+import fr.insee.lunatic.model.flat.ControlType;
+import fr.insee.lunatic.model.flat.RosterForLoop;
+
+import java.util.List;
+
+public class LunaticRosterControl implements LunaticFormatControl<RosterForLoop> {
+
+    @Override
+    public List<ControlType> generateFormatControls(RosterForLoop roster) {
+        List<ControlType> controls = LunaticTableControls.getFormatControlsForBodyCells(roster.getComponents());
+
+        // The format controls of a roster for loop (dynamic table) are row-level controls
+        controls.forEach(control -> control.setType(ControlContextType.ROW));
+
+        return controls;
+    }
+
+}
diff --git a/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticTableControls.java b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticTableControls.java
new file mode 100644
index 000000000..92028cd2a
--- /dev/null
+++ b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticTableControls.java
@@ -0,0 +1,45 @@
+package fr.insee.eno.core.processing.out.steps.lunatic.control;
+
+import fr.insee.lunatic.model.flat.*;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+public class LunaticTableControls implements LunaticFormatControl<Table> {
+
+    @Override
+    public List<ControlType> generateFormatControls(Table table) {
+        List<ControlType> controls = new ArrayList<>();
+        for(BodyLine bodyLine : table.getBodyLines()) {
+            controls.addAll(getFormatControlsForBodyCells(bodyLine.getBodyCells()));
+        }
+        return controls;
+    }
+
+    static List<ControlType> getFormatControlsForBodyCells(List<BodyCell> bodyCells) {
+        List<ControlType> controls = new ArrayList<>();
+
+        bodyCells.stream()
+                .filter(Objects::nonNull)
+                .forEach(bodyCell -> {
+                    if(ComponentTypeEnum.INPUT_NUMBER.equals(bodyCell.getComponentType())) {
+                        controls.addAll(
+                                LunaticInputNumberControl.getFormatControlsFromInputNumberAttributes(
+                                        bodyCell.getId(), bodyCell.getMin(), bodyCell.getMax(),
+                                        bodyCell.getDecimals().intValue(), bodyCell.getResponse().getName())
+                        );
+                    }
+
+                    // TODO: Implements date pickers components correctly in tables/rosters
+                    /*
+                    if(ComponentTypeEnum.DATEPICKER.equals(bodyCell.getComponentType())) {
+                        controls.add(
+                                getFormatControlFromDatepickerAttributes(bodyCell.getId(), bodyCell.getMin(), bodyCell.getMax(),
+                                        bodyCell.getDecimals().intValue(), bodyCell.getResponse().getName()));
+                    }*/
+                });
+        return controls;
+    }
+
+}
diff --git a/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/pagination/LunaticAddPageNumbers.java b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/pagination/LunaticAddPageNumbers.java
index 37f3aae65..00661f4f1 100644
--- a/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/pagination/LunaticAddPageNumbers.java
+++ b/eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/pagination/LunaticAddPageNumbers.java
@@ -2,6 +2,7 @@
 
 import fr.insee.eno.core.parameter.LunaticParameters;
 import fr.insee.eno.core.processing.ProcessingStep;
+import fr.insee.lunatic.model.flat.Pagination;
 import fr.insee.lunatic.model.flat.Questionnaire;
 import lombok.extern.slf4j.Slf4j;
 
@@ -36,12 +37,11 @@ public void apply(Questionnaire lunaticQuestionnaire) {
         }
     }
 
-    // TODO: enum in Lunatic-Model for this...
-    public static String lunaticNumberingMode(LunaticParameters.LunaticPaginationMode paginationMode) {
+    private static Pagination lunaticNumberingMode(LunaticParameters.LunaticPaginationMode paginationMode) {
         return switch (paginationMode) {
-            case NONE -> "none";
-            case SEQUENCE -> "sequence";
-            case QUESTION -> "question";
+            case NONE -> Pagination.NONE;
+            case SEQUENCE -> Pagination.SEQUENCE;
+            case QUESTION -> Pagination.QUESTION;
         };
     }
 
diff --git a/eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticAddControlFormatTest.java b/eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticAddControlFormatTest.java
index 9fccfd767..4fa6ac19a 100644
--- a/eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticAddControlFormatTest.java
+++ b/eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticAddControlFormatTest.java
@@ -18,6 +18,7 @@ class LunaticAddControlFormatTest {
     private Questionnaire lunaticQuestionnaire;
     InputNumber number;
     Datepicker datePicker;
+    Duration duration;
     LunaticAddControlFormat processing;
 
     @BeforeEach
@@ -34,6 +35,11 @@ void init() {
         number.setId("number-id");
         number.setDecimals(BigInteger.ZERO);
         number.setResponse(buildResponse("NUMBER_VAR"));
+
+        duration = new Duration();
+        duration.setComponentType(ComponentTypeEnum.DURATION);
+        duration.setId("duration-id");
+        duration.setResponse(buildResponse("DURATION_VAR"));
     }
 
     @Test
@@ -365,6 +371,24 @@ void shouldRosterComponentsHaveSubComponentsControls() {
         roster.getControls().forEach(control -> assertEquals(ControlContextType.ROW, control.getType()));
     }
 
+    /** Nominal case. Edge cases etc. are unit tested in dedicated classes. */
+    @Test
+    void durationFormatControl() {
+        lunaticQuestionnaire = new Questionnaire();
+
+        duration.setMin("P1Y2M");
+        duration.setMax("P2Y1M");
+        duration.setFormat(DurationFormat.YEARS_MONTHS);
+
+        lunaticQuestionnaire.getComponents().add(duration);
+        processing.apply(lunaticQuestionnaire);
+
+        assertEquals(1, duration.getControls().size());
+    }
+
+
+    // ----- Utility test methods below -----
+
     private ResponseType buildResponse(String name) {
         ResponseType response = new ResponseType();
         response.setName(name);
diff --git a/eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticDurationDescriptionTest.java b/eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticDurationDescriptionTest.java
new file mode 100644
index 000000000..bfe32bbcf
--- /dev/null
+++ b/eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticDurationDescriptionTest.java
@@ -0,0 +1,113 @@
+package fr.insee.eno.core.processing.out.steps.lunatic;
+
+import fr.insee.eno.core.PoguesToEno;
+import fr.insee.eno.core.exceptions.business.ParsingException;
+import fr.insee.eno.core.mappers.LunaticMapper;
+import fr.insee.eno.core.model.EnoQuestionnaire;
+import fr.insee.eno.core.parameter.EnoParameters;
+import fr.insee.eno.core.processing.out.steps.lunatic.table.LunaticTableProcessing;
+import fr.insee.lunatic.model.flat.*;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class LunaticDurationDescriptionTest {
+
+    @Test
+    void minAndMaxYearMonth() {
+        //
+        Questionnaire lunaticQuestionnaire = new Questionnaire();
+        Duration duration = new Duration();
+        duration.setFormat(DurationFormat.YEARS_MONTHS);
+        duration.setMin("P1Y3M");
+        duration.setMax("P3Y4M");
+        lunaticQuestionnaire.getComponents().add(duration);
+        //
+        LunaticDurationDescription processing = new LunaticDurationDescription();
+        processing.apply(lunaticQuestionnaire);
+        //
+        LabelType description = lunaticQuestionnaire.getComponents().getFirst().getDescription();
+        assertEquals("De 1 an et 3 mois à 3 ans et 4 mois.", description.getValue());
+        assertEquals(LabelTypeEnum.TXT, description.getType());
+    }
+
+    @Test
+    void minAndMaxHourMinute() {
+        //
+        Questionnaire lunaticQuestionnaire = new Questionnaire();
+        Duration duration = new Duration();
+        duration.setFormat(DurationFormat.HOURS_MINUTES);
+        duration.setMin("PT0H3M");
+        duration.setMax("PT5H4M");
+        lunaticQuestionnaire.getComponents().add(duration);
+        //
+        LunaticDurationDescription processing = new LunaticDurationDescription();
+        processing.apply(lunaticQuestionnaire);
+        //
+        LabelType description = lunaticQuestionnaire.getComponents().getFirst().getDescription();
+        assertEquals("De 3 minutes à 5 heures et 4 minutes.", description.getValue());
+        assertEquals(LabelTypeEnum.TXT, description.getType());
+    }
+
+    @Test
+    void withoutMinYearMonth() {
+        //
+        Questionnaire lunaticQuestionnaire = new Questionnaire();
+        Duration duration = new Duration();
+        duration.setFormat(DurationFormat.YEARS_MONTHS);
+        duration.setMin("P0Y0M");
+        duration.setMax("P3Y4M");
+        lunaticQuestionnaire.getComponents().add(duration);
+        //
+        LunaticDurationDescription processing = new LunaticDurationDescription();
+        processing.apply(lunaticQuestionnaire);
+        //
+        LabelType description = lunaticQuestionnaire.getComponents().getFirst().getDescription();
+        assertEquals("Jusqu'à 3 ans et 4 mois.", description.getValue());
+        assertEquals(LabelTypeEnum.TXT, description.getType());
+    }
+
+    @Test
+    void withoutMinHourMinute() {
+        //
+        Questionnaire lunaticQuestionnaire = new Questionnaire();
+        Duration duration = new Duration();
+        duration.setFormat(DurationFormat.HOURS_MINUTES);
+        duration.setMin("PT0H0M");
+        duration.setMax("PT1H1M");
+        lunaticQuestionnaire.getComponents().add(duration);
+        //
+        LunaticDurationDescription processing = new LunaticDurationDescription();
+        processing.apply(lunaticQuestionnaire);
+        //
+        LabelType description = lunaticQuestionnaire.getComponents().getFirst().getDescription();
+        assertEquals("Jusqu'à 1 heure et 1 minute.", description.getValue());
+        assertEquals(LabelTypeEnum.TXT, description.getType());
+    }
+
+    @Test
+    void integrationTest() throws ParsingException {
+        // Given
+        EnoQuestionnaire enoQuestionnaire = PoguesToEno.fromInputStream(
+                        this.getClass().getClassLoader().getResourceAsStream("integration/pogues/pogues-durations-2.json"))
+                .transform(EnoParameters.of(EnoParameters.Context.DEFAULT, EnoParameters.ModeParameter.CAWI));
+        Questionnaire lunaticQuestionnaire = new Questionnaire();
+        new LunaticMapper().mapQuestionnaire(enoQuestionnaire, lunaticQuestionnaire);
+        new LunaticSortComponents(enoQuestionnaire).apply(lunaticQuestionnaire);
+        new LunaticTableProcessing(enoQuestionnaire).apply(lunaticQuestionnaire);
+
+        // When
+        new LunaticDurationDescription().apply(lunaticQuestionnaire);
+
+        // Then
+        Duration duration0 = (Duration) lunaticQuestionnaire.getComponents().get(1);
+        Duration duration1 = (Duration) lunaticQuestionnaire.getComponents().get(2);
+        assertEquals("Jusqu'à 2 ans et 6 mois.",
+                duration0.getDescription().getValue());
+        assertEquals("Jusqu'à 1 heure et 30 minutes.",
+                duration1.getDescription().getValue());
+        assertEquals(LabelTypeEnum.TXT, duration0.getDescription().getType());
+        assertEquals(LabelTypeEnum.TXT, duration1.getDescription().getType());
+    }
+
+}
\ No newline at end of file
diff --git a/eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/control/DurationControlExpressionTest.java b/eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/control/DurationControlExpressionTest.java
new file mode 100644
index 000000000..0eb835044
--- /dev/null
+++ b/eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/control/DurationControlExpressionTest.java
@@ -0,0 +1,85 @@
+package fr.insee.eno.core.processing.out.steps.lunatic.control;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class DurationControlExpressionTest {
+
+    @Test
+    void testCastExpressionYear() {
+        String result = DurationControlExpression.generateYearVTLCast("DURATION_VAR");
+        String expected = "cast(substr(DURATION_VAR, 2, instr(DURATION_VAR, \"Y\") - 2), integer)";
+        assertEquals(expected, result);
+    }
+
+    @Test
+    void testCastExpressionMonth() {
+        String result = DurationControlExpression.generateMonthVTLCast("DURATION_VAR");
+        String expected = "cast(substr(DURATION_VAR, instr(DURATION_VAR, \"Y\") + 1, instr(DURATION_VAR, \"M\") - instr(DURATION_VAR, \"Y\") - 1), integer)";
+        assertEquals(expected, result);
+    }
+
+    @Test
+    void testCastExpressionHour() {
+        String result = DurationControlExpression.generateHourVTLCast("DURATION_VAR");
+        String expected = "cast(substr(DURATION_VAR, 3, instr(DURATION_VAR, \"H\") - 3), integer)";
+        assertEquals(expected, result);
+    }
+
+    @Test
+    void testCastExpressionMinute() {
+        String result = DurationControlExpression.generateMinuteVTLCast("DURATION_VAR");
+        String expected = "cast(substr(DURATION_VAR, instr(DURATION_VAR, \"H\") + 1, instr(DURATION_VAR, \"M\") - instr(DURATION_VAR, \"H\") - 1), integer)";
+        assertEquals(expected, result);
+    }
+
+    @Test
+    void fullExpression_yearMonth() {
+        //
+        LunaticDurationControl.YearMonthValue minValue = new LunaticDurationControl.YearMonthValue(0, 3);
+        LunaticDurationControl.YearMonthValue maxValue = new LunaticDurationControl.YearMonthValue(1, 6);
+        //
+        String result = DurationControlExpression.generateControlExpression("DURATION_VAR", minValue, maxValue);
+        //
+        String expected = """
+                not(
+                  not(isnull(DURATION_VAR)) and (
+                    cast(substr(DURATION_VAR, 2, instr(DURATION_VAR, "Y") - 2), integer) < 0 or (
+                      cast(substr(DURATION_VAR, 2, instr(DURATION_VAR, "Y") - 2), integer) = 0 and
+                      cast(substr(DURATION_VAR, instr(DURATION_VAR, "Y") + 1, instr(DURATION_VAR, "M") - instr(DURATION_VAR, "Y") - 1), integer) < 3
+                    ) or
+                    cast(substr(DURATION_VAR, 2, instr(DURATION_VAR, "Y") - 2), integer) > 1 or (
+                      cast(substr(DURATION_VAR, 2, instr(DURATION_VAR, "Y") - 2), integer) = 1 and
+                      cast(substr(DURATION_VAR, instr(DURATION_VAR, "Y") + 1, instr(DURATION_VAR, "M") - instr(DURATION_VAR, "Y") - 1), integer) > 6
+                    )
+                  )
+                )""".replace("\n", "").replace(" ", "");
+        assertEquals(expected, result.replace(" ", ""));
+    }
+
+    @Test
+    void fullExpression_hourMinute() {
+        //
+        LunaticDurationControl.HourMinuteValue minValue = new LunaticDurationControl.HourMinuteValue(0, 15);
+        LunaticDurationControl.HourMinuteValue maxValue = new LunaticDurationControl.HourMinuteValue(1, 30);
+        //
+        String result = DurationControlExpression.generateControlExpression("DURATION_VAR", minValue, maxValue);
+        //
+        String expected = """
+                not(
+                  not(isnull(DURATION_VAR)) and (
+                    cast(substr(DURATION_VAR, 3, instr(DURATION_VAR, "H") - 3), integer) < 0 or (
+                      cast(substr(DURATION_VAR, 3, instr(DURATION_VAR, "H") - 3), integer) = 0 and
+                      cast(substr(DURATION_VAR, instr(DURATION_VAR, "H") + 1, instr(DURATION_VAR, "M") - instr(DURATION_VAR, "H") - 1), integer) < 15
+                    ) or
+                    cast(substr(DURATION_VAR, 3, instr(DURATION_VAR, "H") - 3), integer) > 1 or (
+                      cast(substr(DURATION_VAR, 3, instr(DURATION_VAR, "H") - 3), integer) = 1 and
+                      cast(substr(DURATION_VAR, instr(DURATION_VAR, "H") + 1, instr(DURATION_VAR, "M") - instr(DURATION_VAR, "H") - 1), integer) > 30
+                    )
+                  )
+                )""".replace("\n", "").replace(" ", "");
+        assertEquals(expected, result.replace(" ", ""));
+    }
+
+}
diff --git a/eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/control/DurationControlMessageTest.java b/eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/control/DurationControlMessageTest.java
new file mode 100644
index 000000000..a0a080733
--- /dev/null
+++ b/eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/control/DurationControlMessageTest.java
@@ -0,0 +1,160 @@
+package fr.insee.eno.core.processing.out.steps.lunatic.control;
+
+import fr.insee.eno.core.processing.out.steps.lunatic.control.DurationControlMessage.HourMinuteControlMessage;
+import fr.insee.eno.core.processing.out.steps.lunatic.control.DurationControlMessage.YearMonthControlMessage;
+import fr.insee.eno.core.processing.out.steps.lunatic.control.LunaticDurationControl.HourMinuteValue;
+import fr.insee.eno.core.processing.out.steps.lunatic.control.LunaticDurationControl.YearMonthValue;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class DurationControlMessageTest {
+
+    @Nested
+    class YearMonthCase {
+        YearMonthControlMessage logicClass = new YearMonthControlMessage();
+
+        @Test
+        void messageTest_between() {
+            YearMonthValue minValue = new YearMonthValue(1, 6);
+            YearMonthValue maxValue = new YearMonthValue(3, 1);
+            //
+            String result = logicClass.generateControlMessage(minValue, maxValue);
+            //
+            String expected = "\"La durée saisie doit être comprise entre 1 an et 6 mois minimum et 3 ans et 1 mois maximum.\"";
+            assertEquals(expected, result);
+        }
+
+        @Test
+        void messageTest_between2() {
+            YearMonthValue minValue = new YearMonthValue(2, 6);
+            YearMonthValue maxValue = new YearMonthValue(3, 0);
+            //
+            String result = logicClass.generateControlMessage(minValue, maxValue);
+            //
+            String expected = "\"La durée saisie doit être comprise entre 2 ans et 6 mois minimum et 3 ans maximum.\"";
+            assertEquals(expected, result);
+        }
+
+        @Test
+        void messageTest_lowerThan() {
+            YearMonthValue minValue = new YearMonthValue(0, 0);
+            YearMonthValue maxValue = new YearMonthValue(5, 0);
+            //
+            String result = logicClass.generateControlMessage(minValue, maxValue);
+            //
+            String expected = "\"La durée saisie doit être inférieure à 5 ans.\"";
+            assertEquals(expected, result);
+        }
+
+        @Test
+        void messageTest_minGreaterThanMax1() {
+            YearMonthValue minValue = new YearMonthValue(1, 0);
+            YearMonthValue maxValue = new YearMonthValue(0, 6);
+
+            DurationControlMessage.YearMonthControlMessage logicClass1 = new DurationControlMessage.YearMonthControlMessage();
+
+            Exception exception = assertThrows(IllegalArgumentException.class, () -> {
+                logicClass1.generateControlMessage(minValue, maxValue);
+            });
+
+            assertEquals("The minimum duration cannot be greater than the maximum duration.", exception.getMessage());
+        }
+
+        @Test
+        void messageTest_minGreaterThanMax2() {
+            YearMonthValue minValue = new YearMonthValue(2, 6);
+            YearMonthValue maxValue = new YearMonthValue(2, 4);
+
+            DurationControlMessage.YearMonthControlMessage logicClass1 = new DurationControlMessage.YearMonthControlMessage();
+
+            Exception exception = assertThrows(IllegalArgumentException.class, () -> {
+                logicClass1.generateControlMessage(minValue, maxValue);
+            });
+
+            assertEquals("The minimum duration cannot be greater than the maximum duration.", exception.getMessage());
+        }
+
+        @Test
+        void formatDurationTest() {
+            assertEquals("1 an et 6 mois", logicClass.formatDuration(new YearMonthValue(1, 6)));
+            assertEquals("1 an", logicClass.formatDuration(new YearMonthValue(1, 0)));
+            assertEquals("10 ans", logicClass.formatDuration(new YearMonthValue(10, 0)));
+            assertEquals("1 mois", logicClass.formatDuration(new YearMonthValue(0, 1)));
+            assertEquals("3 mois", logicClass.formatDuration(new YearMonthValue(0, 3)));
+            YearMonthValue zeroValue = new YearMonthValue(0, 0);
+            assertThrows(IllegalArgumentException.class, () -> logicClass.formatDuration(zeroValue));
+        }
+    }
+
+    @Nested
+    class HourMinuteCase {
+        HourMinuteControlMessage logicClass = new HourMinuteControlMessage();
+
+        @Test
+        void messageTest_between() {
+            //
+            HourMinuteValue minValue = new HourMinuteValue(1, 30);
+            HourMinuteValue maxValue = new HourMinuteValue(3, 0);
+            //
+            String result = logicClass.generateControlMessage(minValue, maxValue);
+            //
+            String expected = "\"La durée saisie doit être comprise entre 1 heure et 30 minutes minimum et 3 heures maximum.\"";
+            assertEquals(expected, result);
+        }
+
+        @Test
+        void messageTest_lowerThan() {
+            //
+            HourMinuteValue minValue = new HourMinuteValue(0, 0);
+            HourMinuteValue maxValue = new HourMinuteValue(12, 0);
+            //
+            String result = logicClass.generateControlMessage(minValue, maxValue);
+            //
+            String expected = "\"La durée saisie doit être inférieure à 12 heures.\"";
+            assertEquals(expected, result);
+        }
+
+        @Test
+        void messageTest_minGreaterThanMax1() {
+            HourMinuteValue minValue = new HourMinuteValue(1, 0);
+            HourMinuteValue maxValue = new HourMinuteValue(0, 6);
+
+            DurationControlMessage.HourMinuteControlMessage logicClass1 = new DurationControlMessage.HourMinuteControlMessage();
+
+            Exception exception = assertThrows(IllegalArgumentException.class, () -> {
+                logicClass1.generateControlMessage(minValue, maxValue);
+            });
+
+            assertEquals("The minimum duration cannot be greater than the maximum duration.", exception.getMessage());
+        }
+
+        @Test
+        void messageTest_minGreaterThanMax2() {
+            HourMinuteValue minValue = new HourMinuteValue(2, 6);
+            HourMinuteValue maxValue = new HourMinuteValue(2, 4);
+
+            DurationControlMessage.HourMinuteControlMessage logicClass1 = new DurationControlMessage.HourMinuteControlMessage();
+
+            Exception exception = assertThrows(IllegalArgumentException.class, () -> {
+                logicClass1.generateControlMessage(minValue, maxValue);
+            });
+
+            assertEquals("The minimum duration cannot be greater than the maximum duration.", exception.getMessage());
+        }
+
+        @Test
+        void formatDurationValueTests() {
+            assertEquals("1 heure et 30 minutes", logicClass.formatDuration(new HourMinuteValue(1, 30)));
+            assertEquals("1 minute", logicClass.formatDuration(new HourMinuteValue(0, 1)));
+            assertEquals("45 minutes", logicClass.formatDuration(new HourMinuteValue(0, 45)));
+            assertEquals("1 heure", logicClass.formatDuration(new HourMinuteValue(1, 0)));
+            assertEquals("2 heures", logicClass.formatDuration(new HourMinuteValue(2, 0)));
+            HourMinuteValue zeroValue = new HourMinuteValue(0, 0);
+            assertThrows(IllegalArgumentException.class, () -> logicClass.formatDuration(zeroValue));
+        }
+    }
+
+}
diff --git a/eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticDurationControlTest.java b/eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticDurationControlTest.java
new file mode 100644
index 000000000..050526a1f
--- /dev/null
+++ b/eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticDurationControlTest.java
@@ -0,0 +1,127 @@
+package fr.insee.eno.core.processing.out.steps.lunatic.control;
+
+import fr.insee.eno.core.exceptions.business.RequiredPropertyException;
+import fr.insee.lunatic.model.flat.*;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static fr.insee.eno.core.processing.out.steps.lunatic.control.LunaticDurationControl.HourMinuteValue;
+import static fr.insee.eno.core.processing.out.steps.lunatic.control.LunaticDurationControl.YearMonthValue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class LunaticDurationControlTest {
+
+
+    @Test
+    void durationWithNoMin() {
+        Duration lunaticDuration = new Duration();
+        lunaticDuration.setId("duration-id");
+        lunaticDuration.setMax("P1Y0M");
+        lunaticDuration.setFormat(DurationFormat.YEARS_MONTHS);
+        RequiredPropertyException exception = assertThrows(RequiredPropertyException.class, () ->
+                LunaticDurationControl.generateDurationFormatControl(lunaticDuration));
+        assertEquals("Duration component 'duration-id' has a null min.", exception.getMessage());
+    }
+    @Test
+    void durationWithNoMax() {
+        Duration lunaticDuration = new Duration();
+        lunaticDuration.setId("duration-id");
+        lunaticDuration.setMin("P1Y0M");
+        lunaticDuration.setFormat(DurationFormat.YEARS_MONTHS);
+        RequiredPropertyException exception = assertThrows(RequiredPropertyException.class, () ->
+                LunaticDurationControl.generateDurationFormatControl(lunaticDuration));
+        assertEquals("Duration component 'duration-id' has a null max.", exception.getMessage());
+    }
+    @Test
+    void durationWithNoFormat() {
+        Duration lunaticDuration = new Duration();
+        lunaticDuration.setId("duration-id");
+        lunaticDuration.setMin("P0Y0M");
+        lunaticDuration.setMax("P1Y0M");
+        RequiredPropertyException exception = assertThrows(RequiredPropertyException.class, () ->
+                LunaticDurationControl.generateDurationFormatControl(lunaticDuration));
+        assertEquals("Duration component 'duration-id' has a null format.", exception.getMessage());
+    }
+
+
+    @Test
+    void testParseYearMonth() {
+        String yearMonthString = "P1Y6M";
+        YearMonthValue yearMonthValue = LunaticDurationControl.parseYearMonth(yearMonthString);
+        assertEquals(1, yearMonthValue.years());
+        assertEquals(6, yearMonthValue.months());
+    }
+    @Test
+    void testParseHourMinute() {
+        String hourMinuteString = "PT10H39M";
+        HourMinuteValue hourMinuteValue = LunaticDurationControl.parseHourMinute(hourMinuteString);
+        assertEquals(10, hourMinuteValue.hours());
+        assertEquals(39, hourMinuteValue.minutes());
+    }
+    @ParameterizedTest
+    @ValueSource(strings = {
+            "PT10H20M" // Incorrect format (here hours and minutes)
+            ,"P0Y" // minute value is missing
+            ,"PY5M" // year value is missing
+            ,"5Y5M" // "P" is missing
+            ,"10Y" // "P" and minute value are missing
+    })
+    void testParseYearMonth_invalidFormat(String input) {
+        assertThrows(IllegalArgumentException.class, () -> LunaticDurationControl.parseYearMonth(input));
+    }
+    @ParameterizedTest
+    @ValueSource(strings = {
+            "P1Y2M" // Incorrect format (here years and months)
+            ,"P10H20M" // should be "PT" not "P"
+            ,"PT1HM" // no value for minutes
+            ,"PTH30M" // no value for hours
+    })
+    void testParseHourMinute_invalidFormat(String input) {
+        assertThrows(IllegalArgumentException.class, () -> LunaticDurationControl.parseHourMinute(input));
+    }
+
+
+    @Test
+    void generateControl_yearMonth() {
+        //
+        Duration lunaticDuration = new Duration();
+        lunaticDuration.setId("duration-id");
+        lunaticDuration.setResponse(new ResponseType());
+        lunaticDuration.getResponse().setName("DURATION_VAR");
+        lunaticDuration.setMin("P0Y3M");
+        lunaticDuration.setMax("P1Y6M");
+        lunaticDuration.setFormat(DurationFormat.YEARS_MONTHS);
+        //
+        ControlType generatedControl = LunaticDurationControl.generateDurationFormatControl(lunaticDuration);
+        //
+        commonPropsTest(generatedControl);
+        // Note: message and expression are already thoroughly tested in dedicated classes
+    }
+    @Test
+    void generateControl_hourMinute() {
+        //
+        Duration lunaticDuration = new Duration();
+        lunaticDuration.setId("duration-id");
+        lunaticDuration.setResponse(new ResponseType());
+        lunaticDuration.getResponse().setName("DURATION_VAR");
+        lunaticDuration.setMin("PT0H15M");
+        lunaticDuration.setMax("PT1H30M");
+        lunaticDuration.setFormat(DurationFormat.HOURS_MINUTES);
+        //
+        ControlType generatedControl = LunaticDurationControl.generateDurationFormatControl(lunaticDuration);
+        //
+        commonPropsTest(generatedControl);
+        // Note: message and expression are already thoroughly tested in dedicated classes
+    }
+    private void commonPropsTest(ControlType generatedControl) {
+        assertEquals("duration-id-format", generatedControl.getId());
+        assertEquals(ControlContextType.SIMPLE, generatedControl.getType());
+        assertEquals(ControlTypeEnum.FORMAT, generatedControl.getTypeOfControl());
+        assertEquals(ControlCriticalityEnum.ERROR, generatedControl.getCriticality());
+        assertEquals(LabelTypeEnum.VTL_MD, generatedControl.getErrorMessage().getType());
+        assertEquals(LabelTypeEnum.VTL, generatedControl.getControl().getType());
+    }
+
+}
diff --git a/eno-core/src/test/resources/functional/ddi/ddi-l20g2ba7.xml b/eno-core/src/test/resources/functional/ddi/ddi-l20g2ba7.xml
index 3803c300a..aa0b0ab3f 100644
--- a/eno-core/src/test/resources/functional/ddi/ddi-l20g2ba7.xml
+++ b/eno-core/src/test/resources/functional/ddi/ddi-l20g2ba7.xml
@@ -3302,6 +3302,7 @@ never cared for what they say never cared for games they play never cared for wh
                <r:DateFieldFormat>PTnHnM</r:DateFieldFormat>
                <r:DateTypeCode controlledVocabularyID="INSEE-DTC-CV">duration</r:DateTypeCode>
                <r:Range>
+                  <r:MinimumValue included="true">PT0H0M</r:MinimumValue>
                   <r:MaximumValue included="true">PT200H59M</r:MaximumValue>
                </r:Range>
                <r:OutParameter isArray="false">
@@ -3349,6 +3350,7 @@ never cared for what they say never cared for games they play never cared for wh
                <r:DateFieldFormat>PnYnM</r:DateFieldFormat>
                <r:DateTypeCode controlledVocabularyID="INSEE-DTC-CV">duration</r:DateTypeCode>
                <r:Range>
+                  <r:MinimumValue included="true">P0Y0M</r:MinimumValue>
                   <r:MaximumValue included="true">P5Y11M</r:MaximumValue>
                </r:Range>
                <r:OutParameter isArray="false">
@@ -12624,6 +12626,7 @@ ligne 3</r:Content>
                   <r:DateFieldFormat>PTnHnM</r:DateFieldFormat>
                   <r:DateTypeCode controlledVocabularyID="INSEE-DTC-CV">duration</r:DateTypeCode>
                   <r:Range>
+                     <r:MinimumValue included="true">PT0H0M</r:MinimumValue>
                      <r:MaximumValue included="true">PT200H59M</r:MaximumValue>
                   </r:Range>
                </r:DateTimeRepresentation>
@@ -12656,6 +12659,7 @@ ligne 3</r:Content>
                   <r:DateFieldFormat>PnYnM</r:DateFieldFormat>
                   <r:DateTypeCode controlledVocabularyID="INSEE-DTC-CV">duration</r:DateTypeCode>
                   <r:Range>
+                     <r:MinimumValue included="true">PT0H0M</r:MinimumValue>
                      <r:MaximumValue included="true">P5Y11M</r:MaximumValue>
                   </r:Range>
                </r:DateTimeRepresentation>
diff --git a/eno-core/src/test/resources/integration/pogues/pogues-variables.json b/eno-core/src/test/resources/integration/pogues/pogues-variables.json
index 17a4e91b5..5dc8b3827 100644
--- a/eno-core/src/test/resources/integration/pogues/pogues-variables.json
+++ b/eno-core/src/test/resources/integration/pogues/pogues-variables.json
@@ -290,11 +290,13 @@
         }
       },
       {
-        "Label": "DURATION label",
+        "Label": "DURATION_BOUNDED label",
         "id": "lk6qeybx",
         "type": "CollectedVariableType",
-        "Name": "DURATION",
+        "Name": "DURATION_BOUNDED_1",
         "Datatype": {
+          "Maximum": "PT11H0M",
+          "Minimum": "PT3H40M",
           "Format": "PTnHnM",
           "typeName": "DURATION",
           "type": "DurationDatatypeType"
@@ -304,7 +306,7 @@
         "Label": "DURATION_BOUNDED label",
         "id": "lk6qocnd",
         "type": "CollectedVariableType",
-        "Name": "DURATION_BOUNDED",
+        "Name": "DURATION_BOUNDED_2",
         "Datatype": {
           "Maximum": "PT10H0M",
           "Minimum": "PT1H30M",
@@ -669,6 +671,8 @@
               "id": "lk6ruckh",
               "mandatory": false,
               "Datatype": {
+                "Maximum": "PT11H0M",
+                "Minimum": "PT3H40M",
                 "Format": "PTnHnM",
                 "typeName": "DURATION",
                 "type": "DurationDatatypeType"
diff --git a/eno-core/src/test/resources/logback-test.xml b/eno-core/src/test/resources/logback-test.xml
new file mode 100644
index 000000000..c4db7f548
--- /dev/null
+++ b/eno-core/src/test/resources/logback-test.xml
@@ -0,0 +1,11 @@
+<configuration>
+    <root level="WARN">
+        <appender-ref ref="CONSOLE" />
+    </root>
+
+    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>%d{yyyy-MM-dd HH:mm} %-5level %logger{36} - %msg%n</pattern>
+        </encoder>
+    </appender>
+</configuration>
diff --git a/settings.gradle.kts b/settings.gradle.kts
index d325d3cc2..a24ece7c5 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -8,7 +8,7 @@ pluginManagement {
 dependencyResolutionManagement {
     versionCatalogs {
         create("libs") {
-            version("lunatic-model", "4.0.0")
+            version("lunatic-model", "4.1.0")
             version("pogues-model", "1.6.0")
             library("lunatic-model", "fr.insee.lunatic", "lunatic-model").versionRef("lunatic-model")
             library("pogues-model", "fr.insee.pogues", "pogues-model").versionRef("pogues-model")


Reviews:
user: login: JulienCarmona
body: Good Job ! 👌 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T10:57:05Z
user: login: nsenave
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T12:54:08Z
user: login: nsenave
body: J'ai corrigé qq détails + refacto en "lego" pour essayer de simplifier certaines parties cf. #1234 , mais 👍 good job
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T13:13:20Z
user: login: nsenave
body: 👍 J'ajoute juste un commit pour dé-dupliquer un peu de code
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T14:29:16Z
user: login: nsenave
body: J'ai refait le tour ça m'a l'air ok
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T15:24:01Z

Review comments:
diff_hunk: @@ -0,0 +1,261 @@
+package fr.insee.eno.core.processing.out.steps.lunatic.control;
+
+import fr.insee.eno.core.exceptions.business.RequiredPropertyException;
+import fr.insee.eno.core.processing.out.steps.lunatic.LunaticAddControlFormat;
+import fr.insee.lunatic.model.flat.*;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static fr.insee.eno.core.processing.out.steps.lunatic.control.LunaticDurationControl.*;
+import static fr.insee.eno.core.processing.out.steps.lunatic.pagination.LunaticAddPageNumbersUtils.buildResponse;
+import static org.junit.jupiter.api.Assertions.*;
+
+class LunaticDurationControlTest {
+
+    @Test
+    void testParseYearMonth() {
+        //
+        String yearMonthString = "P1Y6M";
+        //
+        YearMonthValue yearMonthValue = LunaticDurationControl.parseYearMonth(yearMonthString);
+        //
+        assertEquals(1, yearMonthValue.years());
+        assertEquals(6, yearMonthValue.months());
+    }
+
+    @Test
+    void testParseHourMinute() {
+        //
+        String hourMinuteString = "PT10H39M";
+        //
+        HourMinuteValue hourMinuteValue = LunaticDurationControl.parseHourMinute(hourMinuteString);
+        //
+        assertEquals(10, hourMinuteValue.hours());
+        assertEquals(39, hourMinuteValue.minutes());
+    }
+
+    @Test
+    void testMessageYearMonth() {
+        //
+        YearMonthValue minValue = new YearMonthValue(1, 6);
+        YearMonthValue maxValue = new YearMonthValue(3, 1);
+        //
+        String result = generateControlMessage(minValue, maxValue);
+        //
+        String expected = "\"La durée saisie doit être comprise entre 1 an et 6 mois et 3 ans et 1 mois.\"";
+        assertEquals(expected, result);
+    }
+
+    @Test
+    void testMessageHourMinute() {
+        //
+        HourMinuteValue minValue = new HourMinuteValue(1, 30);
+        HourMinuteValue maxValue = new HourMinuteValue(3, 0);
+        //
+        String result = generateControlMessage(minValue, maxValue);
+        //
+        String expected = "\"La durée saisie doit être comprise entre 1 heure et 30 minutes et 3 heures.\"";
+        assertEquals(expected, result);
+    }
+
+    @Test
+    void testFormatDurationHourMinuteDuration() {
+        assertEquals("1 heure et 30 minutes", formatDuration(new HourMinuteValue(1, 30)));
+        assertEquals("1 minute", formatDuration(new HourMinuteValue(0, 1)));
+        assertEquals("45 minutes", formatDuration(new HourMinuteValue(0, 45)));
+        assertEquals("1 heure", formatDuration(new HourMinuteValue(1, 0)));
+        assertEquals("2 heures", formatDuration(new HourMinuteValue(2, 0)));
+    }
+
+    @Test
+    void testCastExpressionYear() {
+        String responseName = "VAR_DUREE";
+        String result = generateControlExpression(new YearMonthValue(1, 0), new YearMonthValue(10, 0));
+        String expected = String.format("cast(substr(%s, 2, instr(%s, \"Y\") - 2), integer)", responseName, responseName);
+        assertTrue(result.contains(expected), "L'expression ne contient pas l'extraction de l'année attendue");
+    }
+
+    @Test
+    void testCastExpressionMonth() {
+        String responseName = "VAR_DUREE";
+        String result = generateControlExpression(new YearMonthValue(0, 1), new YearMonthValue(0, 12));
+        String expected = String.format("cast(substr(%s, instr(%s, \"Y\") + 1, instr(%s, \"M\") - instr(%s, \"Y\") - 1), integer)", responseName, responseName, responseName, responseName);
+        assertTrue(result.contains(expected), "The expression does not contain the expected month extraction.");
+    }
+
+    @Test
+    void testCastExpressionHour() {
+        String responseName = "VAR_DUREE";
+        String result = generateControlExpression(new HourMinuteValue(1, 0), new HourMinuteValue(23, 0));
+        String expected = String.format("cast(substr(%s, 3, instr(%s, \"H\") - 3), integer)", responseName, responseName);
+        assertTrue(result.contains(expected), "The expression does not contain the expected hour extraction.");
+    }
+
+    @Test
+    void testCastExpressionMinute() {
+        String responseName = "VAR_DUREE";
+        String result = generateControlExpression(new HourMinuteValue(0, 1), new HourMinuteValue(0, 59));
+        String expected = String.format("cast(substr(%s, instr(%s, \"H\") + 1, instr(%s, \"M\") - instr(%s, \"H\") - 1), integer)", responseName, responseName, responseName, responseName);
+        assertTrue(result.contains(expected), "The expression does not contain the expected minute extraction.");
+    }
+
+    @Test
+    void testCastExpressionYearAndMonth() {
+        String responseName = "VAR_DUREE";
+        String result = generateControlExpression(new YearMonthValue(1, 6), new YearMonthValue(10, 11));
+        String expectedYear = String.format("cast(substr(%s, 2, instr(%s, \"Y\") - 2), integer)", responseName, responseName);
+        String expectedMonth = String.format("cast(substr(%s, instr(%s, \"Y\") + 1, instr(%s, \"M\") - instr(%s, \"Y\") - 1), integer)", responseName, responseName, responseName, responseName);
+        assertTrue(result.contains(expectedYear), "The expression does not contain the expected year extraction.");
+        assertTrue(result.contains(expectedMonth), "The expression does not contain the expected month extraction.");
+    }
+
+    @Test
+    void testCastExpressionHourAndMinute() {
+        String responseName = "VAR_DUREE";
+        String result = generateControlExpression(new HourMinuteValue(1, 30), new HourMinuteValue(23, 45));
+        String expectedHour = String.format("cast(substr(%s, 3, instr(%s, \"H\") - 3), integer)", responseName, responseName);
+        String expectedMinute = String.format("cast(substr(%s, instr(%s, \"H\") + 1, instr(%s, \"M\") - instr(%s, \"H\") - 1), integer)", responseName, responseName, responseName, responseName);
+        assertTrue(result.contains(expectedHour), "The expression does not contain the expected hour extraction.");
+        assertTrue(result.contains(expectedMinute), "The expression does not contain the expected minute extraction.");
+    }
+
+    private Questionnaire lunaticQuestionnaire;
+    Duration duration;
+    LunaticAddControlFormat processing;
+
+    @BeforeEach
+    void init() {
+        processing = new LunaticAddControlFormat();
+        duration = new Duration();
+        duration.setComponentType(ComponentTypeEnum.DURATION);
+        duration.setId("duration-id");
+        duration.setResponse(buildResponse("DURATION_VAR"));
+    }
+
+    @Test
+    @DisplayName("Duration: no format, should throw exception")
+    void durationNoFormatNorMinMax() {
+        lunaticQuestionnaire = new Questionnaire();
+        lunaticQuestionnaire.getComponents().add(duration);
+        assertThrows(RequiredPropertyException.class, () -> processing.apply(lunaticQuestionnaire));
path: eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticDurationControlTest.java
user: login: JulienCarmona
body: Tu ne valides pas le contenu du message de l'exception ?
Bon là l'Exception est typé et ça à l'air assez clair ce qui ne va pas mais en général c'est bien d'assert sur le message ou une partie pour être sur que c'est la bonne info qui est remontée.
created_at: 2025-03-03T10:55:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,261 @@
+package fr.insee.eno.core.processing.out.steps.lunatic.control;
+
+import fr.insee.eno.core.exceptions.business.RequiredPropertyException;
+import fr.insee.eno.core.processing.out.steps.lunatic.LunaticAddControlFormat;
+import fr.insee.lunatic.model.flat.*;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static fr.insee.eno.core.processing.out.steps.lunatic.control.LunaticDurationControl.*;
+import static fr.insee.eno.core.processing.out.steps.lunatic.pagination.LunaticAddPageNumbersUtils.buildResponse;
+import static org.junit.jupiter.api.Assertions.*;
+
+class LunaticDurationControlTest {
+
+    @Test
+    void testParseYearMonth() {
+        //
+        String yearMonthString = "P1Y6M";
+        //
+        YearMonthValue yearMonthValue = LunaticDurationControl.parseYearMonth(yearMonthString);
+        //
+        assertEquals(1, yearMonthValue.years());
+        assertEquals(6, yearMonthValue.months());
+    }
+
+    @Test
+    void testParseHourMinute() {
+        //
+        String hourMinuteString = "PT10H39M";
+        //
+        HourMinuteValue hourMinuteValue = LunaticDurationControl.parseHourMinute(hourMinuteString);
+        //
+        assertEquals(10, hourMinuteValue.hours());
+        assertEquals(39, hourMinuteValue.minutes());
+    }
+
+    @Test
+    void testMessageYearMonth() {
+        //
+        YearMonthValue minValue = new YearMonthValue(1, 6);
+        YearMonthValue maxValue = new YearMonthValue(3, 1);
+        //
+        String result = generateControlMessage(minValue, maxValue);
+        //
+        String expected = "\"La durée saisie doit être comprise entre 1 an et 6 mois et 3 ans et 1 mois.\"";
+        assertEquals(expected, result);
+    }
+
+    @Test
+    void testMessageHourMinute() {
+        //
+        HourMinuteValue minValue = new HourMinuteValue(1, 30);
+        HourMinuteValue maxValue = new HourMinuteValue(3, 0);
+        //
+        String result = generateControlMessage(minValue, maxValue);
+        //
+        String expected = "\"La durée saisie doit être comprise entre 1 heure et 30 minutes et 3 heures.\"";
+        assertEquals(expected, result);
+    }
+
+    @Test
+    void testFormatDurationHourMinuteDuration() {
+        assertEquals("1 heure et 30 minutes", formatDuration(new HourMinuteValue(1, 30)));
+        assertEquals("1 minute", formatDuration(new HourMinuteValue(0, 1)));
+        assertEquals("45 minutes", formatDuration(new HourMinuteValue(0, 45)));
+        assertEquals("1 heure", formatDuration(new HourMinuteValue(1, 0)));
+        assertEquals("2 heures", formatDuration(new HourMinuteValue(2, 0)));
+    }
+
+    @Test
+    void testCastExpressionYear() {
+        String responseName = "VAR_DUREE";
+        String result = generateControlExpression(new YearMonthValue(1, 0), new YearMonthValue(10, 0));
+        String expected = String.format("cast(substr(%s, 2, instr(%s, \"Y\") - 2), integer)", responseName, responseName);
+        assertTrue(result.contains(expected), "L'expression ne contient pas l'extraction de l'année attendue");
+    }
+
+    @Test
+    void testCastExpressionMonth() {
+        String responseName = "VAR_DUREE";
+        String result = generateControlExpression(new YearMonthValue(0, 1), new YearMonthValue(0, 12));
+        String expected = String.format("cast(substr(%s, instr(%s, \"Y\") + 1, instr(%s, \"M\") - instr(%s, \"Y\") - 1), integer)", responseName, responseName, responseName, responseName);
+        assertTrue(result.contains(expected), "The expression does not contain the expected month extraction.");
+    }
+
+    @Test
+    void testCastExpressionHour() {
+        String responseName = "VAR_DUREE";
+        String result = generateControlExpression(new HourMinuteValue(1, 0), new HourMinuteValue(23, 0));
+        String expected = String.format("cast(substr(%s, 3, instr(%s, \"H\") - 3), integer)", responseName, responseName);
+        assertTrue(result.contains(expected), "The expression does not contain the expected hour extraction.");
+    }
+
+    @Test
+    void testCastExpressionMinute() {
+        String responseName = "VAR_DUREE";
+        String result = generateControlExpression(new HourMinuteValue(0, 1), new HourMinuteValue(0, 59));
+        String expected = String.format("cast(substr(%s, instr(%s, \"H\") + 1, instr(%s, \"M\") - instr(%s, \"H\") - 1), integer)", responseName, responseName, responseName, responseName);
+        assertTrue(result.contains(expected), "The expression does not contain the expected minute extraction.");
+    }
+
+    @Test
+    void testCastExpressionYearAndMonth() {
+        String responseName = "VAR_DUREE";
+        String result = generateControlExpression(new YearMonthValue(1, 6), new YearMonthValue(10, 11));
+        String expectedYear = String.format("cast(substr(%s, 2, instr(%s, \"Y\") - 2), integer)", responseName, responseName);
+        String expectedMonth = String.format("cast(substr(%s, instr(%s, \"Y\") + 1, instr(%s, \"M\") - instr(%s, \"Y\") - 1), integer)", responseName, responseName, responseName, responseName);
+        assertTrue(result.contains(expectedYear), "The expression does not contain the expected year extraction.");
+        assertTrue(result.contains(expectedMonth), "The expression does not contain the expected month extraction.");
+    }
+
+    @Test
+    void testCastExpressionHourAndMinute() {
+        String responseName = "VAR_DUREE";
+        String result = generateControlExpression(new HourMinuteValue(1, 30), new HourMinuteValue(23, 45));
+        String expectedHour = String.format("cast(substr(%s, 3, instr(%s, \"H\") - 3), integer)", responseName, responseName);
+        String expectedMinute = String.format("cast(substr(%s, instr(%s, \"H\") + 1, instr(%s, \"M\") - instr(%s, \"H\") - 1), integer)", responseName, responseName, responseName, responseName);
+        assertTrue(result.contains(expectedHour), "The expression does not contain the expected hour extraction.");
+        assertTrue(result.contains(expectedMinute), "The expression does not contain the expected minute extraction.");
+    }
+
+    private Questionnaire lunaticQuestionnaire;
+    Duration duration;
+    LunaticAddControlFormat processing;
+
+    @BeforeEach
+    void init() {
+        processing = new LunaticAddControlFormat();
+        duration = new Duration();
+        duration.setComponentType(ComponentTypeEnum.DURATION);
+        duration.setId("duration-id");
+        duration.setResponse(buildResponse("DURATION_VAR"));
+    }
+
+    @Test
+    @DisplayName("Duration: no format, should throw exception")
+    void durationNoFormatNorMinMax() {
+        lunaticQuestionnaire = new Questionnaire();
+        lunaticQuestionnaire.getComponents().add(duration);
+        assertThrows(RequiredPropertyException.class, () -> processing.apply(lunaticQuestionnaire));
path: eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/control/LunaticDurationControlTest.java
user: login: nsenave
body: Bonne idée ! d'ailleurs il y avait une erreur de copier-coller après refactor précisément là-dessus 😸 

28eff41abc96f5438bdb2df9f6b39c6a3a5980da
created_at: 2025-03-04T12:54:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,100 @@
+package fr.insee.eno.core.processing.out.steps.lunatic;
+
+import fr.insee.eno.core.processing.ProcessingStep;
+import fr.insee.eno.core.processing.out.steps.lunatic.control.LunaticDurationControl;
+import fr.insee.eno.core.processing.out.steps.lunatic.control.LunaticDurationControl.HourMinuteValue;
+import fr.insee.eno.core.processing.out.steps.lunatic.control.LunaticDurationControl.YearMonthValue;
+import fr.insee.lunatic.model.flat.*;
+
+import java.util.List;
+
+import static fr.insee.eno.core.processing.out.steps.lunatic.control.DurationControlMessage.HourMinuteControlMessage.formatHours;
+import static fr.insee.eno.core.processing.out.steps.lunatic.control.DurationControlMessage.HourMinuteControlMessage.formatMinutes;
+import static fr.insee.eno.core.processing.out.steps.lunatic.control.DurationControlMessage.YearMonthControlMessage.formatMonths;
+import static fr.insee.eno.core.processing.out.steps.lunatic.control.DurationControlMessage.YearMonthControlMessage.formatYears;
+
+/**
+ * Generate a description for duration input components.
+ */
+public class LunaticDurationDescription implements ProcessingStep<Questionnaire> {
+
+    @Override
+    public void apply(Questionnaire lunaticQuestionnaire) {
+        generateDurationDescription(lunaticQuestionnaire.getComponents());
+        lunaticQuestionnaire.getComponents().stream()
+                .filter(Loop.class::isInstance).map(Loop.class::cast)
+                .map(Loop::getComponents)
+                .forEach(this::generateDurationDescription);
+    }
+
+    private void generateDurationDescription(List<ComponentType> components) {
+        components.stream()
+                .filter(Duration.class::isInstance).map(Duration.class::cast)
+                .forEach(this::generateDurationDescription);
+    }
+
+    private void generateDurationDescription(Duration duration) {
+        String min = duration.getMin();
+        String max = duration.getMax();
+        DurationFormat format = duration.getFormat();
+
+        String generatedDescription;
+        switch (format) {
+            case DurationFormat.YEARS_MONTHS -> generatedDescription = generateYearMonthDescription(min, max);
+            case DurationFormat.HOURS_MINUTES -> generatedDescription = generateHourMinuteDescription(min, max);
+            default -> throw new IllegalArgumentException("Unknown duration format: " + format);
+        }
+
+        LabelType description = new LabelType();
+        description.setValue(generatedDescription);
+        description.setType(LabelTypeEnum.TXT);
+        duration.setDescription(description);
+    }
+
+    private String generateYearMonthDescription(String min, String max) {
+        YearMonthValue minValue = LunaticDurationControl.parseYearMonth(min);
+        YearMonthValue maxValue = LunaticDurationControl.parseYearMonth(max);
+
+        if (minValue.years() == 0 && minValue.months() == 0) {
+            return String.format("Jusqu'à %s.", formatYearMonth(maxValue));
+        }
+        return String.format("De %s à %s.", formatYearMonth(minValue), formatYearMonth(maxValue));
+    }
+
+    private String generateHourMinuteDescription(String min, String max) {
+        HourMinuteValue minValue = LunaticDurationControl.parseHourMinute(min);
+        HourMinuteValue maxValue = LunaticDurationControl.parseHourMinute(max);
+
+        if (minValue.hours() == 0 && minValue.minutes() == 0) {
+            return String.format("Jusqu'à %s.", formatHourMinute(maxValue));
+        }
+        return String.format("De %s à %s.", formatHourMinute(minValue), formatHourMinute(maxValue));
+    }
+
+    String formatYearMonth(YearMonthValue value) {
+        int years = value.years();
+        int months = value.months();
+        if (years > 0 && months > 0)
+            return formatYears(years) + " et " + formatMonths(months);
+        if (years > 0)
+            return formatYears(years);
+        if (months > 0)
+            return formatMonths(months);
+        throw new IllegalArgumentException("Invalid duration: " + value);
+    }
+
+    String formatHourMinute(HourMinuteValue value) {
+        int hours = value.hours();
+        int minutes = value.minutes();
+        if (hours > 0 && minutes > 0)
+            return formatHours(hours) + " et " + formatMinutes(minutes);
+        if (hours > 0)
+            return formatHours(hours);
+        if (minutes > 0)
+            return formatMinutes(minutes);
+        throw new IllegalArgumentException("Invalid duration: " + value);
+    }
path: eno-core/src/main/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticDurationDescription.java
user: login: nsenave
body: du code dupliqué dans formatYearMonth et formatHourMinute, je fais un commit de refactor
created_at: 2025-03-12T14:16:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,113 @@
+package fr.insee.eno.core.processing.out.steps.lunatic;
+
+import fr.insee.eno.core.PoguesToEno;
+import fr.insee.eno.core.exceptions.business.ParsingException;
+import fr.insee.eno.core.mappers.LunaticMapper;
+import fr.insee.eno.core.model.EnoQuestionnaire;
+import fr.insee.eno.core.parameter.EnoParameters;
+import fr.insee.eno.core.processing.out.steps.lunatic.table.LunaticTableProcessing;
+import fr.insee.lunatic.model.flat.*;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class LunaticDurationDescriptionTest {
+
+    @Test
+    void minAndMaxYearMonth() {
+        //
+        Questionnaire lunaticQuestionnaire = new Questionnaire();
+        Duration duration = new Duration();
+        duration.setFormat(DurationFormat.YEARS_MONTHS);
+        duration.setMin("P1Y3M");
+        duration.setMax("P3Y4M");
+        lunaticQuestionnaire.getComponents().add(duration);
+        //
+        LunaticDurationDescription processing = new LunaticDurationDescription();
+        processing.apply(lunaticQuestionnaire);
+        //
+        LabelType description = lunaticQuestionnaire.getComponents().getFirst().getDescription();
+        assertEquals("De 1 an et 3 mois à 3 ans et 4 mois.", description.getValue());
+        assertEquals(LabelTypeEnum.TXT, description.getType());
+    }
+
+    @Test
+    void minAndMaxHourMinute() {
+        //
+        Questionnaire lunaticQuestionnaire = new Questionnaire();
+        Duration duration = new Duration();
+        duration.setFormat(DurationFormat.HOURS_MINUTES);
+        duration.setMin("PT0H3M");
+        duration.setMax("PT5H4M");
+        lunaticQuestionnaire.getComponents().add(duration);
+        //
+        LunaticDurationDescription processing = new LunaticDurationDescription();
+        processing.apply(lunaticQuestionnaire);
+        //
+        LabelType description = lunaticQuestionnaire.getComponents().getFirst().getDescription();
+        assertEquals("De 3 minutes à 5 heures et 4 minutes.", description.getValue());
+        assertEquals(LabelTypeEnum.TXT, description.getType());
+    }
+
+    @Test
+    void withoutMinYearMonth() {
+        //
+        Questionnaire lunaticQuestionnaire = new Questionnaire();
+        Duration duration = new Duration();
+        duration.setFormat(DurationFormat.YEARS_MONTHS);
+        duration.setMin("P0Y0M");
+        duration.setMax("P3Y4M");
+        lunaticQuestionnaire.getComponents().add(duration);
+        //
+        LunaticDurationDescription processing = new LunaticDurationDescription();
+        processing.apply(lunaticQuestionnaire);
+        //
+        LabelType description = lunaticQuestionnaire.getComponents().getFirst().getDescription();
+        assertEquals("Jusqu'à 3 ans et 4 mois.", description.getValue());
+        assertEquals(LabelTypeEnum.TXT, description.getType());
+    }
+
+    @Test
+    void withoutMinHourMinute() {
+        //
+        Questionnaire lunaticQuestionnaire = new Questionnaire();
+        Duration duration = new Duration();
+        duration.setFormat(DurationFormat.HOURS_MINUTES);
+        duration.setMin("PT0H0M");
+        duration.setMax("PT1H1M");
+        lunaticQuestionnaire.getComponents().add(duration);
+        //
+        LunaticDurationDescription processing = new LunaticDurationDescription();
+        processing.apply(lunaticQuestionnaire);
+        //
+        LabelType description = lunaticQuestionnaire.getComponents().getFirst().getDescription();
+        assertEquals("Jusqu'à 1 heure et 1 minute.", description.getValue());
+        assertEquals(LabelTypeEnum.TXT, description.getType());
+    }
+
+    @Test
+    void integrationTest() throws ParsingException {
+        // Given
+        EnoQuestionnaire enoQuestionnaire = PoguesToEno.fromInputStream(
path: eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticDurationDescriptionTest.java
user: login: nsenave
body: un peu osé le PoguesToEno (c'est pas ce qui sert en prod actuellement), dans l'idéal on fait un test paramétré avec `PoguesDDIToEno` et `PoguesToEno` mais comme la cible à terme c'est bien d'avoir PoguesToEno comme voie principale on peut laisser comme ça

NB : `DDIToEno` on peut déjà ne plus s'en servir dans les nouveaux tests, c'est obsolète par rapport à PoguesDDIToEno
created_at: 2025-03-12T14:27:54Z
author_association: CONTRIBUTOR
path: eno-core/src/test/java/fr/insee/eno/core/processing/out/steps/lunatic/LunaticDurationDescriptionTest.java
user: login: nsenave
body: un remarque si l'input du test d'intégration (mais ok, pas besoin de changer) sinon c'est carré 👍 
created_at: 2025-03-12T14:28:35Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-04T13:07:26Z
body: 👋 Version 3.35.0-SNAPSHOT deployed on docker hub
author_association: NONE
user: login: RemiVerriez
created_at: 2025-03-05T15:33:46Z
body: ATTENTION : la branche feat-duration-controls-2, associée à la version 3.36.0-SNAPSHOT, contient les modifications suite aux retours de recette pour les contrôles des durées,, mais ÉGALEMENT les travaux liés aux repères de saisie pour les durées (descriptions).

- Ajout de repères de saisie pour les champs durées :
       - Si min = 0 ----> "Jusqu'à [max]"
       - Sinon ----> "De [min] à [max]"
       
Le code des contrôles de durées a été réutilisé à certains endroits pour les descriptions, ce qui a pour conséquence le passage de quelques méthodes en "public".

- Ajout de "minimum" et "maximum" dans les messages des contrôles, conformément aux retours de recette.
- Ajout d'un contrôle si min > max, conformément aux retours de recette.

Des tests ont été effectués + Javadoc
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-05T15:42:00Z
body: 👋 Version 3.36.0-SNAPSHOT deployed on docker hub
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-06T08:10:20Z
body: 👋 Version 3.36.0-SNAPSHOT.1 deployed on docker hub
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-03-12T15:01:52Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=InseeFr_Eno&pullRequest=1229) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [4 New issues](https://sonarcloud.io/project/issues?id=InseeFr_Eno&pullRequest=1229&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=InseeFr_Eno&pullRequest=1229&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=InseeFr_Eno&pullRequest=1229&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [96.1% Coverage on New Code](https://sonarcloud.io/component_measures?id=InseeFr_Eno&pullRequest=1229&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.2% Duplication on New Code](https://sonarcloud.io/component_measures?id=InseeFr_Eno&pullRequest=1229&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=InseeFr_Eno&pullRequest=1229)


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-12T15:05:34Z
body: 👋 Version 3.36.0-SNAPSHOT.2 deployed on docker hub
author_association: NONE