#!/usr/bin/env python3 """Test de outils/temps-session.py, hors ligne. python3 -I tests/test-temps-session.py Un transcript fabriqué dans un HOME jetable, et un Gitea simulé (l'appel réseau est remplacé) : on vérifie ce que l'outil propose de saisir. """ import contextlib, datetime, importlib.util, io, json, os, sys, tempfile from pathlib import Path DEPOT = Path(__file__).resolve().parent.parent OUTIL = DEPOT / "outils" / "temps-session.py" SESSION = "00000000-0000-0000-0000-000000000000" echecs = [] def verifier(condition, message): if not condition: echecs.append(message) def charger(): spec = importlib.util.spec_from_file_location("temps_session", OUTIL) m = importlib.util.module_from_spec(spec) spec.loader.exec_module(m) return m def humain(t, texte): return {"type": "user", "timestamp": t, "message": {"role": "user", "content": texte}} def agent(t): return {"type": "assistant", "timestamp": t, "message": {"role": "assistant", "content": [{"type": "text", "text": "…"}]}} # Deux tours : A de 10:00 à 10:05 (300 s), B de 10:08 à 10:10 (120 s). TRANSCRIPT = [humain("2026-10-21T10:00:00Z", "A"), agent("2026-10-21T10:02:00Z"), agent("2026-10-21T10:05:00Z"), humain("2026-10-21T10:08:00Z", "B"), agent("2026-10-21T10:10:00Z")] # (api, jeton) de chaque appel au Gitea simulé, pour le dernier lancement. APPELS = [] def lancer(ts, saisies, *options, jeton=True, refus=False): """Lance main() avec un Gitea qui renvoie `saisies` ; rend les lignes JSON. jeton=False n'ajoute pas --jeton ; refus=True attend que l'outil refuse, sans avoir appelé Gitea.""" def gitea(api, chemin, j): APPELS.append((api, j)) if chemin == "/user": return {"login": "agent"} return saisies if chemin.endswith("&page=1") else [] ts.gitea = gitea APPELS.clear() sys.argv = ["temps-session.py", "--depot", "organisation/depot", "--ticket", "1", "--session", SESSION, *(["--jeton", str(JETON)] if jeton else []), *options] sortie = io.StringIO() erreur = io.StringIO() try: with contextlib.redirect_stdout(sortie), contextlib.redirect_stderr(erreur): ts.main() except SystemExit: verifier(refus, "l'outil refuse %s : %s" % (" ".join(options), erreur.getvalue().strip()[-120:])) verifier(not APPELS, "l'outil refuse %s après avoir appelé %r" % (" ".join(options), APPELS)) return [] verifier(not refus, "l'outil accepte %s" % " ".join(options)) return [json.loads(l) for l in sortie.getvalue().splitlines() if l.strip()] with tempfile.TemporaryDirectory() as t: t = Path(t) os.environ["HOME"] = str(t) projet = t / ".claude" / "projects" / "-espace" projet.mkdir(parents=True) (projet / (SESSION + ".jsonl")).write_text("".join(json.dumps(d) + "\n" for d in TRANSCRIPT)) for v in ("XDG_CONFIG_HOME", "TKFA_CONFIG", "GITEA_TOKEN_FILE"): os.environ.pop(v, None) JETON = t / "jeton" JETON.write_text("factice\n") ts = charger() # Configuration des essais : `autre` par défaut (son jeton, chemin relatif # au fichier), `variable` (jeton par GITEA_TOKEN_FILE), et deux instances # à refuser. Les essais 1 à 7 passent --jeton, qui prime. conf = t / "conf" conf.mkdir() (conf / "autre.jeton").write_text("autre\n") config = conf / "tkfa.toml" config.write_text('''defaut = "autre" [instances.autre] api = "https://gitea.example.org/api/v1/" jeton = "autre.jeton" [instances.variable] api = "https://gitea.example.com/api/v1" jeton_env = "GITEA_TOKEN_FILE" [instances.sans-jeton] api = "https://gitea.example.net/api/v1" [instances.en-clair] api = "http://gitea.example.org/api/v1" jeton = "autre.jeton" ''') os.environ["TKFA_CONFIG"] = str(config) # 1. Rien de saisi : une séance (tours à moins de --seance), 420 s, datée de sa fin. lignes = lancer(ts, []) verifier([l["time"] for l in lignes] == [420], "sans saisie : %r au lieu de 420 s" % lignes) verifier(lignes and lignes[0]["created"].startswith("2026-10-21T10:10:00"), "la saisie n'est pas datée de la fin de la séance : %r" % lignes) # 2. A saisi, Gitea a ignoré la date envoyée et daté la saisie de son envoi # (10:06, après A, avant B) : seul B reste, 120 s. lignes = lancer(ts, [{"created": "2026-10-21T10:06:00Z", "time": 300}]) verifier([l["time"] for l in lignes] == [120], "saisie datée de son envoi : %r au lieu de 120 s (le temps de B est perdu)" % lignes) # 3. A saisi, Gitea a gardé la date envoyée (la fin de A) : seul B reste. lignes = lancer(ts, [{"created": "2026-10-21T10:05:00Z", "time": 300}]) verifier([l["time"] for l in lignes] == [120], "saisie datée de la fin de A : %r au lieu de 120 s" % lignes) # 4. Tout saisi : relancer ne double rien. lignes = lancer(ts, [{"created": "2026-10-21T10:06:00Z", "time": 300}, {"created": "2026-10-21T10:11:00Z", "time": 120}]) verifier(lignes == [], "tout est saisi, et l'outil propose encore %r" % lignes) # 5. --jusqua : le travail d'après la borne (B, sur un autre ticket) n'est pas compté. lignes = lancer(ts, [], "--jusqua", "2026-10-21T10:07:00Z") verifier([l["time"] for l in lignes] == [300], "--jusqua entre A et B : %r au lieu de 300 s" % lignes) verifier(lignes and lignes[0]["created"].startswith("2026-10-21T10:05:00"), "--jusqua : la saisie n'est pas datée de la fin de A : %r" % lignes) # 6. --jusqua au milieu d'un tour : seul ce qui précède la borne compte. lignes = lancer(ts, [], "--jusqua", "2026-10-21T10:03:00Z") verifier([l["time"] for l in lignes] == [120], "--jusqua dans A : %r au lieu de 120 s" % lignes) # 7. --depuis et --jusqua ensemble, sans fuseau (celui de la machine) : B seul. def local(h): return datetime.datetime.fromisoformat(h).astimezone().replace(tzinfo=None).isoformat() lignes = lancer(ts, [], "--depuis", local("2026-10-21T10:07:00+00:00"), "--jusqua", local("2026-10-21T10:30:00+00:00")) verifier([l["time"] for l in lignes] == [120], "--depuis/--jusqua sans fuseau : %r au lieu de 120 s" % lignes) # 8. Sans configuration, aucune instance : refus, même avec --jeton et # GITEA_TOKEN_FILE. del os.environ["TKFA_CONFIG"] os.environ["GITEA_TOKEN_FILE"] = str(JETON) lancer(ts, [], refus=True) lancer(ts, [], jeton=False, refus=True) # 9. L'instance par défaut : son api, son jeton, et pas GITEA_TOKEN_FILE, # resté défini ; `jeton_env` sert l'instance qui le nomme. lancer(ts, [], "--config", str(config), jeton=False) verifier(APPELS and all(a == ("https://gitea.example.org/api/v1", "autre") for a in APPELS), "instance par défaut : appels %r" % APPELS) lancer(ts, [], "--config", str(config), "--instance", "variable", jeton=False) verifier(APPELS and all(a == ("https://gitea.example.com/api/v1", "factice") for a in APPELS), "jeton_env : appels %r" % APPELS) # 10. TKFA_CONFIG, puis le chemin par défaut, sont lus aussi. os.environ["TKFA_CONFIG"] = str(config) lancer(ts, [], jeton=False) verifier(APPELS and APPELS[0][0] == "https://gitea.example.org/api/v1", "TKFA_CONFIG : appels %r" % APPELS) del os.environ["TKFA_CONFIG"] (t / ".config" / "tkfa").mkdir(parents=True) (t / ".config" / "tkfa" / "config.toml").write_text(config.read_text().replace( '"autre.jeton"', '"%s"' % (conf / "autre.jeton"))) lancer(ts, [], jeton=False) verifier(APPELS and APPELS[0] == ("https://gitea.example.org/api/v1", "autre"), "~/.config/tkfa/config.toml : appels %r" % APPELS) (t / ".config" / "tkfa" / "config.toml").unlink() # 11. Refus, avant tout appel : instance sans jeton (GITEA_TOKEN_FILE est # défini mais ne part pas ailleurs), variable de `jeton_env` absente, # api en http, instance ou fichier absents. lancer(ts, [], "--config", str(config), "--instance", "sans-jeton", jeton=False, refus=True) del os.environ["GITEA_TOKEN_FILE"] lancer(ts, [], "--config", str(config), "--instance", "variable", jeton=False, refus=True) lancer(ts, [], "--config", str(config), "--instance", "en-clair", refus=True) lancer(ts, [], "--config", str(config), "--instance", "inconnue", refus=True) lancer(ts, [], "--config", str(t / "absent.toml"), refus=True) # 12. --version : le nom de l'outil et sa version, sans rien lire d'autre. sortie = io.StringIO() sys.argv = ["temps-session.py", "--version"] try: with contextlib.redirect_stdout(sortie): ts.main() except SystemExit: pass verifier(sortie.getvalue().strip() == "temps-session.py " + ts.VERSION, "--version affiche %r" % sortie.getvalue()) if echecs: print("%d échec(s) :" % len(echecs)) for e in echecs: print(" ✗", e) sys.exit(1) print("ok : outil de saisie des temps")