"""Tests de la mise en temps du loyer. Ce que ces tests protègent, c'est la lecture d'une courbe : un mois vacant, un mois de changement de locataire et un mois à loyer plein doivent rester trois choses différentes. Les cas ne sont pas inventés — ils viennent tous du parc réel (bail commercial trimestriel, relocation en cours de mois, avoir annulant un loyer, régularisation rétroactive à cheval sur six mois). """ from datetime import date import pytest from plesna_gerance.services.loyers import ( loyer_au_m2, mediane, mois_entiers, mois_suivant, paliers, repartir, variation, ) def ligne(debut: str, fin: str, loyers: float, provisions: float = 0.0): """Une ligne de loyer telle que la base la porte.""" return (date.fromisoformat(debut), date.fromisoformat(fin), loyers, provisions) class TestMoisEntiers: """Ce qui fait qu'une période porte un loyer plutôt qu'un prorata.""" def test_un_mois_plein_couvre_son_mois(self): assert mois_entiers(date(2026, 3, 1), date(2026, 3, 31)) == ["2026-03"] def test_fevrier_se_termine_le_28_ou_le_29(self): assert mois_entiers(date(2025, 2, 1), date(2025, 2, 28)) == ["2025-02"] assert mois_entiers(date(2024, 2, 1), date(2024, 2, 29)) == ["2024-02"] def test_un_trimestre_couvre_ses_trois_mois(self): assert mois_entiers(date(2025, 10, 1), date(2025, 12, 31)) == [ "2025-10", "2025-11", "2025-12", ] def test_une_periode_partielle_ne_couvre_aucun_mois(self): assert mois_entiers(date(2024, 6, 22), date(2024, 6, 30)) == [] assert mois_entiers(date(2025, 3, 14), date(2025, 8, 31)) == [] def test_une_periode_absente_ou_inversee_ne_couvre_rien(self): assert mois_entiers(None, date(2026, 3, 31)) == [] assert mois_entiers(date(2026, 3, 31), date(2026, 3, 1)) == [] def test_le_passage_a_l_annee_suivante(self): assert mois_suivant("2025-12") == "2026-01" class TestRepartition: """Comment les lignes se rangent sur l'axe des mois.""" def test_un_loyer_mensuel_va_dans_son_mois(self): serie = repartir([ligne("2026-03-01", "2026-03-31", 640.0, 31.0)]) assert [point.mois for point in serie.mois] == ["2026-03"] assert serie.mois[0].loyer == 640.0 assert serie.mois[0].charges == 31.0 assert serie.mois[0].reparti is False def test_un_bail_trimestriel_se_repartit_sur_ses_mois(self): """Sans répartition, deux mois sur trois d'un bail commercial seraient montrés vides alors que le local est loué.""" serie = repartir([ligne("2025-10-01", "2025-12-31", 3963.27)]) assert [point.mois for point in serie.mois] == [ "2025-10", "2025-11", "2025-12", ] assert [point.loyer for point in serie.mois] == [1321.09, 1321.09, 1321.09] assert all(point.reparti for point in serie.mois) def test_un_mois_sans_ligne_reste_vide_et_non_a_zero(self): """Un loyer à zéro se lirait comme un logement prêté ; le trou dit la vacance, qui est ce que les comptes rendus montrent.""" serie = repartir( [ ligne("2024-01-01", "2024-01-31", 850.0), ligne("2024-04-01", "2024-04-30", 900.0), ] ) assert [point.mois for point in serie.mois] == [ "2024-01", "2024-02", "2024-03", "2024-04", ] assert [point.loyer for point in serie.mois] == [850.0, None, None, 900.0] def test_un_prorata_se_range_a_part_du_loyer(self): """Le locataire entre le 22 : le mois est facturé, mais pas au prix d'un mois plein. Additionner les deux inventerait une baisse de loyer.""" serie = repartir([ligne("2024-06-22", "2024-06-30", 417.0)]) point = serie.mois[0] assert point.mois == "2024-06" assert point.loyer is None assert point.prorata == 417.0 assert point.en_transition is True def test_un_mois_de_transition_n_est_pas_une_vacance(self): """Sortie le 3, entrée le 14 : le mois est loué deux fois en morceaux. Le montrer vide le confondrait avec un mois sans locataire.""" serie = repartir( [ ligne("2025-03-01", "2025-03-03", 109.68), ligne("2025-03-14", "2025-03-31", 622.2), ] ) point = serie.mois[0] assert point.loyer is None assert point.prorata == pytest.approx(731.88) assert point.en_transition is True def test_un_avoir_ne_deforme_pas_le_loyer_du_mois(self): """Le loyer d'octobre est annulé par un avoir. Le loyer contractuel reste 1390 ; l'avoir se lit à côté, sans creuser la courbe.""" serie = repartir( [ ligne("2024-10-01", "2024-10-31", 1390.0), ligne("2024-10-18", "2024-10-18", -1390.0), ] ) point = serie.mois[0] assert point.loyer == 1390.0 assert point.prorata == -1390.0 assert point.en_transition is False def test_un_prorata_qui_finit_le_mois_est_une_entree(self): """Lot 15 du parc : le locataire entre le 8 juillet. La location ne s'arrête donc pas au dernier loyer plein, elle recommence.""" serie = repartir([ligne("2026-07-08", "2026-07-31", 1111.67)]) point = serie.mois[0] assert point.entree is True assert point.sortie is False def test_un_prorata_qui_ouvre_le_mois_est_une_sortie(self): """Même lot, quatre mois plus tôt : le locataire part le 10 mars.""" serie = repartir([ligne("2026-03-01", "2026-03-10", 482.35)]) point = serie.mois[0] assert point.sortie is True assert point.entree is False def test_un_mois_qui_change_de_locataire_porte_les_deux(self): """Sortie le 3, entrée le 14 : le mois ferme un bail et en ouvre un autre, et c'est l'entrée qui dit que le lot reste loué.""" serie = repartir( [ ligne("2025-03-01", "2025-03-03", 109.68), ligne("2025-03-14", "2025-03-31", 622.2), ] ) point = serie.mois[0] assert point.entree is True assert point.sortie is True def test_un_avoir_n_ouvre_ni_ne_ferme_de_bail(self): """Un avoir porte la période qu'il annule : lu comme une entrée, il ferait croire à une relocation là où rien n'a été loué.""" serie = repartir( [ ligne("2024-10-15", "2024-10-31", -700.0), ligne("2024-11-18", "2024-11-18", -1390.0), ] ) assert [point.entree for point in serie.mois] == [False, False] assert [point.sortie for point in serie.mois] == [False, False] def test_un_mois_plein_n_est_ni_entree_ni_sortie(self): serie = repartir([ligne("2026-01-01", "2026-01-31", 1447.05)]) point = serie.mois[0] assert point.entree is False assert point.sortie is False def test_une_regularisation_a_cheval_reste_hors_de_la_courbe(self): """De mars à août sans couvrir un mois entier : l'étaler inventerait six demi-mois de loyer.""" serie = repartir( [ ligne("2025-09-01", "2025-09-30", 1023.0), ligne("2025-03-14", "2025-08-31", -418.55), ] ) assert [point.mois for point in serie.mois] == ["2025-09"] assert len(serie.ecartees) == 1 assert serie.ecartees[0].loyers == -418.55 def test_sans_aucune_ligne_la_serie_est_vide(self): assert repartir([]).mois == [] class TestPaliers: """Les révisions de loyer, lues dans la suite des mois.""" def test_un_loyer_stable_ne_fait_qu_un_palier(self): serie = repartir( [ ligne("2026-01-01", "2026-01-31", 900.0), ligne("2026-02-01", "2026-02-28", 900.0), ligne("2026-03-01", "2026-03-31", 900.0), ] ) niveaux = paliers(serie.mois) assert len(niveaux) == 1 assert (niveaux[0].mois_debut, niveaux[0].mois_fin) == ("2026-01", "2026-03") def test_une_revision_ouvre_un_palier(self): serie = repartir( [ ligne("2025-12-01", "2025-12-31", 900.0), ligne("2026-01-01", "2026-01-31", 907.85), ligne("2026-02-01", "2026-02-28", 907.85), ] ) niveaux = paliers(serie.mois) assert [n.loyer for n in niveaux] == [900.0, 907.85] assert niveaux[-1].mois_debut == "2026-01" def test_une_vacance_coupe_le_palier_meme_a_loyer_egal(self): """Relouer au même prix après trois mois vides, c'est un nouveau bail : dire que le loyer « n'a pas bougé depuis 2024 » serait faux.""" serie = repartir( [ ligne("2024-01-01", "2024-01-31", 850.0), ligne("2024-05-01", "2024-05-31", 850.0), ] ) niveaux = paliers(serie.mois) assert len(niveaux) == 2 assert niveaux[-1].mois_debut == "2024-05" def test_les_centimes_d_un_trimestre_ne_creent_pas_de_faux_paliers(self): """3963,27 divisé par trois ne retombe pas juste ; l'égalité se juge au centime, sinon chaque mois ouvrirait son propre palier.""" serie = repartir( [ ligne("2025-01-01", "2025-03-31", 3963.27), ligne("2025-04-01", "2025-06-30", 3963.27), ] ) assert len(paliers(serie.mois)) == 1 class TestLoyerAuM2: """Le ratio, et ce qu'il refuse de calculer.""" def test_le_ratio_se_calcule_hors_charges(self): assert loyer_au_m2(640.0, 47.84) == 13.38 def test_sans_surface_il_n_y_a_pas_de_ratio(self): """Zéro serait un loyer au m² nul ; `None` est un trou à combler.""" assert loyer_au_m2(640.0, None) is None def test_une_surface_absurde_vaut_une_surface_absente(self): assert loyer_au_m2(640.0, 0) is None assert loyer_au_m2(640.0, -10) is None def test_un_mois_sans_loyer_n_a_pas_de_ratio(self): assert loyer_au_m2(None, 47.84) is None class TestMedianeEtVariation: """Les deux agrégats qui situent un lot.""" def test_la_mediane_resiste_a_un_local_commercial(self): """Une moyenne serait tirée par la valeur extrême ; c'est tout l'intérêt de la médiane sur un parc de trente lots.""" assert mediane([11.0, 12.0, 13.0, 14.0, 90.0]) == 13.0 def test_la_mediane_d_un_effectif_pair_prend_le_milieu(self): assert mediane([10.0, 12.0, 14.0, 16.0]) == 13.0 def test_sans_lot_comparable_il_n_y_a_pas_de_mediane(self): assert mediane([]) is None def test_la_variation_se_lit_en_pourcentage(self): assert variation(1023.0, 1031.06) == 0.79 def test_une_variation_sans_reference_n_existe_pas(self): assert variation(None, 900.0) is None assert variation(0.0, 900.0) is None