File size: 7,444 Bytes
3bffe86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2f5797b
3bffe86
 
 
 
2f5797b
3bffe86
 
 
 
 
 
 
 
 
2f5797b
 
3bffe86
2f5797b
 
3bffe86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0a7509a
 
 
 
 
 
 
3bffe86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
"""Tests de cohérence du ``SPECS.md`` avec le code réel.

Sprint A2 (préparation des gates pour B-12 — refonte SPECS en A14).

Le SPECS actuel (mars 2025 + addendum sprints 16-30) est désynchronisé
d'~75 sprints. La refonte intégrale est planifiée en Sprint A14. En
attendant, on pose ici un **garde-fou minimal** qui évite que de
nouvelles divergences ne s'ajoutent silencieusement :

1. Le document doit exister et déclarer une version + date.
2. Toute promesse explicitement *abandonnée* depuis SPECS v1 doit être
   marquée par une balise ``Reporté`` ou ``Abandonné`` (pour qu'un
   primo-lecteur ne s'attende pas à trouver la fonctionnalité).

Le test est délibérément permissif : il ne vérifie pas le contenu
fonctionnel section par section (c'est le rôle de A14). Il garantit
seulement qu'on ne reculera pas davantage.
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest


REPO_ROOT = Path(__file__).resolve().parents[2]
SPECS_PATH = REPO_ROOT / "docs" / "reference" / "specification.md"


def _read_specs() -> str:
    if not SPECS_PATH.exists():
        pytest.skip("specification.md absent")
    return SPECS_PATH.read_text(encoding="utf-8")


# ---------------------------------------------------------------------------
# Existence et meta
# ---------------------------------------------------------------------------


def test_specs_exists() -> None:
    """Pré-requis : la spec doit exister (Phase 1 D5 : déplacée de
    SPECS.md (racine) vers docs/reference/specification.md)."""
    assert SPECS_PATH.exists(), (
        f"{SPECS_PATH.relative_to(REPO_ROOT)} absent. Si retiré "
        "volontairement, supprimer aussi ce test."
    )


def test_specs_declares_version_and_date() -> None:
    """SPECS doit déclarer son numéro de version et sa date (ligne ``Version`` ou ``Date``)."""
    text = _read_specs()
    has_version = bool(re.search(r"\bVersion\s*\d", text, re.IGNORECASE))
    has_date = bool(
        re.search(r"\b(?:janvier|février|mars|avril|mai|juin|juillet|août|septembre|octobre|novembre|décembre)\s+\d{4}", text, re.IGNORECASE)
        or re.search(r"\b(?:january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{4}", text, re.IGNORECASE)
    )
    assert has_version and has_date, (
        "SPECS.md doit contenir une ligne 'Version X.Y' et un mois en lettres "
        "(ex: 'Mars 2025'). Manque : "
        f"version={has_version}, date={has_date}."
    )


# ---------------------------------------------------------------------------
# Promesses abandonnées doivent être marquées
# ---------------------------------------------------------------------------

#: Items que SPECS v1 promettait mais qui ne sont pas implémentés
#: au 2 mai 2026. Pour chacun, le SPECS doit soit (a) ne plus en parler,
#: soit (b) l'accompagner d'une balise « Reporté », « Abandonné », « Annulé »
#: ou « Non implémenté » dans un rayon de 200 caractères.
#:
#: Note historique : Kraken et Calamari étaient initialement listés
#: ici (promesses SPECS v1 abandonnées), puis ré-implémentés en
#: Phase 3 du chantier post-rewrite (mai 2026).  Ils sont aujourd'hui
#: des adapters de premier rang (cf. ``picarones/adapters/ocr/``)
#: et leur mention dans spec.md § 4.2 n'a plus besoin d'être encadrée
#: par un marqueur d'abandon.
ABANDONED_FEATURES_TO_FLAG = {
    "AWS Textract": ["AWS Textract"],
    "OCRopus": ["OCRopus"],
    "Recommandation automatique": [
        "Recommandation automatique",
        "recommandation automatique : quel concurrent",
    ],
    "Export PDF": ["Export PDF", "PDF synthétique"],
    "k-means clustering": ["k-means", "Clustering automatique des patterns"],
    "Annotations inline": ["Annotations inline"],
    "Badge SVG qualité": ["Badge de qualité générable", "SVG quality badge"],
}

#: Mots-clés qui marquent un statut « non livré ».
DEPRECATION_MARKERS = (
    "reporté",
    "reportée",
    "abandonné",
    "abandonnée",
    "annulé",
    "annulée",
    "non implémenté",
    "non implémentée",
    "non livré",
    "non livrée",
    "deferred",
    "abandoned",
    "cancelled",
    "not implemented",
)


#: Balises HTML qui ouvrent et ferment un bloc de promesses
#: explicitement déclarées comme abandonnées. Les features listées
#: entre ces deux balises sont acceptées par le test, où qu'elles
#: apparaissent dans le document.
ABANDONED_BLOCK_START = "<!-- specs-check: known-abandoned-start -->"
ABANDONED_BLOCK_END = "<!-- specs-check: known-abandoned-end -->"


def _extract_abandoned_block(text: str) -> str:
    """Retourne le contenu du bloc d'abandon déclaré, ou chaîne vide."""
    start = text.find(ABANDONED_BLOCK_START)
    end = text.find(ABANDONED_BLOCK_END)
    if start == -1 or end == -1 or end < start:
        return ""
    return text[start + len(ABANDONED_BLOCK_START) : end].lower()


