Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions scripts/notebook_tools/scan_slidev_composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,9 @@ def measure_slide(page, slide_idx: int, canvas_w: int, canvas_h: int) -> dict:
// (RECOUVREMENT ci-après), qui mesure le contenu rendu et
// l'ordre de peinture au lieu de la boîte élément naïve.
const chevauchements = [];
// #16188 — porte de confirmation élément : on compte les paires éteintes
// (Range chevauche mais boîtes élément disjointes) au lieu de `continue` muet.
const chevauchementsEteints = [];
const textEls = Array.from(
root.querySelectorAll('h1, h2, h3, h4, p, li, blockquote, td, th')
);
Expand Down Expand Up @@ -332,7 +335,18 @@ def measure_slide(page, slide_idx: int, canvas_w: int, canvas_h: int) -> dict:
const eb = b.el.getBoundingClientRect();
const eOverlapX = Math.min(ea.right, eb.right) - Math.max(ea.left, eb.left);
const eOverlapY = Math.min(ea.bottom, eb.bottom) - Math.max(ea.top, eb.top);
if (eOverlapX <= 0 || eOverlapY <= 0) continue;
// #16188 — porte de confirmation élément muette = silent suppression.
// Comptage explicite des paires éteintes pour observabilité : si la
// porte disparaît dans un refactor futur, une mesure bouge. Doctrine
// #12719 acceptance 4 ("un marqueur presque-juste qui le DIT ne coûte rien").
if (eOverlapX <= 0 || eOverlapY <= 0) {
chevauchementsEteints.push({
a: a.key, b: b.key,
overlap_range: [Math.round(overlapX), Math.round(overlapY)],
element_disjoint: true,
});
continue;
}
chevauchements.push({
a: a.key, b: b.key,
a_bbox: [Math.round(a.left), Math.round(a.top), Math.round(a.right), Math.round(a.bottom)],
Expand Down Expand Up @@ -538,7 +552,7 @@ def measure_slide(page, slide_idx: int, canvas_w: int, canvas_h: int) -> dict:
};
}

