From 5f00cc3dbea40e8b4f7aa4515728eb9cd5adb488 Mon Sep 17 00:00:00 2001 From: jsboige Date: Thu, 5 Nov 2026 11:43:34 +0100 Subject: [PATCH] =?UTF-8?q?Fix(tooling,#22687):=20la=20cause=20du=20plafon?= =?UTF-8?q?d=20EPIC=20nomme=20le=20marqueur=20au=20lieu=20de=20dire=20?= =?UTF-8?q?=C2=AB=20claim=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `claim_visit_stamp` retient tout marqueur de lane -- prise, amendement, override, livraison, rendu de main -- parce que chacun dit qu'une lane a servi l'issue a cette date. La cause du plafond n'avait que cette date et la traduisait par un verbe fixe : « claim ». Sur l'EPIC #16334 le marqueur retenu etait un [RELEASED] du 2026-10-26, et la lane lisait « claim pose sur l'EPIC » pour une visite qui etait un rendu. La selection passe dans `_claim_visit_event`, que servent desormais `claim_visit_stamp` (API inchangee), `claim_visit` (nouvelle paire date + marqueur) et `_claim_visit_from_comments` (voie bulk). La paire sort du MEME evenement : date et marqueur ne peuvent pas designer deux visites differentes. `marker_label` traduit le code retenu, sa table est comparee a `_KEYWORDS` de l'organe par un test, et le repli d'un marqueur non transporte dit « marqueur de lane » -- jamais le mot que l'issue retire. Le marqueur voyage dans la requete GraphQL deja payee (identite de cache `claim_stamps_bulk_v2`) et dans l'instantane local : aucun aller-retour ajoute. Tests : 208 passed sur les quatre fichiers touches, 404 passed sur la famille check_lane_claim. Part of #22687 Part of #22500 Co-Authored-By: Claude Sonnet 5.5 --- scripts/check_lane_claim.py | 22 ++- scripts/pick_idle_grain.py | 162 ++++++++++++++++-- .../test_check_lane_claim_umbrella_cap.py | 42 ++++- scripts/tests/test_gh_snapshot_sweep_22983.py | 7 +- scripts/tests/test_pick_idle_grain_22500.py | 28 ++- scripts/tests/test_pick_idle_grain_belt.py | 76 ++++++++ 6 files changed, 306 insertions(+), 31 deletions(-) diff --git a/scripts/check_lane_claim.py b/scripts/check_lane_claim.py index 3d88e9a20d9..c6e4fe44322 100644 --- a/scripts/check_lane_claim.py +++ b/scripts/check_lane_claim.py @@ -3725,20 +3725,26 @@ def epic_visit_cap(payload: dict, *, now: datetime | None = None, calls["pool"] = 1 if pool_error: errors.append(f"pool: {pool_error}") - targets = [ - { + targets: list[dict] = [] + for epic, body in capped: + comments = [c for c in (body.get("comments") or []) + if isinstance(c, dict)] + # Date ET marqueur sortent du MEME evenement (`picker.claim_visit`) : + # la cause du plafond nomme ce que la lane a fait (prise, rendu de + # main, livraison...) au lieu d'appeler « claim » un `[RELEASED]` + # (#22687 constat 1). Deux appels separes pourraient designer deux + # evenements differents. + stamp, marker = picker.claim_visit(comments) + targets.append({ "number": epic, "title": body.get("title"), "labels": body.get("labels"), # `klass` est la cle que lit `umbrella_visit_cause` ; `_is_umbrella_issue` # vient de la trancher, donc la valeur est celle de l'organe. "klass": "umbrella", - "last_claim_stamp": picker.claim_visit_stamp( - [c for c in (body.get("comments") or []) if isinstance(c, dict)] - ), - } - for epic, body in capped - ] + "last_claim_stamp": stamp, + "last_claim_marker": marker, + }) if pool: # N ne compte pas parmi les filles visitees (demande explicite). daughters = [c for c in pool if c.get("number") != number] diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index 0952af20523..b7f40cddf7b 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -8254,8 +8254,16 @@ def umbrella_visit_cause(it: dict, *, now=None) -> str | None: moment = now or NOW window_h = UMBRELLA_VISIT_CAP_DAYS * 24 recent: list[tuple[float, str]] = [] + # #22687 constat 1 : le marqueur retenu SE NOMME. Mesure fondatrice -- + # sur l'EPIC #16334 le marqueur retenu etait un `[RELEASED]` du + # 2026-10-26 et la cause affichait « claim pose sur l'EPIC » : un rendu + # de main lu comme une prise. `last_claim_marker` n'est pas toujours + # connu (chemins qui ne portent que la date) ; `marker_label(None)` dit + # alors « marqueur de lane », jamais « claim ». for key, what in ((("last_child_stamp"), "sous-issue creee"), - (("last_claim_stamp"), "claim pose sur l'EPIC")): + (("last_claim_stamp"), + f"{marker_label(it.get('last_claim_marker'))} " + f"sur l'EPIC")): age = _visit_age_hours(it.get(key), moment) if age is not None and age < window_h: recent.append((age, what)) @@ -8399,8 +8407,8 @@ def apply_child_visits(pool: list[dict], targets: list[dict]) -> dict[int, str]: return latest -def claim_visit_stamp(comments: list[dict]) -> str | None: - """``createdAt`` serveur du plus recent marqueur de claim attribue a une lane. +def _claim_visit_event(comments: list[dict]) -> Any | None: + """Marqueur de lane le plus recent du fil, avec sa grammaire d'organe. Lecture deleguee a l'organe des claims (#19147, reserve tierce) : ``_sort_events`` lit les marqueurs avec sa grammaire (decorations markdown @@ -8411,9 +8419,37 @@ def claim_visit_stamp(comments: list[dict]) -> str | None: """ from check_lane_claim import _sort_events - stamps = [ev.created_at for ev in _sort_events({"comments": comments}) + cs = [c for c in comments if isinstance(c, dict)] + events = [ev for ev in _sort_events({"comments": cs}) if ev.lane and ev.created_at] - return max(stamps) if stamps else None + return max(events, key=lambda ev: ev.created_at) if events else None + + +def claim_visit_stamp(comments: list[dict]) -> str | None: + """``createdAt`` serveur du plus recent marqueur de claim attribue a une lane. + + Voir `_claim_visit_event` pour la selection. ``None`` = aucune visite de + lane dans ce fil. + """ + ev = _claim_visit_event(comments) + return ev.created_at if ev else None + + +def claim_visit(comments: list[dict]) -> tuple[str | None, str | None]: + """Paire ``(date, marqueur)`` de la visite de lane la plus recente. + + ``claim_visit_stamp`` ne rend que la moitie de ce qu'un appelant sait : la + date dit QUAND une lane a servi l'issue, pas CE QU'elle a fait. Une cause + de plafond EPIC qui n'avait que la date affirmait « claim » sur un + ``[RELEASED]`` (#22687, constat 1) -- un rendu de main lu comme une prise. + + La paire sort du MEME evenement, donc elle ne peut pas se contredire : le + marqueur est celui de la date rendue. ``(None, None)`` = aucune visite. + """ + ev = _claim_visit_event(comments) + if ev is None: + return None, None + return ev.created_at, ((ev.get("marker") or "").upper() or None) def delivered_info_stamp(comments: list[dict]) -> str | None: @@ -8497,6 +8533,63 @@ def latest_claim_stamp(issue_number: int) -> str | None: return max(stamps) if stamps else None +#: Marqueur rendu par `_sort_events` -> ce qu'il dit en francais. Le message +#: de refus d'un EPIC plafonne nommait « claim » TOUS les marqueurs, y compris +#: un `[RELEASED]` : un rendu de main n'est pas une prise, et la lane lisait +#: une cause fausse (#22687, constat 1). +#: La liste suit `_KEYWORDS` de `check_lane_claim` : un code qui n'est pas lu +#: par l'organe ne peut pas arriver ici, et en inventer un donnerait une +#: traduction qui ne correspond a aucun marqueur reel. +_MARKER_LABELS: dict[str, str] = { + "CLAIMED": "prise", + "CLAIMED-AMEND": "amendement", + "RELEASED": "rendu de main", + "CANCELLED": "annulation", + "ABANDONED": "abandon", + "DONE": "cloture", + "OVERRIDE": "override", + "DELIVERED": "livraison", +} +#: Etiquette du marqueur rendu par `delivered_info_stamp`, qui ne passe pas par +#: la grammaire des claims. +_INFO_DELIVERED_LABEL = "livraison (candidate-delivered)" + + +def marker_label(marker: str | None) -> str: + """Ce que dit un marqueur, en clair. Un marqueur inconnu SE NOMME. + + Le repli n'est pas « claim » : c'est precisement le mot que #22687 retire, + parce qu'il affirme une prise la ou un rendu de main a eu lieu. Un + marqueur que ce module ne sait pas traduire se cite tel quel, entre + crochets -- mieux vaut un code brut qu'une affirmation fausse. + """ + if not marker: + return "marqueur de lane" + return _MARKER_LABELS.get(marker, f"marqueur [{marker}]") + + +def _claim_visit_from_comments( + comments: list[dict]) -> tuple[str | None, str | None]: + """(stamp, marqueur) du plus recent marqueur de visite -- sans reseau. + + Meme selection que ``latest_claim_stamp`` (marqueur de lane attribue le + plus recent, ou ``[INFO] candidate-delivered`` s'il est plus tardif), mais + rend AUSSI le marqueur retenu : la date seule ne permet pas de dire si + l'issue a ete prise ou rendue, et le message de plafond EPIC l'affirmait + a tort (#22687). + + ``(None, None)`` = aucun marqueur de visite dans ce fil, ce qui est une + mesure ; une lecture reseau en echec rend ce meme couple a un autre + niveau, ou l'appelant sait qu'il n'a pas lu. + """ + cs = [c for c in comments if isinstance(c, dict)] + stamp, marker = claim_visit(cs) + info = delivered_info_stamp(cs) + if info and (stamp is None or info > stamp): + return info, _INFO_DELIVERED_LABEL + return stamp, (marker or None) + + def _claim_stamp_from_comments(comments: list[dict]) -> str | None: """Stamp canonique d'une liste de commentaires -- sans appel reseau. @@ -8504,14 +8597,12 @@ def _claim_stamp_from_comments(comments: list[dict]) -> str | None: en argument : c'est ce que ``fetch_latest_claim_stamps_bulk`` branche apres son unique aller-retour GraphQL. Sortie : ISO 8601 UTC ou ``None``. """ - cs = [c for c in comments if isinstance(c, dict)] - stamps = [s for s in (claim_visit_stamp(cs), delivered_info_stamp(cs)) if s] - return max(stamps) if stamps else None + return _claim_visit_from_comments(comments)[0] def _claim_stamps_from_snapshot( numbers: list[int], -) -> tuple[dict[int, str | None], list[int]]: +) -> tuple[dict[int, str | None], dict[int, str | None], list[int]]: """Stamps de visite servis par l'instantane local, et numeros non servis. #22983 -- le tapis ne doit plus interroger GitHub pour savoir QUAND une @@ -8521,18 +8612,22 @@ def _claim_stamps_from_snapshot( perimee) est rendu a part : l'appelant le passe au transport historique plutot que de fabriquer un « pas de marqueur ». - Rend `(stamps, non_servis)`. Un stamp `None` est une **mesure** (aucun - marqueur de visite dans ce fil), l'absence de la cle est un **non-dit**. + Rend `(stamps, marqueurs, non_servis)`. Un stamp `None` est une + **mesure** (aucun marqueur de visite dans ce fil), l'absence de la cle est + un **non-dit**. Les marqueurs suivent la meme regle : c'est l'instantane + qui les porte, sans appel GitHub, parce qu'il porte deja les + commentaires dont `_claim_visit_from_comments` les tire (#22687). """ served: dict[int, str | None] = {} + markers: dict[int, str | None] = {} missing: list[int] = [] for n in numbers: comments = _snapshot_comments(n) if comments is None: missing.append(n) continue - served[n] = _claim_stamp_from_comments(comments) - return served, missing + served[n], markers[n] = _claim_visit_from_comments(comments) + return served, markers, missing def validate_picks_live( @@ -8674,6 +8769,7 @@ def fetch_latest_claim_stamps_bulk( cache: PayloadCache | None = None, cache_mode: str = "off", cache_status: dict[str, dict[str, Any]] | None = None, + markers_out: dict[int, str | None] | None = None, ) -> tuple[dict[int, str | None], str | None]: """Stamp canonique par issue, une seule requete GraphQL multiplexee. @@ -8702,7 +8798,11 @@ def fetch_latest_claim_stamps_bulk( return {}, None sorted_nums = sorted({int(n) for n in issue_numbers}) - identity = ["claim_stamps_bulk"] + [str(n) for n in sorted_nums] + # `_v2` : la charge porte desormais le marqueur en plus de la date + # (#22687). Changer l'identite plutot que decoder l'ancienne forme evite + # qu'un cache chaud rende des dates sans marqueur, ce qui afficherait + # « marqueur de lane » la ou le marqueur est connue. + identity = ["claim_stamps_bulk_v2"] + [str(n) for n in sorted_nums] def fetch_raw() -> dict[str, str | None]: # Les alias GraphQL doivent etre des identifiers valides. On utilise @@ -8732,14 +8832,15 @@ def fetch_raw() -> dict[str, str | None]: except json.JSONDecodeError as exc: raise RuntimeError(f"GraphQL decode failed: {exc}") from exc repo = data.get("data", {}).get("repository") or {} - out_map: dict[str, str | None] = {} + out_map: dict[str, dict[str, str | None] | None] = {} for n in sorted_nums: node = repo.get(f"i{n}") if not node: out_map[str(n)] = None continue comments = (node.get("comments") or {}).get("nodes") or [] - out_map[str(n)] = _claim_stamp_from_comments(comments) + stamp, marker = _claim_visit_from_comments(comments) + out_map[str(n)] = {"s": stamp, "m": marker} return out_map try: @@ -8756,7 +8857,16 @@ def fetch_raw() -> dict[str, str | None]: return {}, f"{type(exc).__name__}: {exc}" # La cache rend des cles str ; on remet en `int` pour matcher les # `issue["number"]` du `belt_pool` (et la cle `int -> str | None` du JSON). - int_map: dict[int, str | None] = {int(k): v for k, v in result.items()} + # Une charge d'avant #22687 (date seule) reste lisible : elle se lit + # comme une date sans marqueur, jamais comme un marqueur invente. + int_map: dict[int, str | None] = {} + for k, v in result.items(): + if isinstance(v, dict): + int_map[int(k)] = v.get("s") + if markers_out is not None: + markers_out[int(k)] = v.get("m") + else: + int_map[int(k)] = v return int_map, None @@ -11761,9 +11871,12 @@ def _belt_phase(name: str) -> None: # la cible : c'est la meme reduction que la voie reseau # (`_claim_stamp_from_comments`), sans appel GitHub. Seuls les # numeros NON SERVIS passent par le bulk GraphQL. - snapshot_stamps, stamp_missing = _claim_stamps_from_snapshot( - bulk_targets) + snapshot_stamps, snapshot_markers, stamp_missing = ( + _claim_stamps_from_snapshot(bulk_targets)) bulk_stamps: dict[int, str | None] = dict(snapshot_stamps) + # #22687 : le marqueur voyage avec la date. Il sert au message de + # plafond EPIC, qui nommait « claim » un `[RELEASED]`. + bulk_markers: dict[int, str | None] = dict(snapshot_markers) bulk_err = None if stamp_missing: net_stamps, bulk_err = fetch_latest_claim_stamps_bulk( @@ -11771,6 +11884,7 @@ def _belt_phase(name: str) -> None: cache=payload_cache, cache_mode=effective_cache_mode, cache_status=cache_status, + markers_out=bulk_markers, ) bulk_stamps.update(net_stamps) _belt_phase(f"stamps: {len(bulk_stamps)} servis " @@ -11792,6 +11906,16 @@ def _probe(n: int) -> str | None: settle_belt_head(belt_pool, belt_check_window, _probe, max_probes=belt_check_window * 3 + 12) + # #22687 : le marqueur n'est pose que sur les items dont la + # DATE vient du bulk. Un numero servi par la sonde unitaire + # de repli porte une date sans marqueur (elle coute une + # requete de plus pour le nommer) -- `marker_label(None)` le + # dit honnetement au lieu d'affirmer une prise. + for it in belt_pool: + n = it.get("number") + stamp = bulk_markers.get(n) + if stamp and it.get("last_claim_stamp") == bulk_stamps.get(n): + it["last_claim_marker"] = stamp _belt_phase(f"settle_belt_head fait: " f"{len(fallback_used)} fallback(s) unitaire(s)") if fallback_used: diff --git a/scripts/tests/test_check_lane_claim_umbrella_cap.py b/scripts/tests/test_check_lane_claim_umbrella_cap.py index 422abcf6c89..ddbc5eb268c 100644 --- a/scripts/tests/test_check_lane_claim_umbrella_cap.py +++ b/scripts/tests/test_check_lane_claim_umbrella_cap.py @@ -217,12 +217,52 @@ def test_claim_on_the_epic_itself_is_capped(capsys): assert rc == 1 data = report(capsys) assert data["epic_visit_cap"].startswith(CAUSE_PREFIX) - assert "claim pose sur l'EPIC il y a 3.0 j" in data["epic_visit_cap"] + # #22687 constat 1 : la cause NOMME le marqueur retenu au lieu du mot + # generique « claim », qui affirmait une prise sur tout marqueur -- y + # compris un `[RELEASED]` (cf le test dedie plus bas). + assert "prise sur l'EPIC il y a 3.0 j" in data["epic_visit_cap"] assert data["epic_visit_cap_epic"] == SERRE100 # L'EPIC est N lui-meme : sa charge est deja lue, aucun parent a chercher. assert loaders.parent_calls == [] +def test_released_marker_on_the_epic_is_named_not_called_a_claim(capsys): + """#22687 constat 1 -- la cause nomme ce que la lane a FAIT. + + Mesure fondatrice : sur #16334 le marqueur retenu etait un `[RELEASED]`, + et la cause annoncait « claim pose sur l'EPIC ». Une lane qui lit ca rend + la main une seconde fois (ou croit qu'une autre lane tient l'EPIC) pour + une visite qui est en realite un rendu. La date seule ne distingue pas + une prise d'un rendu : le marqueur, si. + """ + payload = epic(comments=[comment( + "[RELEASED] lane myia-ai-01:CoursIA-2 -- tranche rendue", iso(days=3))]) + rc = run(payload, Loaders(pool=[])) + assert rc == 1 + cause = report(capsys)["epic_visit_cap"] + assert cause.startswith(CAUSE_PREFIX) + assert "rendu de main sur l'EPIC il y a 3.0 j" in cause + assert "claim" not in cause + + +def test_every_marker_the_organ_reads_has_its_own_word(capsys): + """Aucun marqueur de l'organe ne retombe sur le mot generique. + + `CANCELLED` est lu par `_KEYWORDS` : il doit donc avoir sa traduction, + comme le `[RELEASED]` ci-dessus. Un code absent du dictionnaire est un + trou, pas un repli acceptable -- cf `marker_label` pour les codes que + l'organe ne produit pas. + """ + payload = epic(comments=[comment( + "[CANCELLED] lane myia-ai-01:CoursIA-2 -- tranche abandonnee", + iso(days=3))]) + rc = run(payload, Loaders(pool=[])) + assert rc == 1 + cause = report(capsys)["epic_visit_cap"] + assert "annulation sur l'EPIC il y a 3.0 j" in cause + assert "claim" not in cause + + def test_claim_older_than_the_window_does_not_block(capsys): payload = epic(comments=[comment( "[CLAIMED] lane myia-ai-01:CoursIA-2 -- tranche", iso(days=10))]) diff --git a/scripts/tests/test_gh_snapshot_sweep_22983.py b/scripts/tests/test_gh_snapshot_sweep_22983.py index 953d2f430ee..31e374391de 100644 --- a/scripts/tests/test_gh_snapshot_sweep_22983.py +++ b/scripts/tests/test_gh_snapshot_sweep_22983.py @@ -559,19 +559,22 @@ def _instantane_frais(pig_module=pig): def test_les_stamps_viennent_de_l_instantane_et_les_non_servis_sont_separes(): _instantane_frais() - served, missing = pig._claim_stamps_from_snapshot([7, 9]) + served, markers, missing = pig._claim_stamps_from_snapshot([7, 9]) assert missing == [9] assert 7 in served # Un stamp `None` est une MESURE (aucun marqueur), pas une absence. assert served[7] is None or isinstance(served[7], str) + # Le marqueur suit le meme regime : servi par l'instantane, sans reseau. + assert 7 in markers def test_un_stamp_absent_de_l_instantane_n_est_jamais_fabrique(): """Aucun instantane : tout est non servi, aucun stamp invente.""" pig._BELT_SNAPSHOT = None pig._PASS_SNAPSHOT = None - served, missing = pig._claim_stamps_from_snapshot([7]) + served, markers, missing = pig._claim_stamps_from_snapshot([7]) assert served == {} + assert markers == {} assert missing == [7] diff --git a/scripts/tests/test_pick_idle_grain_22500.py b/scripts/tests/test_pick_idle_grain_22500.py index 5c6dd6dd0b6..86b2b537cba 100644 --- a/scripts/tests/test_pick_idle_grain_22500.py +++ b/scripts/tests/test_pick_idle_grain_22500.py @@ -160,10 +160,36 @@ def test_un_claim_sur_l_epic_compte_comme_une_visite(): """C'est le geste mesure du 25/10 : quatre lanes ont claimé le meme EPIC le meme jour. Le plafond doit voir le claim, pas seulement la creation d'une sous-issue.""" + epic = _item(3001, klass="umbrella", last_claim_stamp=_iso(6), + last_claim_marker="CLAIMED") + cause = pig.umbrella_visit_cause(epic, now=NOW) + assert cause is not None + assert "prise sur l'EPIC" in cause + + +def test_un_rendu_de_main_sur_l_epic_se_nomme_et_ne_se_lit_pas_comme_une_prise(): + """#22687 constat 1 : la cause dit ce que la lane a FAIT. + + Sur #16334 le marqueur retenu etait un `[RELEASED]` du 26/10 et la cause + annoncait « claim pose sur l'EPIC ». Une lane qui lit ca rend la main une + seconde fois pour une visite qui est un rendu : le message coute un tour. + """ + epic = _item(3001, klass="umbrella", last_claim_stamp=_iso(6), + last_claim_marker="RELEASED") + cause = pig.umbrella_visit_cause(epic, now=NOW) + assert cause is not None + assert "rendu de main sur l'EPIC" in cause + assert "claim" not in cause + + +def test_un_marqueur_non_transporte_ne_se_traduit_pas_en_prise(): + """Chemins qui ne portent que la date (sonde unitaire de repli) : la cause + dit « marqueur de lane ». Le repli n'est jamais le mot que #22687 retire.""" epic = _item(3001, klass="umbrella", last_claim_stamp=_iso(6)) cause = pig.umbrella_visit_cause(epic, now=NOW) assert cause is not None - assert "claim pose sur l'EPIC" in cause + assert "marqueur de lane sur l'EPIC" in cause + assert "claim" not in cause def test_epic_de_consolidation_est_exempt(): diff --git a/scripts/tests/test_pick_idle_grain_belt.py b/scripts/tests/test_pick_idle_grain_belt.py index 248b65fec78..b3ee2a6f89f 100644 --- a/scripts/tests/test_pick_idle_grain_belt.py +++ b/scripts/tests/test_pick_idle_grain_belt.py @@ -1043,6 +1043,51 @@ def test_claim_visit_stamp_counts_every_lane(): assert pig.claim_visit_stamp(comments) == "2026-10-04T09:00:00Z" +# --- #22687 constat 1 : la visite rend sa DATE **et** son MARQUEUR ---------- + +def test_claim_visit_pair_names_the_event_that_produced_the_date(): + """La paire ne peut pas designer deux evenements differents. + + `claim_visit_stamp` ne disait que QUAND une lane avait servi l'issue ; la + cause du plafond EPIC en deduisait « claim », donc un `[RELEASED]` etait + lu comme une prise (#22687). La paire sort du MEME evenement. + """ + comments = [ + _claim("2026-10-04T09:00:00Z", + "[CLAIMED] lane myia-po-2027:CoursIA -- T1"), + _claim("2026-10-04T12:00:00Z", + "[RELEASED] lane myia-po-2027:CoursIA -- T1 rendue"), + ] + assert pig.claim_visit(comments) == ("2026-10-04T12:00:00Z", "RELEASED") + # Le stamp public reste la moitie-date, inchangee. + assert pig.claim_visit_stamp(comments) == "2026-10-04T12:00:00Z" + + +def test_claim_visit_without_any_lane_marker_is_a_measure_not_a_guess(): + assert pig.claim_visit([_claim("2026-10-04T09:00:00Z", + "commentaire ordinaire")]) == (None, None) + assert pig.claim_visit([]) == (None, None) + + +def test_marker_label_never_falls_back_to_claim(): + """Le repli ne doit pas etre le mot que #22687 retire.""" + assert pig.marker_label("CLAIMED") == "prise" + assert pig.marker_label("RELEASED") == "rendu de main" + assert pig.marker_label(None) == "marqueur de lane" + assert pig.marker_label("") == "marqueur de lane" + # Un code que l'organe ne produit pas se cite tel quel, entre crochets. + assert pig.marker_label("SOMETHINGELSE") == "marqueur [SOMETHINGELSE]" + + +def test_every_marker_keyword_of_the_organ_has_a_translation(): + """La table suit `_KEYWORDS` de l'organe : un code lu par lui sans + traduction serait un trou, et ce test le voit avant une lane.""" + from check_lane_claim import _KEYWORDS + + missing = [k for k in _KEYWORDS if k not in pig._MARKER_LABELS] + assert not missing, f"marqueurs de l'organe sans traduction : {missing}" + + def test_belt_released_issue_stays_behind_7742_scenario(): """Bout en bout, cas mesure #7742 : prise le 31/08, rendue le 19/09 apres deux tranches mergees. Lue a la prise seule, elle repassait devant une @@ -2059,6 +2104,37 @@ class _R: assert pig.latest_claim_stamp(12) is None +def test_fetch_latest_claim_stamps_bulk_carries_the_marker_with_the_date(monkeypatch): + """#22687 : la charge bulk porte le marqueur, pas seulement la date. + + Une date sans marqueur obligeait la cause du plafond EPIC a inventer un + verbe -- « claim » sur un `[RELEASED]`. Le marqueur voyage dans la MEME + requete : couter une seconde sonde pour nommer la visite serait payer un + aller-retour par EPIC plafonne. + """ + issue_payloads = { + 20: [_claim("2026-10-04T09:00:00Z", + "[CLAIMED] lane myia-po-2027:CoursIA -- T1"), + _claim("2026-10-04T12:00:00Z", + "[RELEASED] lane myia-po-2027:CoursIA -- T1 rendue")], + 21: [_claim("2026-10-05T10:00:00Z", "aucun marqueur")], + } + + def fake_run(cmd, *a, **k): + class _R: + stdout = json.dumps(_graphql_payload_for(issue_payloads)) + return _R() + + monkeypatch.setattr(pig.subprocess, "run", fake_run) + + markers: dict[int, str | None] = {} + stamps, err = pig.fetch_latest_claim_stamps_bulk([20, 21], + markers_out=markers) + assert err is None, err + assert stamps == {20: "2026-10-04T12:00:00Z", 21: None} + assert markers == {20: "RELEASED", 21: None}, markers + + def test_fetch_latest_claim_stamps_bulk_falls_back_on_subprocess_error(monkeypatch): """Repli : une exception subprocess rend ({}, err) ; le caller peut degrader.