koprogo_api/domain/copropriete/
fenetre_ag_ordinaire.rs1use chrono::{Datelike, Duration, NaiveDate};
27use serde::{Deserialize, Serialize};
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum FenetreInvalide {
32 MoisHorsBornes(u32),
34 JourHorsBornes(u32),
36 DateInexistante { mois: u32, jour: u32 },
39}
40
41impl std::fmt::Display for FenetreInvalide {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 match self {
44 Self::MoisHorsBornes(m) => write!(f, "Mois invalide : {m}"),
45 Self::JourHorsBornes(j) => write!(f, "Jour invalide : {j}"),
46 Self::DateInexistante { mois, jour } => write!(
47 f,
48 "Le {jour}/{mois} n'existe pas : le règlement d'ordre intérieur ne peut pas \
49 fixer une période qui ne tombe jamais"
50 ),
51 }
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
58pub struct FenetreAgOrdinaire {
59 mois: u32,
60 jour: u32,
61}
62
63impl FenetreAgOrdinaire {
64 pub const DUREE_JOURS: i64 = 15;
66
67 pub const PREAVIS_PROPOSITIONS_JOURS: i64 = 21;
70
71 pub fn new(mois: u32, jour: u32) -> Result<Self, FenetreInvalide> {
77 if !(1..=12).contains(&mois) {
78 return Err(FenetreInvalide::MoisHorsBornes(mois));
79 }
80 if !(1..=31).contains(&jour) {
81 return Err(FenetreInvalide::JourHorsBornes(jour));
82 }
83 if NaiveDate::from_ymd_opt(2024, mois, jour).is_none() {
85 return Err(FenetreInvalide::DateInexistante { mois, jour });
86 }
87 Ok(Self { mois, jour })
88 }
89
90 pub fn debut(&self, annee: i32) -> Option<NaiveDate> {
95 NaiveDate::from_ymd_opt(annee, self.mois, self.jour)
96 }
97
98 pub fn fin(&self, annee: i32) -> Option<NaiveDate> {
100 self.debut(annee)
101 .map(|d| d + Duration::days(Self::DUREE_JOURS - 1))
102 }
103
104 pub fn contient(&self, date: NaiveDate) -> bool {
110 let Some(debut) = self.debut(date.year()) else {
111 return false;
112 };
113 let Some(fin) = self.fin(date.year()) else {
114 return false;
115 };
116 date >= debut && date <= fin
117 }
118
119 pub fn derniere_date_pour_propositions(&self, annee: i32) -> Option<NaiveDate> {
126 self.debut(annee)
127 .map(|d| d - Duration::days(Self::PREAVIS_PROPOSITIONS_JOURS))
128 }
129
130 pub fn proposition_recevable(&self, recue_le: NaiveDate, annee: i32) -> bool {
132 self.derniere_date_pour_propositions(annee)
133 .is_some_and(|limite| recue_le <= limite)
134 }
135
136 pub fn mois(&self) -> u32 {
137 self.mois
138 }
139
140 pub fn jour(&self) -> u32 {
141 self.jour
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 fn le(annee: i32, mois: u32, jour: u32) -> NaiveDate {
150 NaiveDate::from_ymd_opt(annee, mois, jour).expect("date de test valide")
151 }
152
153 fn juin() -> FenetreAgOrdinaire {
155 FenetreAgOrdinaire::new(6, 1).expect("fenêtre valide")
156 }
157
158 #[test]
159 fn happy_la_periode_dure_quinze_jours_bornes_comprises() {
160 assert_eq!(juin().debut(2026), Some(le(2026, 6, 1)));
161 assert_eq!(juin().fin(2026), Some(le(2026, 6, 15)));
162 }
163
164 #[test]
165 fn happy_une_ag_dans_la_fenetre_est_conforme() {
166 assert!(juin().contient(le(2026, 6, 8)));
167 }
168
169 #[test]
170 fn edge_les_deux_bornes_sont_incluses() {
171 assert!(juin().contient(le(2026, 6, 1)), "le premier jour compte");
172 assert!(juin().contient(le(2026, 6, 15)), "le quinzième aussi");
173 }
174
175 #[test]
176 fn negative_une_ag_hors_fenetre_est_signalee() {
177 assert!(
178 !juin().contient(le(2026, 6, 16)),
179 "le seizième jour est dehors"
180 );
181 assert!(!juin().contient(le(2026, 5, 31)), "la veille aussi");
182 }
183
184 #[test]
185 fn happy_la_fenetre_est_annuelle_et_se_reprojette() {
186 assert!(juin().contient(le(2026, 6, 8)));
189 assert!(juin().contient(le(2031, 6, 8)));
190 }
191
192 #[test]
193 fn edge_une_fenetre_a_cheval_sur_deux_mois_reste_continue() {
194 let fin_decembre = FenetreAgOrdinaire::new(12, 25).expect("fenêtre valide");
195 assert_eq!(fin_decembre.fin(2026), Some(le(2027, 1, 8)));
196 assert!(!fin_decembre.contient(le(2027, 1, 3)));
201 }
202
203 #[test]
206 fn happy_les_propositions_se_deposent_trois_semaines_avant() {
207 assert_eq!(
208 juin().derniere_date_pour_propositions(2026),
209 Some(le(2026, 5, 11)),
210 "1er juin moins vingt-et-un jours"
211 );
212 }
213
214 #[test]
215 fn edge_une_proposition_recue_le_jour_limite_est_recevable() {
216 assert!(juin().proposition_recevable(le(2026, 5, 11), 2026));
219 }
220
221 #[test]
222 fn negative_une_proposition_recue_le_lendemain_ne_lest_plus() {
223 assert!(!juin().proposition_recevable(le(2026, 5, 12), 2026));
224 }
225
226 #[test]
229 fn negative_un_mois_hors_bornes_est_refuse() {
230 assert_eq!(
231 FenetreAgOrdinaire::new(13, 1),
232 Err(FenetreInvalide::MoisHorsBornes(13))
233 );
234 }
235
236 #[test]
237 fn negative_une_date_qui_nexiste_pas_est_refusee() {
238 assert_eq!(
240 FenetreAgOrdinaire::new(11, 31),
241 Err(FenetreInvalide::DateInexistante { mois: 11, jour: 31 })
242 );
243 }
244
245 #[test]
246 fn edge_le_29_fevrier_est_accepte_mais_ne_tombe_pas_toutes_les_annees() {
247 let bissextile = FenetreAgOrdinaire::new(2, 29).expect("le 29 février existe");
248 assert!(bissextile.debut(2028).is_some(), "2028 est bissextile");
249 assert!(
250 bissextile.debut(2026).is_none(),
251 "2026 ne l'est pas : la fenêtre ne tombe pas cette année-là"
252 );
253 assert!(
254 !bissextile.contient(le(2026, 3, 1)),
255 "et rien n'y appartient"
256 );
257 }
258}