[FIX] script todo: résumer les erreurs au lieu de chercher « error »
Le volet « d » et le compteur du tableau de bord cherchaient la sous-chaîne « error ». Le journal de l'installation qui vient d'échouer — APK tué par le noyau sur erplibre-ubuntu-2604-gnome — n'en contient AUCUNE : 0 ligne sur 8765, mesuré. Le volet annonçait « aucune erreur détectée » sur une machine morte, et le tableau de bord 0 erreur. Comptent désormais les marqueurs qui ne disent jamais « error » : « ⚠ ÉCHEC », « FAILURE », une trace Python, un « fatal: » de git, une mort par mémoire. Le volet ouvre sur un résumé — l'étape en échec et son diagnostic, les signaux durs, puis les répétitions comptées par forme. Sur ce journal : 1 étape nommée, 2 signaux, là où il n'affichait rien. --- EN --- The "d" pane and the dashboard counter looked for the "error" substring. The log of the install that just failed — APK killed by the kernel on erplibre-ubuntu-2604-gnome — contains NONE: 0 lines out of 8765, measured. The pane said "no error detected" about a dead machine, and the dashboard 0 errors. Markers that never say "error" now count: "⚠ ÉCHEC", "FAILURE", a Python traceback, a git "fatal:", a death by memory. The pane opens on a summary — the failed step with its diagnostic, the hard signals, then repeats counted by shape. On that log: 1 named step and 2 signals, where it showed nothing. Assisted-by: Claude Opus 5
This commit is contained in:
parent
8f5a439148
commit
6177b1a6a9
3 changed files with 395 additions and 8 deletions
|
|
@ -395,6 +395,133 @@ _LST_IGNORE_ERROR = (
|
|||
)
|
||||
|
||||
|
||||
# Signaux d'échec qui ne contiennent NI « error » NI « warning ». Sans eux, le
|
||||
# scan par sous-chaîne rate des installations franchement ratées : le journal de
|
||||
# la VM erplibre-ubuntu-2604-gnome, dont la compilation de l'APK a été tuée par
|
||||
# le noyau, ne portait AUCUNE ligne « error » — mesuré, 0 sur 8765 lignes —
|
||||
# pendant que « ⚠ ÉCHEC : APK debug (gradle) », « FAILURE: Build failed » et
|
||||
# « daemon disappeared unexpectedly » y étaient. Le détail des erreurs annonçait
|
||||
# donc « aucune erreur détectée » sur une installation en échec.
|
||||
#
|
||||
# Chaque motif est là parce qu'il est apparu dans un vrai journal, pas par
|
||||
# précaution : Gradle dit « FAILURE », Python « Traceback », git « fatal: », apt
|
||||
# « Unable to locate package », le noyau « Killed » ou « Cannot allocate
|
||||
# memory », et nos propres étapes « ⚠ ÉCHEC ».
|
||||
_LST_HARD_MARKERS = (
|
||||
"⚠ échec",
|
||||
"failed:",
|
||||
"failure",
|
||||
"traceback (most recent call last)",
|
||||
"fatal:",
|
||||
"command not found",
|
||||
# PAS « no such file or directory » : sur le journal de référence, 5 de ses
|
||||
# 7 occurrences étaient des sondes bénignes (« cat: .odoo-version »), et le
|
||||
# bruit dilue un résumé dont l'intérêt est justement d'être court. Un
|
||||
# fichier vraiment manquant fait échouer une ÉTAPE, elle-même captée.
|
||||
"permission denied",
|
||||
"unable to locate package",
|
||||
"disappeared unexpectedly",
|
||||
"outofmemory",
|
||||
"cannot allocate memory",
|
||||
"segmentation fault",
|
||||
"core dumped",
|
||||
"killed process",
|
||||
)
|
||||
# Étape en échec, telle que la pose « mstep » : « ⚠ ÉCHEC : <libellé> ». C'est
|
||||
# le signal AUTORITAIRE — il nomme l'étape, là où « FAILURE » ne nomme que
|
||||
# l'outil.
|
||||
_RE_FAILED_STEP = re.compile(r"⚠\s*(?:ÉCHEC|FAILED)\s*:?\s*(.+)")
|
||||
# Début d'une autre étape ou d'une section : borne du diagnostic qui suit.
|
||||
_RE_STEP_BOUND = re.compile(r"^\s*(?:->|==)\s")
|
||||
|
||||
|
||||
def _is_hard_signal(line: str) -> bool:
|
||||
low = line.lower()
|
||||
return any(m in low for m in _LST_HARD_MARKERS)
|
||||
|
||||
|
||||
def _error_signature(line: str) -> str:
|
||||
"""Ligne réduite à sa FORME, pour regrouper les répétitions.
|
||||
|
||||
Un journal d'installation répète la même erreur des centaines de fois avec
|
||||
un chemin ou un numéro qui change. Regrouper sur cette forme donne « ×342 »
|
||||
au lieu de 342 lignes à faire défiler."""
|
||||
sig = re.sub(r"\d+", "#", line)
|
||||
sig = re.sub(r"0x[0-9a-fA-F]+", "#", sig)
|
||||
sig = re.sub(r"/\S+", "/…", sig)
|
||||
return re.sub(r"\s+", " ", sig).strip()[:160]
|
||||
|
||||
|
||||
def scan_log_summary(log_path: str, diag_cap: int = 14) -> dict:
|
||||
"""Résumé d'un journal d'installation : ce qui a échoué, puis le reste.
|
||||
|
||||
Rend {steps, hard, groups, nerr, nwarn} où « steps » liste les étapes en
|
||||
échec AVEC leur diagnostic, « hard » les autres signaux durs dédupliqués, et
|
||||
« groups » les lignes « error »/« warning » regroupées par forme et comptées.
|
||||
|
||||
L'ordre n'est pas cosmétique : une étape en échec nommée vaut mille lignes,
|
||||
et c'est elle qu'on veut lire d'abord."""
|
||||
try:
|
||||
lines = Path(log_path).read_text(errors="replace").splitlines()
|
||||
except OSError:
|
||||
return {"steps": [], "hard": [], "groups": [], "nerr": 0, "nwarn": 0}
|
||||
|
||||
steps, hard, groups = [], {}, {}
|
||||
nerr = nwarn = 0
|
||||
for i, line in enumerate(lines, 1):
|
||||
if EXIT_MARKER in line:
|
||||
continue
|
||||
low = line.lower()
|
||||
match = _RE_FAILED_STEP.search(line)
|
||||
if match:
|
||||
# Le diagnostic suit l'échec, jusqu'à l'étape suivante : c'est lui
|
||||
# qui porte la cause, l'échec ne portant que le nom.
|
||||
diag = []
|
||||
for nxt in lines[i : i + 60]:
|
||||
if _RE_STEP_BOUND.match(nxt) or _RE_FAILED_STEP.search(nxt):
|
||||
break
|
||||
if EXIT_MARKER in nxt:
|
||||
continue
|
||||
if nxt.strip() and len(diag) < diag_cap:
|
||||
diag.append(nxt.rstrip())
|
||||
steps.append(
|
||||
{"line": i, "label": match.group(1).strip(), "diag": diag}
|
||||
)
|
||||
continue
|
||||
if _is_hard_signal(line):
|
||||
sig = _error_signature(line)
|
||||
entry = hard.setdefault(
|
||||
sig, {"line": i, "text": line.strip(), "count": 0}
|
||||
)
|
||||
entry["count"] += 1
|
||||
continue
|
||||
if "error" in low and not any(ig in line for ig in _LST_IGNORE_ERROR):
|
||||
nerr += 1
|
||||
key = ("error", _error_signature(line))
|
||||
groups.setdefault(
|
||||
key, {"line": i, "text": line.strip(), "count": 0}
|
||||
)["count"] += 1
|
||||
if "warning" in low and not any(
|
||||
ig in line for ig in _LST_IGNORE_WARNING
|
||||
):
|
||||
nwarn += 1
|
||||
key = ("warning", _error_signature(line))
|
||||
groups.setdefault(
|
||||
key, {"line": i, "text": line.strip(), "count": 0}
|
||||
)["count"] += 1
|
||||
ordered = sorted(
|
||||
({"kind": k[0], **v} for k, v in groups.items()),
|
||||
key=lambda g: (-g["count"], g["line"]),
|
||||
)
|
||||
return {
|
||||
"steps": steps,
|
||||
"hard": sorted(hard.values(), key=lambda h: h["line"]),
|
||||
"groups": ordered,
|
||||
"nerr": nerr,
|
||||
"nwarn": nwarn,
|
||||
}
|
||||
|
||||
|
||||
def scan_log_error_lines(log_path: str, cap: int = 500) -> tuple[list, list]:
|
||||
"""(lignes_erreur, lignes_avertissement) d'un log, même détection que
|
||||
scan_log_errors mais on RETIENT les lignes (bornées à `cap`) pour les
|
||||
|
|
@ -408,6 +535,9 @@ def scan_log_error_lines(log_path: str, cap: int = 500) -> tuple[list, list]:
|
|||
if EXIT_MARKER in line:
|
||||
continue
|
||||
low = line.lower()
|
||||
if _is_hard_signal(line) and len(errs) < cap:
|
||||
errs.append(f"{i}: {line}")
|
||||
continue
|
||||
if (
|
||||
"error" in low
|
||||
and not any(ig in line for ig in _LST_IGNORE_ERROR)
|
||||
|
|
@ -438,6 +568,13 @@ def scan_log_errors(log_path: str) -> tuple[int, int]:
|
|||
low = line.lower()
|
||||
if EXIT_MARKER in line:
|
||||
continue
|
||||
# Un échec d'étape EST une erreur, même sans le mot « error » : sinon le
|
||||
# tableau de bord affiche « 0 erreur » sur une installation ratée —
|
||||
# mesuré sur erplibre-ubuntu-2604-gnome, 0 ligne « error » pour un APK
|
||||
# tué par le noyau.
|
||||
if _is_hard_signal(line):
|
||||
nerr += 1
|
||||
continue
|
||||
if "error" in low and not any(ig in line for ig in _LST_IGNORE_ERROR):
|
||||
nerr += 1
|
||||
if "warning" in low and not any(
|
||||
|
|
@ -984,26 +1121,64 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
("q", "dismiss", "Fermer"),
|
||||
]
|
||||
|
||||
def __init__(self, vm_name, errs, warns):
|
||||
def __init__(self, vm_name, errs, warns, summary=None):
|
||||
super().__init__()
|
||||
self._vm = vm_name
|
||||
self._errs = errs
|
||||
self._warns = warns
|
||||
self._sum = summary or {}
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="errbox"):
|
||||
yield Static(
|
||||
f" {self._vm} — ⚠ {len(self._errs)} "
|
||||
nsteps = len(self._sum.get("steps", []))
|
||||
head = (
|
||||
f" {self._vm} — ⚠ {len(self._errs)} "
|
||||
f"{t('errors')} · ⚡ {len(self._warns)} {t('warnings')}"
|
||||
)
|
||||
# Le nombre d'étapes en échec passe DEVANT : c'est la seule ligne du
|
||||
# bandeau qui dise si l'installation a abouti.
|
||||
if nsteps:
|
||||
head = (
|
||||
f" {self._vm} — 🛑 {nsteps} "
|
||||
f"{t('failed steps')} · ⚠ {len(self._errs)} "
|
||||
f"{t('errors')} · ⚡ {len(self._warns)} {t('warnings')}"
|
||||
f" ({t('Esc to close')})",
|
||||
id="errtitle",
|
||||
)
|
||||
with Vertical(id="errbox"):
|
||||
yield Static(f"{head} ({t('Esc to close')})", id="errtitle")
|
||||
yield RichLog(
|
||||
id="errlog", highlight=False, markup=False, wrap=True
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
log = self.query_one("#errlog", RichLog)
|
||||
steps = self._sum.get("steps", [])
|
||||
hard = self._sum.get("hard", [])
|
||||
groups = self._sum.get("groups", [])
|
||||
|
||||
# -- Le résumé, d'abord. Une étape nommée vaut mille lignes.
|
||||
if steps:
|
||||
log.write(f"── {t('Failed steps')} ──")
|
||||
for st in steps:
|
||||
log.write(f"🛑 {st['label']} ({t('line')} {st['line']})")
|
||||
for line in st["diag"]:
|
||||
log.write(f" {line.strip()}")
|
||||
log.write("")
|
||||
if hard:
|
||||
log.write(f"── {t('Hard signals')} ──")
|
||||
for h in hard:
|
||||
mult = f" ×{h['count']}" if h["count"] > 1 else ""
|
||||
log.write(f"{h['line']}:{mult} {h['text']}")
|
||||
log.write("")
|
||||
if groups:
|
||||
# Regroupé par FORME : un journal répète la même erreur des
|
||||
# centaines de fois avec un chemin qui change.
|
||||
log.write(f"── {t('Grouped by shape')} ──")
|
||||
for g in groups[:60]:
|
||||
mark = "⚠" if g["kind"] == "error" else "⚡"
|
||||
mult = f" ×{g['count']}" if g["count"] > 1 else ""
|
||||
log.write(f"{mark} {g['line']}:{mult} {g['text']}")
|
||||
log.write("")
|
||||
|
||||
# -- Puis le détail brut, pour qui veut tout lire.
|
||||
if self._errs:
|
||||
log.write(f"── {t('errors').capitalize()} ──")
|
||||
for line in self._errs:
|
||||
|
|
@ -1012,7 +1187,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
log.write(f"── {t('warnings').capitalize()} ──")
|
||||
for line in self._warns:
|
||||
log.write(line)
|
||||
if not self._errs and not self._warns:
|
||||
if not (steps or hard or self._errs or self._warns):
|
||||
log.write(t("No error detected."))
|
||||
|
||||
def action_dismiss(self) -> None:
|
||||
|
|
@ -1782,7 +1957,10 @@ def run_monitor(manifest_path: str, run_app: bool = True):
|
|||
if not vm:
|
||||
return
|
||||
errs, warns = scan_log_error_lines(vm["log"])
|
||||
self.push_screen(ErrorLinesScreen(vm["name"], errs, warns))
|
||||
summary = scan_log_summary(vm["log"])
|
||||
self.push_screen(
|
||||
ErrorLinesScreen(vm["name"], errs, warns, summary)
|
||||
)
|
||||
|
||||
def on_click(self, event) -> None:
|
||||
# Clic sur le sommaire de stats -> déplie / replie le détail.
|
||||
|
|
|
|||
|
|
@ -2253,6 +2253,11 @@ TRANSLATIONS = {
|
|||
"en": "(the hypervisor only relays; -J puts the VM last)",
|
||||
},
|
||||
"Choice": {"fr": "Choix", "en": "Choice"},
|
||||
"failed steps": {"fr": "étapes en échec", "en": "failed steps"},
|
||||
"Failed steps": {"fr": "Étapes en échec", "en": "Failed steps"},
|
||||
"Hard signals": {"fr": "Signaux durs", "en": "Hard signals"},
|
||||
"Grouped by shape": {"fr": "Regroupé par forme", "en": "Grouped by shape"},
|
||||
"line": {"fr": "ligne", "en": "line"},
|
||||
"Tick the Android emulator tool when deploying.": {
|
||||
"fr": "Cochez l'outil Émulateur Android au déploiement.",
|
||||
"en": "Tick the Android emulator tool when deploying.",
|
||||
|
|
|
|||
204
test/test_qemu_install_summary.py
Normal file
204
test/test_qemu_install_summary.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
"""Résumé d'un journal d'installation : ce qui a échoué doit se voir.
|
||||
|
||||
Le détail des erreurs cherchait la sous-chaîne « error ». Or le journal de
|
||||
l'installation qui a réellement échoué — erplibre-ubuntu-2604-gnome, APK tué
|
||||
par le noyau — ne contient AUCUNE ligne « error » : 0 sur 8765, mesuré. Le
|
||||
volet annonçait donc « aucune erreur détectée » sur une installation ratée,
|
||||
et le tableau de bord comptait 0 erreur.
|
||||
|
||||
Ces tests fixent la règle inverse : une étape en échec, un « FAILURE » de
|
||||
Gradle, une trace Python ou une mort par mémoire se voient, et le résumé les
|
||||
présente AVANT les centaines de lignes du détail.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "script/todo"))
|
||||
import qemu_install_monitor as m # noqa: E402
|
||||
|
||||
# Journal réduit à sa forme réelle : les marqueurs de l'installation, puis
|
||||
# l'échec tel que Gradle l'écrit. Aucune ligne ne contient « error ».
|
||||
LOG_GRADLE_OOM = """\
|
||||
== ERPLibre mobile, SDK Android (long) ==
|
||||
-> venv ERPLibre (tout ce qui suit en dépend)
|
||||
-> dépendances npm
|
||||
-> APK debug (gradle)
|
||||
⚠ ÉCHEC : APK debug (gradle)
|
||||
aucun motif connu, dernières lignes :
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* What went wrong:
|
||||
Gradle build daemon disappeared unexpectedly (it may have been killed)
|
||||
⚠ aucun APK produit
|
||||
__ERPLIBRE_EXIT__ 1
|
||||
"""
|
||||
|
||||
|
||||
def _log(text):
|
||||
fh = tempfile.NamedTemporaryFile(
|
||||
"w", suffix=".log", delete=False, encoding="utf-8"
|
||||
)
|
||||
fh.write(text)
|
||||
fh.close()
|
||||
return fh.name
|
||||
|
||||
|
||||
class TestFailedStepsAreSeen(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.path = _log(LOG_GRADLE_OOM)
|
||||
|
||||
def tearDown(self):
|
||||
Path(self.path).unlink(missing_ok=True)
|
||||
|
||||
def test_the_reference_log_has_no_line_saying_error(self):
|
||||
"""La prémisse de tout le reste : la détection par sous-chaîne ne
|
||||
pouvait RIEN trouver ici."""
|
||||
self.assertNotIn("error", LOG_GRADLE_OOM.lower())
|
||||
|
||||
def test_the_failed_step_is_named(self):
|
||||
got = m.scan_log_summary(self.path)
|
||||
self.assertEqual(
|
||||
[s["label"] for s in got["steps"]], ["APK debug (gradle)"]
|
||||
)
|
||||
|
||||
def test_the_step_carries_its_diagnostic(self):
|
||||
"""L'échec nomme l'étape ; c'est le diagnostic qui porte la cause."""
|
||||
diag = "\n".join(m.scan_log_summary(self.path)["steps"][0]["diag"])
|
||||
self.assertIn("FAILURE: Build failed", diag)
|
||||
self.assertIn("daemon disappeared", diag)
|
||||
|
||||
def test_the_exit_marker_is_not_a_diagnostic(self):
|
||||
diag = "\n".join(m.scan_log_summary(self.path)["steps"][0]["diag"])
|
||||
self.assertNotIn(m.EXIT_MARKER, diag)
|
||||
|
||||
def test_the_diagnostic_stops_at_the_next_step(self):
|
||||
"""Sinon le diagnostic avale la suite de l'installation et ne désigne
|
||||
plus rien."""
|
||||
text = LOG_GRADLE_OOM + " -> étape suivante\n bruit\n"
|
||||
path = _log(text)
|
||||
try:
|
||||
diag = "\n".join(m.scan_log_summary(path)["steps"][0]["diag"])
|
||||
finally:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
self.assertNotIn("bruit", diag)
|
||||
|
||||
def test_hard_signals_are_listed(self):
|
||||
hard = " ".join(
|
||||
h["text"] for h in m.scan_log_summary(self.path)["hard"]
|
||||
)
|
||||
self.assertIn("FAILURE", hard)
|
||||
self.assertIn("disappeared unexpectedly", hard)
|
||||
|
||||
def test_the_dashboard_no_longer_counts_zero_errors(self):
|
||||
"""Le compte alimente le tableau de bord : « 0 erreur » sur une
|
||||
installation morte est un mensonge, pas une nuance."""
|
||||
nerr, _ = m.scan_log_errors(self.path)
|
||||
self.assertGreater(nerr, 0)
|
||||
|
||||
def test_the_detail_pane_is_no_longer_empty(self):
|
||||
errs, _ = m.scan_log_error_lines(self.path)
|
||||
self.assertTrue(errs)
|
||||
self.assertTrue(any("ÉCHEC" in e or "FAILURE" in e for e in errs))
|
||||
|
||||
|
||||
class TestOtherRealFailures(unittest.TestCase):
|
||||
"""Chaque motif dur est là parce qu'il est apparu dans un vrai journal."""
|
||||
|
||||
def _first_hard(self, line):
|
||||
path = _log(f" -> étape\n{line}\n")
|
||||
try:
|
||||
return m.scan_log_summary(path)["hard"]
|
||||
finally:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
|
||||
def test_python_traceback(self):
|
||||
self.assertTrue(self._first_hard("Traceback (most recent call last):"))
|
||||
|
||||
def test_git_fatal(self):
|
||||
self.assertTrue(self._first_hard("fatal: repository not found"))
|
||||
|
||||
def test_apt_missing_package(self):
|
||||
self.assertTrue(
|
||||
self._first_hard("E: Unable to locate package python3.12-venv")
|
||||
)
|
||||
|
||||
def test_kernel_oom(self):
|
||||
self.assertTrue(
|
||||
self._first_hard("Out of memory: Killed process 37603 (java)")
|
||||
)
|
||||
|
||||
def test_missing_command(self):
|
||||
self.assertTrue(self._first_hard("bash: emulator: command not found"))
|
||||
|
||||
def test_a_benign_probe_is_not_a_hard_signal(self):
|
||||
"""« No such file or directory » sortait 5 fois sur 7 d'une sonde
|
||||
bénigne (« cat: .odoo-version ») : le bruit dilue un résumé dont tout
|
||||
l'intérêt est d'être court."""
|
||||
self.assertFalse(
|
||||
self._first_hard("cat: .odoo-version: No such file or directory")
|
||||
)
|
||||
|
||||
|
||||
class TestGrouping(unittest.TestCase):
|
||||
def test_repeats_are_counted_not_repeated(self):
|
||||
"""Un journal répète la même erreur des centaines de fois avec un
|
||||
chemin qui change : on veut « ×200 », pas 200 lignes."""
|
||||
lines = "\n".join(
|
||||
f"ERROR: cannot read /var/lib/x/file{i}.txt" for i in range(200)
|
||||
)
|
||||
path = _log(lines + "\n")
|
||||
try:
|
||||
groups = m.scan_log_summary(path)["groups"]
|
||||
finally:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
self.assertEqual(len(groups), 1)
|
||||
self.assertEqual(groups[0]["count"], 200)
|
||||
|
||||
def test_the_most_frequent_comes_first(self):
|
||||
path = _log(
|
||||
"ERROR: rare thing\n"
|
||||
+ "\n".join(f"ERROR: common {i}" for i in range(5))
|
||||
+ "\n"
|
||||
)
|
||||
try:
|
||||
groups = m.scan_log_summary(path)["groups"]
|
||||
finally:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
self.assertEqual(groups[0]["count"], 5)
|
||||
|
||||
def test_warnings_are_grouped_apart_from_errors(self):
|
||||
path = _log("WARNING: a\nERROR: b\n")
|
||||
try:
|
||||
kinds = {g["kind"] for g in m.scan_log_summary(path)["groups"]}
|
||||
finally:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
self.assertEqual(kinds, {"error", "warning"})
|
||||
|
||||
|
||||
class TestQuietLogs(unittest.TestCase):
|
||||
def test_a_clean_log_stays_clean(self):
|
||||
"""Le résumé ne doit pas inventer d'échec là où il n'y en a pas."""
|
||||
path = _log("== installation ==\n -> étape\n ✅ terminé\n")
|
||||
try:
|
||||
got = m.scan_log_summary(path)
|
||||
finally:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
self.assertEqual(got["steps"], [])
|
||||
self.assertEqual(got["hard"], [])
|
||||
self.assertEqual(got["groups"], [])
|
||||
|
||||
def test_a_missing_log_is_not_a_crash(self):
|
||||
got = m.scan_log_summary("/nonexistent/erplibre.log")
|
||||
self.assertEqual(got["steps"], [])
|
||||
self.assertEqual(got["nerr"], 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue