Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KddsXCqEbKMhdFJDYbAsx8
503 lines
19 KiB
Python
503 lines
19 KiB
Python
"""Comenzile slash: /new (+ fork), /cd, /model, /status, /stop, /cleanup, /help."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
import bot
|
|
|
|
try:
|
|
from tests.test_bot import (CHANNEL, USER, FakeChannel, FakeInteraction,
|
|
FakeMessage, FakeUser, allowed, bridge)
|
|
except ImportError: # pragma: no cover
|
|
from test_bot import (CHANNEL, USER, FakeChannel, FakeInteraction,
|
|
FakeMessage, FakeUser, allowed, bridge)
|
|
|
|
__all__ = ["allowed", "bridge"]
|
|
|
|
|
|
async def slash(bridge, name, ch=None, **opts):
|
|
"""Ruleaza o comanda slash pe un canal, ca in Discord."""
|
|
ch = ch or FakeChannel()
|
|
it = FakeInteraction(channel=ch)
|
|
label = await bridge.handle_slash(it, name, **opts)
|
|
return label, ch, it
|
|
|
|
|
|
async def prompt(bridge, text, ch=None):
|
|
"""Un mesaj obisnuit: pleaca la Claude ca prompt."""
|
|
ch = ch or FakeChannel()
|
|
label = await bridge.handle_message(FakeMessage(text, channel=ch))
|
|
return label, ch
|
|
|
|
|
|
# ------------------------------------------------- optiuni slash -> argumente
|
|
def test_slash_args():
|
|
assert bot.slash_args("new", {"fork": True}) == ["--fork"]
|
|
assert bot.slash_args("new", {"fork": False}) == []
|
|
assert bot.slash_args("cleanup", {"force": True}) == ["--force"]
|
|
assert bot.slash_args("cd", {"cale": " /workspace/romfastsql "}) == ["/workspace/romfastsql"]
|
|
assert bot.slash_args("model", {"model": "opus"}) == ["opus"]
|
|
assert bot.slash_args("status", {}) == []
|
|
|
|
|
|
def test_prefixul_vechi_nu_mai_e_parsat():
|
|
"""`parse_command` a disparut: `!` nu mai executa nimic."""
|
|
assert not hasattr(bot, "parse_command")
|
|
assert bot.legacy_prefix_hint("!cd /x").startswith("Comenzile sunt acum slash")
|
|
assert bot.legacy_prefix_hint("salut") is None
|
|
assert bot.legacy_prefix_hint("!") is None
|
|
assert bot.legacy_prefix_hint("!ceva-inexistent") is None
|
|
|
|
|
|
async def test_comanda_necunoscuta_e_refuzata_efemer(bridge):
|
|
label, ch, it = await slash(bridge, "inexistenta")
|
|
assert label == "unknown-command"
|
|
assert ch.sent == []
|
|
assert "nu exista" in it.ephemeral
|
|
|
|
|
|
async def test_orice_comanda_confirma_in_3_secunde(bridge):
|
|
"""Fara `defer()`, Discord marcheaza interactiunea ca esuata."""
|
|
for name in bot.COMMANDS:
|
|
_, _, it = await slash(bridge, name, cale="/tmp", model="sonnet")
|
|
assert it.response.deferred == 1, name
|
|
|
|
|
|
async def test_help(bridge):
|
|
label, ch, _ = await slash(bridge, "help")
|
|
assert label == "cmd:help"
|
|
for c in ("/new", "/cd", "/model", "/status", "/stop", "/cleanup"):
|
|
assert c in ch.all_text
|
|
assert "!" not in ch.all_text
|
|
|
|
|
|
# ---------------------------------------------------------------------- /new
|
|
async def test_new_sterge_sesiunea(bridge, store):
|
|
ch = FakeChannel()
|
|
await prompt(bridge, "salut", ch) # creeaza sesiunea
|
|
assert store.thread(str(ch.id))["sid"] == "sid-fake-0001"
|
|
label, _, _ = await slash(bridge, "new", ch=ch, fork=False)
|
|
assert label == "cmd:new"
|
|
assert store.thread(str(ch.id))["sid"] is None
|
|
assert bridge.runner.procs[str(ch.id)].alive is False
|
|
|
|
|
|
async def test_new_fork_pastreaza_contextul(bridge, store):
|
|
ch = FakeChannel()
|
|
await prompt(bridge, "salut", ch)
|
|
await slash(bridge, "new", ch=ch, fork=True)
|
|
assert store.thread(str(ch.id))["sid"] == "sid-fake-0001"
|
|
assert "fork" in ch.all_text
|
|
assert bridge.runner.procs[str(ch.id)].sid == "sid-fake-0001"
|
|
|
|
|
|
# ----------------------------------------------------------------------- /cd
|
|
async def test_cd_oriunde_in_workspace(bridge, store, tmp_path):
|
|
target = tmp_path / "proiect-nou"
|
|
target.mkdir()
|
|
label, ch, _ = await slash(bridge, "cd", cale=str(target))
|
|
assert label == "cmd:cd"
|
|
assert store.thread(str(ch.id))["cwd"] == str(target)
|
|
assert str(target) in ch.all_text
|
|
|
|
|
|
async def test_cd_refuza_ce_nu_e_director(bridge, store):
|
|
_, ch, _ = await slash(bridge, "cd", cale="/nu/exista/nicaieri")
|
|
assert "nu e un director" in ch.all_text
|
|
assert store.thread(str(ch.id))["cwd"] != "/nu/exista/nicaieri"
|
|
|
|
|
|
async def test_cd_fara_argument(bridge):
|
|
_, ch, _ = await slash(bridge, "cd", cale="")
|
|
assert "Foloseste" in ch.all_text
|
|
|
|
|
|
async def test_cd_se_aplica_procesului_urmator(bridge, tmp_path):
|
|
ch = FakeChannel()
|
|
await prompt(bridge, "salut", ch)
|
|
target = tmp_path / "alt-proiect"
|
|
target.mkdir()
|
|
await slash(bridge, "cd", ch=ch, cale=str(target))
|
|
assert bridge.runner.procs[str(ch.id)].cwd == str(target)
|
|
assert bridge.runner.procs[str(ch.id)].alive is False # se reia cu --resume
|
|
|
|
|
|
# -------------------------------------------------------------------- /model
|
|
async def test_model_implicit_e_sonnet(bridge, store):
|
|
_, ch = await prompt(bridge, "salut")
|
|
assert store.thread(str(ch.id))["model"] == "sonnet"
|
|
assert "sonnet" in ch.all_text
|
|
|
|
|
|
async def test_model_opus_per_fir(bridge, store):
|
|
ch = FakeChannel()
|
|
label, _, _ = await slash(bridge, "model", ch=ch, model="opus")
|
|
assert label == "cmd:model"
|
|
assert store.thread(str(ch.id))["model"] == "opus"
|
|
# firul vecin (alt thread, acelasi canal-parinte) ramane pe sonnet
|
|
ch2 = FakeChannel("201", parent_id=CHANNEL)
|
|
await slash(bridge, "status", ch=ch2)
|
|
assert "model : sonnet" in ch2.all_text
|
|
|
|
|
|
async def test_modelul_ales_ajunge_in_subsol(bridge):
|
|
ch = FakeChannel()
|
|
await slash(bridge, "model", ch=ch, model="opus")
|
|
await prompt(bridge, "salut", ch)
|
|
assert "opus" in ch.sent[-1].content
|
|
|
|
|
|
async def test_model_necunoscut_refuzat(bridge, store):
|
|
"""Discord filtreaza prin Choice, dar centura ramane si in cod."""
|
|
ch = FakeChannel()
|
|
await slash(bridge, "model", ch=ch, model="gpt")
|
|
assert "necunoscut" in ch.all_text
|
|
assert store.thread(str(ch.id))["model"] == "sonnet"
|
|
|
|
|
|
async def test_model_fara_argument_arata_curentul(bridge):
|
|
_, ch, _ = await slash(bridge, "model", model="")
|
|
assert "Model curent" in ch.all_text
|
|
|
|
|
|
# ------------------------------------------------------------------- /status
|
|
async def test_status_arata_campurile_cerute(bridge):
|
|
ch = FakeChannel()
|
|
await prompt(bridge, "salut", ch)
|
|
await slash(bridge, "status", ch=ch)
|
|
text = ch.sent[-1].content
|
|
for camp in ("fir", "sesiune", "director", "model", "proces", "tur in zbor",
|
|
"in coada", "cost fir", "cost azi"):
|
|
assert camp in text
|
|
assert "sid-fake-0001" in text
|
|
assert "$0.0123" in text
|
|
|
|
|
|
async def test_status_arata_stderr(bridge, monkeypatch):
|
|
ch = FakeChannel()
|
|
monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", "crash")
|
|
await prompt(bridge, "salut", ch) # esueaza si lasa stderr in buffer
|
|
proc = bridge.runner.procs[str(ch.id)]
|
|
proc.stderr_buf.append("fake_claude: boom")
|
|
await slash(bridge, "status", ch=ch)
|
|
assert "boom" in ch.sent[-1].content
|
|
|
|
|
|
async def test_status_pe_fir_fara_proces(bridge):
|
|
_, ch, _ = await slash(bridge, "status")
|
|
assert "proces : oprit" in ch.all_text
|
|
assert "(noua)" in ch.all_text
|
|
|
|
|
|
# --------------------------------------------------------------------- /stop
|
|
async def test_stop_opreste_procesul(bridge, store):
|
|
ch = FakeChannel()
|
|
await prompt(bridge, "salut", ch)
|
|
proc = bridge.runner.procs[str(ch.id)]
|
|
assert proc.alive is True # procesul ramane viu intre tururi
|
|
await slash(bridge, "stop", ch=ch)
|
|
assert "oprit" in ch.all_text
|
|
assert proc.alive is False
|
|
|
|
|
|
async def test_stop_fara_proces(bridge):
|
|
_, ch, _ = await slash(bridge, "stop")
|
|
assert "Nu ruleaza nimic" in ch.all_text
|
|
|
|
|
|
async def test_stop_curata_inflight(bridge, store):
|
|
ch = FakeChannel()
|
|
store.set_inflight(str(ch.id), "t1", USER, "m1")
|
|
await slash(bridge, "stop", ch=ch)
|
|
assert store.is_inflight(str(ch.id)) is False
|
|
|
|
|
|
# ------------------------------------------------------------------ /cleanup
|
|
async def test_cleanup_deleaga_in_lane_c(bridge, monkeypatch):
|
|
calls = {}
|
|
|
|
class FakeCleanup:
|
|
@staticmethod
|
|
def find_orphans(state):
|
|
calls["state"] = state
|
|
return [{"pid": 1234, "cmdline": "claude -p", "age_s": 10, "rss_mb": 406.0}]
|
|
|
|
@staticmethod
|
|
def kill_orphans(orphans, dry_run=True):
|
|
calls["dry_run"] = dry_run
|
|
return [{"pid": 1234, "action": "dry-run", "detail": "s-ar trimite SIGTERM"}]
|
|
|
|
@staticmethod
|
|
def format_report(orphans, results=None):
|
|
calls["results"] = results
|
|
return f"{len(orphans)} orfani"
|
|
|
|
monkeypatch.setattr(bot, "cleanup", FakeCleanup)
|
|
_, ch, _ = await slash(bridge, "cleanup", force=False)
|
|
assert "1 orfani" in ch.all_text
|
|
assert calls["dry_run"] is True and calls["results"] is None
|
|
assert "threads" in calls["state"]
|
|
|
|
_, ch2, _ = await slash(bridge, "cleanup", force=True)
|
|
assert calls["dry_run"] is False and calls["results"] is not None
|
|
|
|
|
|
async def test_cleanup_care_crapa_nu_doboara_botul(bridge, monkeypatch):
|
|
class Boom:
|
|
@staticmethod
|
|
def find_orphans(state):
|
|
raise RuntimeError("proc ilizibil")
|
|
|
|
monkeypatch.setattr(bot, "cleanup", Boom)
|
|
_, ch, _ = await slash(bridge, "cleanup")
|
|
assert "a esuat" in ch.all_text
|
|
|
|
|
|
async def test_cleanup_real_ruleaza_in_dry_run(bridge):
|
|
"""Modulul real al lui Lane C: nu omoara nimic implicit."""
|
|
pytest.importorskip("cleanup")
|
|
_, ch, _ = await slash(bridge, "cleanup")
|
|
assert ch.all_text.strip() != ""
|
|
|
|
|
|
# ------------------------------------------------ declararea comenzilor slash
|
|
def test_toate_comenzile_au_handler(bridge):
|
|
for name in bot.COMMANDS:
|
|
assert callable(getattr(bridge, f"cmd_{name}", None)), name
|
|
|
|
|
|
def test_arborele_declara_exact_comenzile_puntii(bridge):
|
|
"""Cu discord.py instalat: semnaturile chiar ajung in arbore."""
|
|
pytest.importorskip("discord")
|
|
import commands_slash
|
|
|
|
tree = commands_slash.build_tree(_FakeClient(), bridge)
|
|
cmds = {c.name: c for c in tree.get_commands()}
|
|
assert set(cmds) == set(bot.COMMANDS)
|
|
assert {p.name for p in cmds["new"].parameters} == {"fork"}
|
|
assert {p.name for p in cmds["cd"].parameters} == {"cale"}
|
|
assert {p.name for p in cmds["cleanup"].parameters} == {"force"}
|
|
assert cmds["cd"].parameters[0].required is True
|
|
assert cmds["new"].parameters[0].required is False
|
|
choices = [c.value for c in cmds["model"].parameters[0].choices]
|
|
assert choices == ["sonnet", "opus"]
|
|
|
|
|
|
class _FakeState:
|
|
_command_tree = None
|
|
|
|
|
|
class _FakeClient:
|
|
"""`CommandTree` cere doar http + _connection; nu se deschide nicio conexiune."""
|
|
|
|
application_id = 42
|
|
http = None
|
|
|
|
def __init__(self):
|
|
self._connection = _FakeState()
|
|
|
|
|
|
class _FakeTree:
|
|
"""Arbore fals pentru testarea sync-ului, fara retea."""
|
|
|
|
def __init__(self, client=None, fail=None):
|
|
self.client = client or _FakeClient()
|
|
self.fail = fail
|
|
self.synced: list[int] = []
|
|
self.copied: list[int] = []
|
|
|
|
def copy_global_to(self, guild):
|
|
self.copied.append(guild.id)
|
|
|
|
async def sync(self, guild=None):
|
|
if self.fail is not None:
|
|
raise self.fail
|
|
self.synced.append(guild.id)
|
|
return ["/new", "/cd"]
|
|
|
|
|
|
# ------------------------------------------------- inregistrarea in Discord
|
|
async def test_sync_se_face_pe_guild(bridge):
|
|
pytest.importorskip("discord")
|
|
import commands_slash
|
|
|
|
tree = _FakeTree()
|
|
out = await commands_slash.sync_guilds(tree, ["100", "101"])
|
|
assert tree.synced == [100, 101] and tree.copied == [100, 101]
|
|
assert out == {"100": "ok (2 comenzi)", "101": "ok (2 comenzi)"}
|
|
|
|
|
|
async def test_sync_esuat_nu_doboara_botul_si_spune_linkul(bridge, caplog):
|
|
pytest.importorskip("discord")
|
|
import commands_slash
|
|
|
|
tree = _FakeTree(fail=RuntimeError("403 Forbidden (Missing Access)"))
|
|
with caplog.at_level("ERROR"):
|
|
out = await commands_slash.sync_guilds(tree, ["100"])
|
|
assert out["100"].startswith("esuat")
|
|
assert "applications.commands" in caplog.text
|
|
assert "client_id=42" in caplog.text
|
|
|
|
|
|
async def test_sync_fara_arbore_sau_fara_guilduri_nu_arunca():
|
|
import commands_slash
|
|
|
|
assert await commands_slash.sync_guilds(None, ["100"]) == {}
|
|
assert await commands_slash.sync_guilds(_FakeTree(), []) == {}
|
|
|
|
|
|
# --------------------------------------------------------------------- /infra
|
|
import infra_actions # noqa: E402
|
|
|
|
|
|
def _st(**over) -> dict:
|
|
base = {
|
|
"quorum": {"quorate": True, "votes": 3, "expected": 3},
|
|
"guests": {"running": 2, "total": 3},
|
|
"ha": [], "replication": {"ok": 0, "total": 0, "last": 0, "failed": []},
|
|
"ups": None, "backup": None, "ssl": None, "vm109": None,
|
|
"dr_failover_active": None, "maint": 0, "jobs": [], "errors": {},
|
|
"nodes": [{"name": "pvemini", "ip": "10.0.20.201", "online": True,
|
|
"cpu": 0.0, "mem": 0, "maxmem": 0, "uptime": 0, "guests": 2}],
|
|
}
|
|
base.update(over)
|
|
return base
|
|
|
|
|
|
def test_slash_args_infra():
|
|
assert bot.slash_args("infra", {"actiune": "oprire", "simulare": None}) == ["oprire"]
|
|
assert bot.slash_args("infra", {"actiune": "oprire", "simulare": False}) == ["oprire", "real"]
|
|
assert bot.slash_args("infra", {"actiune": "oprire", "simulare": True}) == ["oprire", "simulare"]
|
|
|
|
|
|
async def test_infra_actiune_necunoscuta_fara_subprocess(bridge, monkeypatch):
|
|
import subprocess
|
|
|
|
monkeypatch.setattr(subprocess, "run", lambda *a, **k: pytest.fail("nu trebuia sa ruleze nimic"))
|
|
_, ch, _ = await slash(bridge, "infra", actiune="nu-exista", simulare=None)
|
|
assert "necunoscuta" in ch.all_text
|
|
|
|
|
|
async def test_infra_fara_actiune(bridge):
|
|
_, ch, _ = await slash(bridge, "infra", actiune="", simulare=None)
|
|
assert "necunoscuta" in ch.all_text or "Foloseste" in ch.all_text
|
|
|
|
|
|
async def test_infra_stare(bridge, monkeypatch):
|
|
st = _st(ups={"status": "OL", "charge": 100.0, "runtime_s": 1200})
|
|
monkeypatch.setattr(infra_actions, "status", lambda fresh=False: st)
|
|
_, ch, _ = await slash(bridge, "infra", actiune="stare", simulare=None)
|
|
text = ch.all_text
|
|
assert "cvorum" in text and "3/3" in text and "guest-uri" in text and "pvemini" in text
|
|
assert "UPS" in text and "OL" in text
|
|
|
|
|
|
async def test_infra_sarcini_fara_joburi(bridge, monkeypatch):
|
|
monkeypatch.setattr(infra_actions, "status", lambda fresh=False: _st(jobs=[]))
|
|
_, ch, _ = await slash(bridge, "infra", actiune="sarcini", simulare=None)
|
|
assert "Niciun job activ" in ch.all_text
|
|
|
|
|
|
async def test_infra_sarcini_cu_job_activ(bridge, monkeypatch):
|
|
jobs = [{"id": "oprire", "host": "pvemini", "active": True, "log": "/var/log/x.log"}]
|
|
monkeypatch.setattr(infra_actions, "status", lambda fresh=False: _st(jobs=jobs))
|
|
monkeypatch.setattr(infra_actions, "log_tail", lambda aid, n=60: {"file": "/x", "lines": ["linia 1"]})
|
|
_, ch, _ = await slash(bridge, "infra", actiune="sarcini", simulare=None)
|
|
assert "oprire" in ch.all_text and "linia 1" in ch.all_text
|
|
|
|
|
|
async def test_infra_precondition_esuata(bridge, monkeypatch):
|
|
monkeypatch.setattr(infra_actions, "status", lambda fresh=False: _st())
|
|
monkeypatch.setattr(infra_actions, "check", lambda aid, st, dry: (False, "motiv de test"))
|
|
apelat = []
|
|
monkeypatch.setattr(infra_actions, "run", lambda *a, **k: apelat.append(a) or {"ok": True, "code": 200})
|
|
_, ch, _ = await slash(bridge, "infra", actiune="wol", simulare=None)
|
|
assert "⛔" in ch.all_text and "motiv de test" in ch.all_text
|
|
assert apelat == []
|
|
|
|
|
|
async def test_infra_fara_confirmare_ruleaza_direct(bridge, monkeypatch):
|
|
"""`wol` nu cere confirmare: ruleaza imediat daca precondiția trece."""
|
|
monkeypatch.setattr(infra_actions, "status", lambda fresh=False: _st())
|
|
monkeypatch.setattr(infra_actions, "check", lambda aid, st, dry: (True, ""))
|
|
apelat = []
|
|
|
|
def _run(aid, dry):
|
|
apelat.append((aid, dry))
|
|
return {"ok": True, "code": 200, "host": "oracle-prod-admin"}
|
|
|
|
monkeypatch.setattr(infra_actions, "run", _run)
|
|
_, ch, _ = await slash(bridge, "infra", actiune="wol", simulare=None)
|
|
assert apelat == [("wol", False)] # "wol" nu are simulare -> dry mereu False
|
|
assert "pornit" in ch.all_text
|
|
|
|
|
|
async def test_infra_cu_confirmare_arata_intrebarea_si_nu_ruleaza(bridge, monkeypatch):
|
|
monkeypatch.setattr(infra_actions, "status", lambda fresh=False: _st())
|
|
monkeypatch.setattr(infra_actions, "check", lambda aid, st, dry: (True, ""))
|
|
apelat = []
|
|
monkeypatch.setattr(infra_actions, "run", lambda *a, **k: apelat.append(a) or {"ok": True, "code": 200})
|
|
_, ch, _ = await slash(bridge, "infra", actiune="oprire", simulare=False) # "real" -> dry=False
|
|
assert "confirmi" in ch.all_text.lower()
|
|
assert apelat == [] # butonul nu a fost apasat, comanda nu a rulat nimic
|
|
|
|
|
|
async def test_infra_actiune_de_simulare_are_dry_true(bridge, monkeypatch):
|
|
vazute = []
|
|
monkeypatch.setattr(infra_actions, "status", lambda fresh=False: _st())
|
|
|
|
def _check(aid, st, dry):
|
|
vazute.append(dry)
|
|
return (True, "")
|
|
|
|
monkeypatch.setattr(infra_actions, "check", _check)
|
|
monkeypatch.setattr(infra_actions, "run", lambda *a, **k: {"ok": True, "code": 200})
|
|
await slash(bridge, "infra", actiune="oprire", simulare=None) # implicit: simulare
|
|
await slash(bridge, "infra", actiune="oprire", simulare=False) # explicit: real
|
|
assert vazute == [True, False]
|
|
|
|
|
|
def test_infra_view_e_none_fara_discordpy(bridge):
|
|
if bot.discord is None:
|
|
assert bridge.infra_view("oprire", True) is None
|
|
assert bridge.infra_boot_view() is None
|
|
|
|
|
|
async def test_infra_boot_check_anunta_mentenanta_apoi_se_opreste(bridge, monkeypatch):
|
|
ch = FakeChannel()
|
|
monkeypatch.setenv("DISCORD_CHANNEL_IDS", str(ch.id))
|
|
bridge.get_channel = lambda cid: ch if str(cid) == str(ch.id) else None
|
|
|
|
st = _st(maint=1)
|
|
monkeypatch.setattr(infra_actions, "status", lambda fresh=False: st)
|
|
monkeypatch.setattr(infra_actions, "maintenance_pending", lambda s: True)
|
|
|
|
await bridge.infra_boot_check()
|
|
assert "guest-uri de pornit" in ch.all_text
|
|
|
|
|
|
async def test_infra_boot_check_iese_daca_nu_e_in_mentenanta(bridge, monkeypatch):
|
|
st = _st(maint=0)
|
|
monkeypatch.setattr(infra_actions, "status", lambda fresh=False: st)
|
|
apelat = []
|
|
monkeypatch.setattr(infra_actions, "maintenance_pending", lambda s: apelat.append(s) or False)
|
|
await bridge.infra_boot_check()
|
|
assert apelat == [] # a iesit la prima citire (maint == 0), fara sa mai verifice
|
|
|
|
|
|
async def test_infra_boot_check_nu_doboara_botul_la_eroare(bridge, monkeypatch):
|
|
"""`status()` care arunca nu are voie sa opreasca bucla sau botul."""
|
|
|
|
def _boom(fresh=False):
|
|
raise RuntimeError("retea picata")
|
|
|
|
async def _fast_sleep(_):
|
|
return None
|
|
|
|
monkeypatch.setattr(infra_actions, "status", _boom)
|
|
monkeypatch.setattr(asyncio, "sleep", _fast_sleep)
|
|
await bridge.infra_boot_check() # 20 incercari esuate, nici o exceptie in afara
|