def _has_deprecation_nearby(text: str, idx: int, window: int = 200) -> bool:
    """Vrai si l'un des marqueurs est présent dans une fenêtre autour de ``idx``."""
    start = max(0, idx - window)
    end = min(len(text), idx + window)
    snippet = text[start:end].lower()
    return any(marker in snippet for marker in DEPRECATION_MARKERS)


def _is_globally_abandoned(text: str, pattern: str) -> bool:
    """Vrai si ``pattern`` est listé dans le bloc d'abandon global ET
    accompagné d'un marqueur de deprecation dans le bloc."""
    block = _extract_abandoned_block(text)
    if not block:
        return False
    if pattern.lower() not in block:
        return False
    # Le bloc lui-même doit contenir au moins un marqueur de deprecation
    return any(marker in block for marker in DEPRECATION_MARKERS)


@pytest.mark.parametrize("feature_name,patterns", list(ABANDONED_FEATURES_TO_FLAG.items()))
def test_abandoned_feature_marked_or_absent(feature_name: str, patterns: list[str]) -> None:
    """Pour chaque promesse abandonnée, l'une des trois conditions
    suivantes doit être satisfaite :

    1. La feature n'apparaît plus dans SPECS ;
    2. Chaque mention est accompagnée d'un marqueur de deprecation
       dans une fenêtre de 200 chars ;
    3. La feature est listée dans un bloc global
       ``<!-- specs-check: known-abandoned-start -->`` … ``-end -->``
       qui contient lui-même un marqueur de deprecation.

    La condition 3 permet de centraliser la documentation des
    abandons dans un encart unique sans devoir paraphraser à chaque
    occurrence."""
    text = _read_specs()
    text_lower = text.lower()

    # Condition 3 : bloc global
    for pattern in patterns:
        if _is_globally_abandoned(text, pattern):
            return

    unmarked: list[tuple[str, int]] = []
    for pattern in patterns:
        for m in re.finditer(re.escape(pattern.lower()), text_lower):
            if not _has_deprecation_nearby(text, m.start()):
                unmarked.append((pattern, m.start()))

    assert not unmarked, (
        f"Mention(s) de '{feature_name}' dans SPECS.md sans balise de "
        f"deprecation à proximité (mots tolérés : {DEPRECATION_MARKERS}). "
        f"Positions : {unmarked}. "
        f"Soit retirer la mention, soit ajouter une note explicite "
        f"« — reporté / abandonné / non livré », soit lister la feature "
        f"dans le bloc <!-- specs-check: known-abandoned-start --> en tête de SPECS.md."
    )