File size: 4,631 Bytes
d4d4112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Garde-fou de reproductibilité du ``RunManifest``.

L'audit S58 a relevé que ``RunManifest.dependencies_lock`` n'était
jamais peuplé et que ``pipeline_specs`` ne contenait que les noms,
rompant la promesse documentée *« à code_version + corpus + specs +
dependencies_lock identiques, ré-exécuter doit donner les mêmes
résultats »*.

Ces tests verrouillent le contrat :

1. ``capture_dependencies_lock()`` retourne un dict non vide trié.
2. ``RunManifest`` accepte des ``pipeline_specs`` complètes (steps,
   adapter_name, params, inputs_from), pas seulement des noms.
3. ``adapter_kwargs`` permet de reconstituer les constructeurs
   d'adapters (model, temperature, etc.).
4. La sérialisation est déterministe : deux manifests à entrée
   identique produisent les mêmes octets JSON.
"""

from __future__ import annotations

from datetime import datetime, timezone

from picarones.app.services.dependencies import capture_dependencies_lock
from picarones.domain.artifacts import ArtifactType
from picarones.domain.pipeline_spec import PipelineSpec, PipelineStep
from picarones.domain.run_manifest import RunManifest


def test_capture_dependencies_lock_non_empty_and_sorted() -> None:
    """``capture_dependencies_lock()`` retourne ≥ 1 paquet (pydantic
    au minimum) et trié alphabétiquement (case-insensitive).
    """
    lock = capture_dependencies_lock()
    assert len(lock) > 0, "lock vide — picarones lui-même doit être listé."
    keys = list(lock.keys())
    assert keys == sorted(keys, key=str.lower), (
        "lock non trié — le manifest ne sera pas bit-for-bit "
        "reproductible cross-environnement."
    )
    # pydantic est une dépendance ferme du projet — sa présence prouve
    # que la capture marche sur l'env réel.
    assert any(k.lower() == "pydantic" for k in lock)


def test_run_manifest_carries_full_pipeline_specs() -> None:
    """Le manifest doit porter les ``PipelineSpec`` complètes, pas
    seulement les noms.  Sans ça, un relecteur 5 ans plus tard ne peut
    pas reconstituer le DAG sans accès au YAML d'origine.
    """
    step = PipelineStep(
        id="ocr",
        kind="ocr",
        adapter_name="tesseract",
        input_types=(ArtifactType.IMAGE,),
        output_types=(ArtifactType.RAW_TEXT,),
        params={"lang": "fra"},
    )
    spec = PipelineSpec(name="tess_only", steps=(step,))

    manifest = RunManifest(
        run_id="r1",
        corpus_name="c1",
        n_documents=1,
        pipeline_specs=(spec,),
        adapter_kwargs={"tesseract": {"lang": "fra", "psm": 6}},
        view_specs=(),
        code_version="1.0.0-test",
        started_at=datetime.now(tz=timezone.utc),
        completed_at=datetime.now(tz=timezone.utc),
        dependencies_lock={"pydantic": "2.5.0"},
    )

    assert manifest.pipeline_specs == (spec,)
    # Vue rétrocompat dérivée des specs.
    assert manifest.pipeline_names == ("tess_only",)
    # Les kwargs d'instanciation sont tracés.
    assert manifest.adapter_kwargs["tesseract"]["psm"] == 6
    # Le step complet est reconstituable.
    assert manifest.pipeline_specs[0].steps[0].params == {"lang": "fra"}


def test_run_manifest_serialization_is_deterministic() -> None:
    """Deux manifests à entrée identique produisent les mêmes
    octets JSON — pré-requis pour le hash d'intégrité que la BnF
    peut citer dans une publication.
    """
    common = dict(
        run_id="r1",
        corpus_name="c1",
        n_documents=42,
        pipeline_specs=(),
        adapter_kwargs={"a": {"k": 1}, "b": {"k": 2}},
        view_specs=(),
        code_version="1.0.0",
        started_at=datetime(2026, 5, 6, tzinfo=timezone.utc),
        completed_at=datetime(2026, 5, 6, tzinfo=timezone.utc),
        dependencies_lock={"pkg-a": "1.0", "pkg-b": "2.0"},
        metadata={"note": "test"},
    )
    m1 = RunManifest(**common)
    m2 = RunManifest(**common)
    assert m1.model_dump_json() == m2.model_dump_json()


def test_run_manifest_rejects_extra_fields() -> None:
    """``extra="forbid"`` — le contrat du manifest n'évolue pas
    silencieusement.  Tout nouveau champ exige un ajout explicite
    au modèle (et donc une revue).
    """
    import pytest
    from pydantic import ValidationError

    with pytest.raises(ValidationError):
        RunManifest(
            run_id="r1",
            corpus_name="c1",
            n_documents=1,
            code_version="1.0",
            started_at=datetime.now(tz=timezone.utc),
            completed_at=datetime.now(tz=timezone.utc),
            unknown_field="nope",  # type: ignore[call-arg]
        )