return { horsCanvas, chevauchements, recouvrements, occupation, contentBottom: Math.round(contentBottom) };
return { horsCanvas, chevauchements, chevauchementsEteints, recouvrements, occupation, contentBottom: Math.round(contentBottom) };
}""",
[canvas_w, canvas_h],
)
Expand All @@ -554,6 +568,10 @@ def measure_slide(page, slide_idx: int, canvas_w: int, canvas_h: int) -> dict:
"hors_canvas": hors,
"container_only": bool(hors) and not any(h.get("tag") in CONTENT_TAGS for h in hors),
"chevauchements": raw.get("chevauchements", []),
# #16188 — porte de confirmation élément (#15695/#15877) maintenant
# observable : champ exposé pour que github_annotations puisse émettre
# `::notice [CHEVAUCHEMENT-FANTOME]` au lieu de `continue` silencieux.
"chevauchements_eteints": raw.get("chevauchementsEteints", []),
"recouvrements": raw.get("recouvrements", []),
"occupation": raw.get("occupation"),
}
Expand Down Expand Up @@ -664,6 +682,17 @@ def github_annotations(report: dict, slides_md: Path) -> list[str]:
f"::warning file={rel},line={line}::[CHEVAUCHEMENT] slide {r['slide']} ({head}) — "
f"{c['a']} × {c['b']} overlap={c['overlap']}px element_overlap={c.get('element_overlap')}px"
)
# #16188 — porte de confirmation élément (#15695/#15877) : signaler
# les paires Range×Range éteintes par boîtes élément disjointes
# (chevauchement-fantôme : graze Range ~1.2 px, boîtes disjointes).
# `::notice` non bloquant, agrégé par slide.
eteints = r.get("chevauchements_eteints", [])
if eteints:
out.append(
f"::notice file={rel},line={line}::[CHEVAUCHEMENT-FANTOME] slide {r['slide']} ({head}) — "
f"{len(eteints)} effleurement(s) Range éteint(s) par la confirmation élément — "
f"boîtes élément disjointes, rien à l'écran"
)
for rv in r.get("recouvrements", [])[:3]:
out.append(
f"::warning file={rel},line={line}::[RECOUVREMENT-TEXTE-IMAGE] slide {r['slide']} — "
Expand Down Expand Up @@ -759,6 +788,11 @@ def main():
n_total = len(results)
n_hors = sum(1 for r in results if content_overflow(r))
n_chev = sum(1 for r in results if r.get("chevauchements"))
# #16188 — compteur de paires éteintes par la confirmation élément
# (#15695/#15877) : la porte muette devient un signal mesurable. Si
# `n_chevauchements_eteints` tombe à 0 sur un deck où l'on sait que
# des effleurements Range existaient, c'est que la porte a disparu.
n_eteints = sum(len(r.get("chevauchements_eteints", [])) for r in results)
n_rec = sum(1 for r in results if r.get("recouvrements"))
n_occ = sum(1 for r in results if occupation_flagged(r, canvas_h))

Expand Down Expand Up @@ -790,6 +824,7 @@ def main():
"n_slides": n_total,
"n_hors_canvas": n_hors,
"n_chevauchements": n_chev,
"n_chevauchements_eteints": n_eteints,
"n_recouvrements": n_rec,
"n_occupation_flagged": n_occ,
"recouvrement_borne": (
Expand Down
99 changes: 98 additions & 1 deletion scripts/notebook_tools/tests/test_scan_slidev_composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from scan_slidev_composition import ( # noqa: E402
content_overflow,
github_annotations,
occupation_flagged,
parse_headmatter_canvas,
split_slides_source,
Expand Down Expand Up @@ -330,7 +331,7 @@ def _build(baseline_slide):
"canvas_w": 980, "canvas_h": 552,
"BORNE": "ADVISORY",
"ctrl_positif_ok": None, "ctrl_positif_msg": None,
"n_total": 0, "n_hors": 0, "n_chev": 0, "n_rec": 0, "n_occ": 0,
"n_total": 0, "n_hors": 0, "n_chev": 0, "n_eteints": 0, "n_rec": 0, "n_occ": 0,
}
exec(block, ns)
return ns["report"]
Expand Down Expand Up @@ -396,3 +397,99 @@ def test_positive_control_fixture_canvas_default():
# le contrôle CI suppose le canvas par défaut 980×552 : top:600px déborde
# de 48 px. Un canvasHeight headmatter > 600 casserait la garantie.
assert parse_headmatter_canvas(FIXTURE) == (980, 552)


# #16188 — porte de confirmation élément (#15695/#15877) maintenant observable.
# 3 tests synthétiques sur `github_annotations` : rapport par slide avec
# `chevauchements_eteints` non vide → 1 `::notice [CHEVAUCHEMENT-FANTOME]`,
# AUCUN `::warning [CHEVAUCHEMENT]` (les paires éteintes sont des fantômes,
# pas des vrais chevauchements) ; rapport propre → rien.
def _fake_report(slide_results, canvas=(980, 552), slides_md=None):
"""Construit un rapport minimal pour github_annotations."""
from pathlib import Path
return {
"canvas": list(canvas),
"_slide_lines": {r["slide"]: 1 for r in slide_results},
"results": slide_results,
"url": "test://fixture",
"baseline_slide": None,
"baseline_commit": None,
"slides_md": slides_md or Path("slides/test/slides.md"),
}


def test_chevauchement_fantome_notice_when_pair_eteinte(tmp_path):
# Paire Range×Range éteinte par la confirmation élément : 1 `::notice`,
# 0 `::warning [CHEVAUCHEMENT]`. Reproduit le cas fondateur #15695
# (puces inline effleurent ~1.2 px, boîtes disjointes).
fake_slides_md = tmp_path / "slides.md"
fake_slides_md.write_text("placeholder", encoding="utf-8")
report = _fake_report([
{
"slide": 5,
"text_head": "slide test fantôme",
"chevauchements": [], # porte a éteint
"chevauchements_eteints": [
{"a": "LI", "b": "LI", "overlap_range": [2, 1], "element_disjoint": True},
{"a": "P", "b": "P", "overlap_range": [1, 1], "element_disjoint": True},
],
}
], slides_md=fake_slides_md)
anns = github_annotations(report, fake_slides_md)
notice_lines = [a for a in anns if "[CHEVAUCHEMENT-FANTOME]" in a]
warning_lines = [a for a in anns if "[CHEVAUCHEMENT]" in a and "FANTOME" not in a]
assert len(notice_lines) == 1, f"attendu 1 notice fantôme, vu {len(notice_lines)} : {notice_lines}"
assert "2 effleurement(s)" in notice_lines[0], \
f"compteur agrégé manquant : {notice_lines[0]}"
assert "boîtes élément disjointes" in notice_lines[0], \
f"libellé de la notice incorrect : {notice_lines[0]}"
assert warning_lines == [], \
f"les paires éteintes ne doivent PAS générer de warning CHEVAUCHEMENT : {warning_lines}"


def test_chevauchement_warning_pair_raportee(tmp_path):
# Paire Range×Range NON éteinte (boîtes éléments qui se chevauchent
# réellement) : 1 `::warning [CHEVAUCHEMENT]`, 0 notice fantôme.
fake_slides_md = tmp_path / "slides.md"
fake_slides_md.write_text("placeholder", encoding="utf-8")
report = _fake_report([
{
"slide": 7,
"text_head": "slide test vrai chevauchement",
"chevauchements": [
{
"a": "P.x", "b": "P.y",
"overlap": [10, 20],
"element_overlap": [10, 20],
}
],
"chevauchements_eteints": [],
}
], slides_md=fake_slides_md)
anns = github_annotations(report, fake_slides_md)
notice_lines = [a for a in anns if "[CHEVAUCHEMENT-FANTOME]" in a]
warning_lines = [a for a in anns if "[CHEVAUCHEMENT]" in a and "FANTOME" not in a]
assert len(warning_lines) == 1, \
f"attendu 1 warning chevauchement, vu {len(warning_lines)} : {warning_lines}"
assert notice_lines == [], \
f"pas de notice fantôme pour un vrai chevauchement : {notice_lines}"


def test_chevauchement_no_signal_propre(tmp_path):
# Slide propre : pas de chevauchement, pas d'éteint → aucun signal
# CHEVAUCHEMENT ni CHEVAUCHEMENT-FANTOME (les autres notices de
# synthèse type `Plancher mécanique advisory` restent autorisées).
fake_slides_md = tmp_path / "slides.md"
fake_slides_md.write_text("placeholder", encoding="utf-8")
report = _fake_report([
{
"slide": 9,
"text_head": "slide test propre",
"chevauchements": [],
"chevauchements_eteints": [],
}
], slides_md=fake_slides_md)
anns = github_annotations(report, fake_slides_md)
chev_lines = [a for a in anns if "CHEVAUCHEMENT" in a]
assert chev_lines == [], \
f"slide propre ne doit produire aucun signal CHEVAUCHEMENT : {chev_lines}"
Loading