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
11 changes: 10 additions & 1 deletion opt/pincabos/script/iso-live.sh
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,10 @@ fi

echo "=== 2) Live initrd ==="
if [ -z "$KERNEL_VERSION" ]; then
KERNEL_VERSION="$(ls "$ROOTFS/lib/modules" | sort -V | tail -1)"
# PINCABOS_ISO_LIVE_KVER_V1 : le live prenait toujours le noyau le plus recent du
# rootfs. Des qu une reconstruction en installe un second, l ISO demarre sur un
# noyau que le parc n a jamais essaye (07/09/2026 : 7.0.0-31 par-dessus le 29).
KERNEL_VERSION="${PCO_LIVE_KVER:-$(ls "$ROOTFS/lib/modules" | sort -V | tail -1)}"
fi
[ -n "$KERNEL_VERSION" ] || die "no kernel found under $ROOTFS/lib/modules"
echo " kernel: $KERNEL_VERSION"
Expand Down Expand Up @@ -285,6 +288,12 @@ EXCLUDES=(
"opt/pincabos/cache" "opt/pincabos/cache/*"
"opt/pincabos/logs/*"
"root/.cache" "root/.cache/*"
# PINCABOS_IMAGE_SANS_BROUILLONS_V1 : nos scripts de travail (pincab-batch-v31,
# v32, v32b, v33, v35b, v35c, v35d...) et les notes de conception de DEV/
# partaient sur CHAQUE cabinet. Ce n'est pas une question de place — 1 Mo sur
# 2,8 Go — mais le cabinet d'un utilisateur n'a pas a porter notre historique.
"root/pincab-*" "root/pincabos-*"
"DEV" "DEV/*"
"var/cache/apt/*" "var/lib/apt/lists/*"
"usr/sbin/policy-rc.d"
# Not needed at runtime on a cab, and it all comes back with apt if wanted.
Expand Down
16 changes: 16 additions & 0 deletions opt/pincabos/tests/doublons-connus.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# PINCABOS_PAS_DE_NOUVEAU_DOUBLON_V1
# Fichiers suivis strictement identiques — de VRAIES copies, pas des liens.
# Releve du 10/09/2026.
#
# Piege : sha256sum SUIT les liens symboliques. Un detecteur qui les inclut
# voit 54 groupes la ou le projet en a deja 51 en liens, et annonce 26 902
# lignes dupliquees au lieu de 1523. On lit le mode git (120000 = lien).
#
# Cette liste ne doit que RETRECIR : un groupe qui n y est pas fait echouer
# test_hygiene. Quand un groupe est deduplique, retirez sa ligne.
#
# 3 groupes, 1523 lignes dupliquees.
#
opt/pincabos/install/modules/network/mod-dhcp4.sh opt/pincabos/modules/network/mod-dhcp4.sh
opt/pincabos/install/modules/network/mod-ssid.sh opt/pincabos/modules/network/mod-ssid.sh
opt/pincabos/install/modules/system/mod-splash.sh opt/pincabos/modules/system/mod-splash.sh
31 changes: 31 additions & 0 deletions opt/pincabos/tests/rm-rf-connus.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# PINCABOS_PAS_DE_NOUVEAU_RM_RF_V1
# Fichiers REELS (jamais les liens) ou « rm -rf "$VAR" » n est pas garde
# par :? ou :-. Releve du 10/09/2026. Une variable vide efface alors la
# racine du chemin parent.
# Cette liste ne doit que RETRECIR.
#
# 23 fichiers.
#
opt/pincabos/apps/PinCabShare/uninstall.sh
opt/pincabos/install/02-install-engine.sh
opt/pincabos/modules/system/mod-plymouth-install.sh
opt/pincabos/modules/system/mod-plymouth-load.sh
opt/pincabos/script/build-master.sh
opt/pincabos/script/installer/pincabos-install-payload
opt/pincabos/script/installer/pincabos-live-installer
opt/pincabos/script/iso-live.sh
opt/pincabos/script/iso/10-audit-nettoyage.sh
opt/pincabos/script/iso/40-payload.sh
opt/pincabos/script/iso/80-live-rootfs.sh
opt/pincabos/script/slim-master.sh
opt/pincabos/script/updatehelp.sh
opt/pincabos/tests/test_hygiene.py
opt/pincabos/tests/test_iso_etapes.py
opt/pincabos/tests/test_live_xorg.py
opt/pincabos/tools/launch-dmd-calibrator.sh
opt/pincabos/tools/launch-fulldmd-calibrator.sh
opt/pincabos/tools/launch-webapp-screen.sh
opt/pincabos/tools/pincabos-backglass-bridge.sh
opt/pincabos/tools/pincabos-gitpush-release-root.sh
opt/pincabos/tools/publy.sh
usr/local/sbin/pincabos-chat-backglass
97 changes: 97 additions & 0 deletions opt/pincabos/tests/test_doctor_installation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Le doctor dit si l'installation a vraiment atteint le cabinet.

PINCABOS_DOCTOR_PREMIER_DEMARRAGE_V1 — le 09/09/2026, deux testeurs ont perdu
leur soirée sur des échecs qui étaient déjà journalisés :

Flo « No displays found by libdmdutil », toutes les 10 s depuis le premier
démarrage. Il a soupçonné son routeur, sa passerelle, son câblage.
Patrick des rubans qui répondaient au menu et restaient noirs en table, faute
de directoutputconfig<N>.ini pour le numéro LedWiz de son contrôleur.
Flo la rotation du playfield choisie à l'installation, jamais appliquée.

Aucun de ces trois échecs n'était silencieux : ils étaient invisibles, ce qui
n'est pas pareil. Le doctor les remonte maintenant.
"""
import re
import unittest
from pathlib import Path

from _charge import RACINE

R = Path(RACINE)
DOCTOR = R / "usr/local/libexec/pincabos/doctor.d"
CHECK = DOCTOR / "85-installation.sh"


class LeControleExiste(unittest.TestCase):
def test_au_bon_endroit_et_dans_le_bon_ordre(self):
self.assertTrue(CHECK.is_file())
# après VPX (80), avant le verdict final (99)
noms = sorted(p.name for p in DOCTOR.glob("*.sh"))
self.assertIn("85-installation.sh", noms)
self.assertLess(noms.index("80-vpx.sh"), noms.index("85-installation.sh"))
self.assertLess(noms.index("85-installation.sh"), noms.index("99-ready.sh"))

def test_droits_alignes_sur_les_autres_modules(self):
# les modules du doctor sont SOURCÉS, pas exécutés
autres = [p.stat().st_mode & 0o777 for p in DOCTOR.glob("*.sh")
if p.name != "85-installation.sh"]
self.assertIn(CHECK.stat().st_mode & 0o777, set(autres),
"droits differents des autres modules du doctor")

def test_ouvre_sa_section(self):
self.assertRegex(CHECK.read_text(encoding="utf-8"), r'^pco_section "INSTALLATION"')


class LesQuatreControles(unittest.TestCase):
def setUp(self):
self.s = CHECK.read_text(encoding="utf-8")

def test_les_rejeux_en_attente(self):
# un drapeau qui traîne après plusieurs démarrages = le rejeu ne passe pas
for drapeau in ("inputs-installer.pending", "dmd-installer.pending"):
self.assertIn(drapeau, self.s)
self.assertIn("--list-boots", self.s,
"il faut distinguer « pas encore rejoué » de « ne passe jamais »")

def test_le_dmd_materiel_vu_par_vpinfe(self):
self.assertIn("No displays found by libdmdutil", self.s,
"c'est la phrase exacte que VPinFE journalise")
self.assertIn("zedmd.json", self.s)

def test_la_rotation_reellement_appliquee(self):
self.assertIn("playfield_rotation", self.s)
self.assertIn("xrandr", self.s)
# la correspondance degrés -> mot-clé xrandr doit être complète
for degre, mot in (("90", "right"), ("180", "inverted"), ("270", "left")):
self.assertRegex(self.s, r"%s\)\s*attendu=%s" % (degre, mot))

def test_les_definitions_d_effets_dof(self):
self.assertIn("directoutputconfig", self.s)
self.assertIn("ledwiz_number", self.s)
self.assertIn("hardware-inventory.json", self.s)


class LeControleNAvalePasSesErreurs(unittest.TestCase):
"""C'est le constat 01 de la revue : 2 187 « || true » dans le projet. Un
contrôle dont le rôle est de rendre les échecs visibles ne peut pas en
ajouter."""

def test_pas_de_ou_true_hors_du_chargement_des_chemins(self):
lignes = [l for l in CHECK.read_text(encoding="utf-8").splitlines()
if "|| true" in l and not l.strip().startswith("#")]
# seule tolérance : le sourcing de pincabos-paths.sh, absent en test
self.assertEqual(len(lignes), 1, "un « || true » de trop : " + repr(lignes))
self.assertIn("pincabos-paths.sh", lignes[0])

def test_chaque_controle_a_ses_trois_issues(self):
# un contrôle qui ne peut que réussir ne sert à rien
s = CHECK.read_text(encoding="utf-8")
self.assertGreaterEqual(len(re.findall(r"\bpco_fail\b", s)), 4)
self.assertGreaterEqual(len(re.findall(r"\bpco_go\b", s)), 5)
self.assertGreaterEqual(len(re.findall(r"\bpco_warn\b", s)), 2)

def test_les_messages_disent_quoi_faire(self):
s = CHECK.read_text(encoding="utf-8")
for indice in ("Map Commander", "la page DMD", "page DOF"):
self.assertIn(indice, s, "aucun message ne renvoie vers " + indice)
190 changes: 190 additions & 0 deletions opt/pincabos/tests/test_hygiene.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
"""Trois cliquets d'hygiène, issus de la revue du 10/09/2026.

Un cliquet ne demande pas de tout réparer aujourd'hui : il fige l'existant et
refuse que ça empire. Chacun des trois vient d'un piège qui a réellement mordu.

doublons 3 groupes, 1 523 lignes. Attention au piège : `sha256sum` SUIT
les liens symboliques. Un détecteur naïf voit 54 groupes et
26 902 lignes là où le projet a déjà posé 51 groupes de liens —
c'est l'erreur que j'ai commise en écrivant la revue. Seul le
mode git (120000) distingue un lien d'une copie.
rm -rf des fichiers effacent « $VAR » sans garde. J'ai écrit une de ces
lignes le 09/09 avec une variable qui n'existait pas ; seul le
« set -u » du script a évité le dégât.
[Install] l'unité de rotation du playfield vivait dans un .target.wants
sans WantedBy — donc jamais démarrée. Flo a perdu ses écrans
dans le bon sens pendant toute une soirée.
"""
import hashlib
import re
import subprocess
import unittest
from pathlib import Path

from _charge import RACINE

R = Path(RACINE)
TESTS = R / "opt/pincabos/tests"
DOUBLONS = TESTS / "doublons-connus.txt"
RMRF = TESTS / "rm-rf-connus.txt"
ISO_LIVE = R / "opt/pincabos/script/iso-live.sh"


def _lignes(fichier):
return [l.strip() for l in fichier.read_text(encoding="utf-8").splitlines()
if l.strip() and not l.startswith("#")]


def _fichiers_de_code():
"""Les fichiers RÉELS, jamais les liens symboliques.

Piège pris le 10/09/2026 : `sha256sum` suit les liens. Un détecteur qui
les inclut voit 54 « groupes de copies » là où le projet a en réalité déjà
posé 51 groupes de liens — et annonce 26 902 lignes dupliquées au lieu de
1 523. `git ls-files -s` donne le mode 120000 pour un lien : c'est la seule
façon fiable de les distinguer."""
modes = {}
for l in subprocess.run(["git", "-C", str(R), "ls-files", "-s"],
capture_output=True, text=True).stdout.splitlines():
m, _, _, f = l.split(None, 3)
modes[f] = m
out = []
for f, mode in modes.items():
if mode == "120000": # lien symbolique : ce n'est pas une copie
continue
p = R / f
if not p.is_file():
continue
if f.endswith((".py", ".sh")):
out.append(f)
else:
try:
if p.open("rb").read(2) == b"#!":
out.append(f)
except OSError:
pass
return sorted(out)


class PasDeNouveauDoublon(unittest.TestCase):
"""PINCABOS_PAS_DE_NOUVEAU_DOUBLON_V1 — la liste ne doit que rétrécir."""

@classmethod
def setUpClass(cls):
par_somme = {}
for f in _fichiers_de_code():
h = hashlib.sha256((R / f).read_bytes()).hexdigest()[:16]
par_somme.setdefault(h, []).append(f)
cls.reels = {" ".join(sorted(v)) for v in par_somme.values() if len(v) > 1}
cls.connus = set(_lignes(DOUBLONS))

def test_la_base_existe(self):
self.assertTrue(DOUBLONS.is_file(), "la base de reference manque")

def test_aucun_groupe_nouveau(self):
nouveaux = sorted(self.reels - self.connus)
self.assertEqual(nouveaux, [],
"copie(s) au lieu de lien(s) symbolique(s) :\n "
+ "\n ".join(nouveaux))

def test_les_groupes_resolus_sortent_de_la_base(self):
# echec volontairement agreable : quelqu un vient de dedupliquer
resolus = sorted(self.connus - self.reels)
self.assertEqual(resolus, [],
"ces groupes ne sont plus dupliques — retirez leur ligne "
"de doublons-connus.txt :\n " + "\n ".join(resolus))


class PasDeNouveauRmRf(unittest.TestCase):
"""PINCABOS_PAS_DE_NOUVEAU_RM_RF_V1 — « rm -rf "$VIDE"/x » efface /x."""

MOTIF = re.compile(r'rm\s+-rf\s+"?\$(?!\{[A-Za-z_]+:[?-])')

@classmethod
def setUpClass(cls):
cls.reels = {f for f in _fichiers_de_code()
if cls.MOTIF.search((R / f).read_text(encoding="utf-8",
errors="replace"))}
cls.connus = set(_lignes(RMRF))

def test_aucun_fichier_nouveau(self):
nouveaux = sorted(self.reels - self.connus)
self.assertEqual(nouveaux, [],
"utilisez \"${VAR:?}\" — une variable vide efface la racine :\n "
+ "\n ".join(nouveaux))

def test_les_fichiers_corriges_sortent_de_la_base(self):
resolus = sorted(self.connus - self.reels)
self.assertEqual(resolus, [],
"corrige(s) — retirez leur ligne de rm-rf-connus.txt :\n "
+ "\n ".join(resolus))


class ToutCeQuiEstActiveEstActivable(unittest.TestCase):
"""Aucune tolérance ici : le contrôle passe déjà strictement.

Une unité posée dans un `*.target.wants/` sans `WantedBy` correspondant n'est
pas démarrée par cette cible — c'est le défaut exact de
pincabos-screen-hotplug, qui laissait le playfield à l'envers."""

def test_chaque_lien_a_son_WantedBy(self):
soucis = []
for lien in sorted((R / "etc/systemd/system").glob("*.target.wants/*")):
cible_nom = lien.parent.name.replace(".wants", "")
unite = (lien.parent / lien.readlink()) if lien.is_symlink() else lien
if not unite.is_file():
soucis.append("%s/%s : cible absente" % (lien.parent.name, lien.name))
continue
texte = unite.read_text(encoding="utf-8", errors="replace")
if not re.search(r"(?m)^WantedBy=.*\b%s\b" % re.escape(cible_nom), texte):
soucis.append("%s/%s : pas de WantedBy=%s"
% (lien.parent.name, lien.name, cible_nom))
self.assertEqual(soucis, [], "\n " + "\n ".join(soucis))

def test_reciproque_toute_unite_qui_se_declare_est_liee(self):
"""PINCABOS_UNITE_DECLAREE_SANS_LIEN_V1 — cliquet, pas règle stricte.

Onze unités déclarent `WantedBy=` sans lien d'activation. Elles ne sont
donc pas démarrées par la cible qu'elles nomment. Certaines le sont
autrement — `pincabos-dudescab-hotplug-recovery` par une règle udev,
`pincabos-splash-sync` par un `.path` du même nom, les trois
`firstboot-*` par un appel direct de l'installateur. D'autres, non :
`safe-batch-full`, `scoreview-x11-hq-preview` et `table-test` ne sont
appelées de nulle part.

Trancher au cas par cas demande de savoir ce que chaque unité doit
faire ; ce test se contente d'empêcher la liste de grandir."""
reels = set()
for unite in sorted((R / "etc/systemd/system").glob("*.service")):
m = re.search(r"(?m)^WantedBy=(\S+)", unite.read_text(encoding="utf-8",
errors="replace"))
if not m:
continue
for cible in m.group(1).split():
if not (R / "etc/systemd/system" / (cible + ".wants") / unite.name).exists():
reels.add("%s %s" % (unite.name, cible))
connus = set(_lignes(TESTS / "unites-sans-lien.txt"))
nouvelles = sorted(reels - connus)
self.assertEqual(nouvelles, [],
"declaree(s) sans lien : elles ne demarreront pas.\n "
+ "\n ".join(nouvelles))
reglees = sorted(connus - reels)
self.assertEqual(reglees, [],
"reglee(s) — retirez leur ligne de unites-sans-lien.txt :\n "
+ "\n ".join(reglees))


class LImageNEmportePasNosBrouillons(unittest.TestCase):
"""PINCABOS_IMAGE_SANS_BROUILLONS_V1 — 11 039 lignes de scripts de travail et
les notes de DEV/ partaient sur chaque cabinet."""

def test_les_exclusions_sont_posees(self):
s = ISO_LIVE.read_text(encoding="utf-8")
for motif in ('"root/pincab-*"', '"root/pincabos-*"', '"DEV" "DEV/*"'):
self.assertIn(motif, s, motif + " n'est pas exclu de l'image")

def test_ce_qui_appartient_a_root_reste(self):
# .bashrc, .profile et .ssh sont legitimes : on n'exclut pas root/ en bloc
s = ISO_LIVE.read_text(encoding="utf-8")
self.assertNotRegex(s, r'"root"\s+"root/\*"',
"exclure root/ en bloc emporterait .ssh et .profile")
21 changes: 21 additions & 0 deletions opt/pincabos/tests/unites-sans-lien.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# PINCABOS_UNITE_DECLAREE_SANS_LIEN_V1
# Unites qui declarent WantedBy= sans lien dactivation, relevees le 10/09/2026.
# Une unite ici n est PAS demarree par la cible qu elle nomme. Certaines le sont
# autrement (udev, .path, appel direct par l installateur) ; d autres ne le sont
# par personne. C est le defaut exact de pincabos-screen-hotplug, qui laissait le
# playfield a l envers.
# Cette liste ne doit que RETRECIR.
#
# 11 unites.
#
pincabos-dashboard-live-capture.service multi-user.target
pincabos-dudescab-hotplug-recovery.service multi-user.target
pincabos-finalize-firstboot.service multi-user.target
pincabos-firstboot-initramfs-refresh.service multi-user.target
pincabos-firstboot-network-webapp-fix.service multi-user.target
pincabos-firstboot-vpinfe-packaged-runtime-fix.service graphical.target
pincabos-safe-batch-full.service multi-user.target
pincabos-scoreview-x11-hq-preview.service graphical.target
pincabos-splash-sync.service multi-user.target
pincabos-table-test.service graphical.target
pincabos-webapp-fallback.service multi-user.target
Loading
Loading