mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 18:04:47 +00:00
feat(ci): автоматический тестировщик — CSS-линтер + smoke API + GitHub Actions CI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fe7d08ee39
commit
87b8d0f3d6
22
.claude/commands/test.md
Normal file
22
.claude/commands/test.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Тестировщик — локальный запуск перед коммитом
|
||||||
|
|
||||||
|
Запусти оба теста и выдай сводный отчёт с замечаниями.
|
||||||
|
|
||||||
|
## Шаг 1 — CSS-линтер
|
||||||
|
```bash
|
||||||
|
python tests/lint_css.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Шаг 2 — Smoke-тесты API
|
||||||
|
```bash
|
||||||
|
python tests/smoke_api.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Шаг 3 — Сводный отчёт
|
||||||
|
|
||||||
|
Выведи:
|
||||||
|
- ✅ или ❌ по каждому тесту
|
||||||
|
- Список замечаний к устранению (если есть)
|
||||||
|
- Вывод: **МОЖНО КОММИТИТЬ** или **НЕЛЬЗЯ — исправь замечания**
|
||||||
|
|
||||||
|
Не коммить пока все тесты не зелёные.
|
||||||
51
.github/workflows/ci.yml
vendored
Normal file
51
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
name: CI — Lint + Smoke Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master, main]
|
||||||
|
pull_request:
|
||||||
|
branches: [master, main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-css:
|
||||||
|
name: CSS-линтер
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
- name: Запуск CSS-линтера
|
||||||
|
run: python tests/lint_css.py
|
||||||
|
|
||||||
|
smoke-api:
|
||||||
|
name: Smoke-тесты API
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Запускаем только на пушах в master (не на PR — там нет деплоя)
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||||
|
# Ждём деплоя GitHub Pages (если были изменения в miniapp/)
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
- name: Пауза после деплоя Pages (60s)
|
||||||
|
run: sleep 60
|
||||||
|
- name: Запуск smoke-тестов
|
||||||
|
run: python tests/smoke_api.py --url https://api.wasrusgen1.pro
|
||||||
|
|
||||||
|
report:
|
||||||
|
name: Сводный отчёт
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint-css, smoke-api]
|
||||||
|
if: always()
|
||||||
|
steps:
|
||||||
|
- name: Итог
|
||||||
|
run: |
|
||||||
|
echo "CSS-линтер: ${{ needs.lint-css.result }}"
|
||||||
|
echo "Smoke API: ${{ needs.smoke-api.result }}"
|
||||||
|
if [[ "${{ needs.lint-css.result }}" == "failure" || "${{ needs.smoke-api.result }}" == "failure" ]]; then
|
||||||
|
echo "❌ CI упал — есть замечания к устранению"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ CI прошёл"
|
||||||
182
tests/lint_css.py
Normal file
182
tests/lint_css.py
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
"""
|
||||||
|
CSS-линтер для miniapp/assets/
|
||||||
|
Запуск: python tests/lint_css.py
|
||||||
|
Возвращает exit code 1 если найдены проблемы.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parent.parent / "miniapp" / "assets"
|
||||||
|
|
||||||
|
ISSUES = []
|
||||||
|
|
||||||
|
|
||||||
|
def issue(file: str, line: int, msg: str):
|
||||||
|
ISSUES.append(f" ❌ {file}:{line} {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Правила ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# 1. Запрещённые паттерны скрытия текста
|
||||||
|
FORBIDDEN_COLOR_PATTERNS = [
|
||||||
|
(
|
||||||
|
# Только свойство color:, но НЕ -webkit-tap-highlight-color:
|
||||||
|
r"(?<![a-z-])color\s*:\s*transparent",
|
||||||
|
"color:transparent создаёт дырку — используй opacity:0 или жёсткий HEX",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 2. Классы для которых color:var(--card) запрещён (текст на карточке)
|
||||||
|
TEXT_CLASSES = [
|
||||||
|
r"\.client-name", r"\.client-phone", r"\.client-footer",
|
||||||
|
r"\.client-arrow", r"\.measurement-\w+", r"\.assembly-card-\w+",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 3. Классы у которых ОБЯЗАТЕЛЬНО должен быть явный color:
|
||||||
|
REQUIRED_COLOR_CLASSES = [
|
||||||
|
r"\.client-name", r"\.client-phone",
|
||||||
|
r"\.assembly-card-name", r"\.assembly-card-status",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ─── Анализ файлов ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def lint_file(path: Path):
|
||||||
|
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
lines = text.splitlines()
|
||||||
|
fname = path.name
|
||||||
|
|
||||||
|
# Разбираем CSS-блоки
|
||||||
|
current_selector = ""
|
||||||
|
block_lines = []
|
||||||
|
brace_depth = 0
|
||||||
|
block_start = 0
|
||||||
|
|
||||||
|
for i, line in enumerate(lines, 1):
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
if brace_depth == 0 and stripped and not stripped.startswith("/*"):
|
||||||
|
# Накапливаем селектор (может быть многострочным)
|
||||||
|
if "{" in stripped:
|
||||||
|
current_selector += stripped.split("{")[0].strip()
|
||||||
|
brace_depth = stripped.count("{") - stripped.count("}")
|
||||||
|
block_start = i
|
||||||
|
block_lines = [stripped]
|
||||||
|
else:
|
||||||
|
current_selector += " " + stripped
|
||||||
|
elif brace_depth > 0:
|
||||||
|
brace_depth += stripped.count("{") - stripped.count("}")
|
||||||
|
block_lines.append(stripped)
|
||||||
|
if brace_depth == 0:
|
||||||
|
_lint_block(fname, current_selector, block_lines, block_start, i)
|
||||||
|
current_selector = ""
|
||||||
|
block_lines = []
|
||||||
|
|
||||||
|
# Построчные проверки
|
||||||
|
for pattern, msg in FORBIDDEN_COLOR_PATTERNS:
|
||||||
|
if re.search(pattern, stripped, re.IGNORECASE):
|
||||||
|
issue(fname, i, msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _lint_block(fname, selector, block_lines, start_line, end_line):
|
||||||
|
block_text = " ".join(block_lines)
|
||||||
|
|
||||||
|
# Правило 1: color:var(--card) в текстовых классах
|
||||||
|
if re.search(r"color\s*:\s*var\(--card\)", block_text, re.IGNORECASE):
|
||||||
|
for pat in TEXT_CLASSES:
|
||||||
|
if re.search(pat, selector):
|
||||||
|
issue(
|
||||||
|
fname, start_line,
|
||||||
|
f"`{selector.strip()}` использует color:var(--card) — "
|
||||||
|
f"не совпадает с фоном карточки в тёмных темах. Используй HEX или opacity:0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Правило 2: color:var(--paper) в текстовых классах
|
||||||
|
if re.search(r"color\s*:\s*var\(--paper\)", block_text, re.IGNORECASE):
|
||||||
|
for pat in TEXT_CLASSES:
|
||||||
|
if re.search(pat, selector):
|
||||||
|
issue(
|
||||||
|
fname, start_line,
|
||||||
|
f"`{selector.strip()}` использует color:var(--paper) — "
|
||||||
|
f"может не совпасть с фоном в других темах. Используй HEX",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Правило 3: обязательные классы должны иметь явный color:
|
||||||
|
has_color = bool(re.search(r"\bcolor\s*:", block_text))
|
||||||
|
for pat in REQUIRED_COLOR_CLASSES:
|
||||||
|
if re.search(pat, selector) and not has_color:
|
||||||
|
issue(
|
||||||
|
fname, start_line,
|
||||||
|
f"`{selector.strip()}` не имеет явного color: — "
|
||||||
|
f"текст унаследует цвет от body и может быть невидим или слишком ярким",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Проверка версий в index.html ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def lint_versions():
|
||||||
|
index = Path(__file__).parent.parent / "miniapp" / "index.html"
|
||||||
|
if not index.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
text = index.read_text(encoding="utf-8")
|
||||||
|
versions = {}
|
||||||
|
|
||||||
|
# Собираем версии из href/src
|
||||||
|
for m in re.finditer(r'(?:href|src)="assets/([^"]+)\?v=([^"]+)"', text):
|
||||||
|
fname, ver = m.group(1), m.group(2)
|
||||||
|
versions[fname] = ver
|
||||||
|
|
||||||
|
# Проверяем что CSS и JS версии не слишком старые (> 30 дней назад)
|
||||||
|
from datetime import datetime
|
||||||
|
today_str = datetime.now().strftime("%Y%m%d")
|
||||||
|
for fname, ver in versions.items():
|
||||||
|
if re.match(r"^\d{8}", ver):
|
||||||
|
file_date = ver[:8]
|
||||||
|
# Сравниваем как строки (работает для YYYYMMDD)
|
||||||
|
days_diff = (datetime.strptime(today_str, "%Y%m%d") -
|
||||||
|
datetime.strptime(file_date, "%Y%m%d")).days
|
||||||
|
if days_diff > 30:
|
||||||
|
ISSUES.append(
|
||||||
|
f" ⚠️ index.html: {fname} версия «{ver}» устарела "
|
||||||
|
f"на {days_diff} дней — возможно забыли обновить версию при последнем изменении"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Форматы версии должны совпадать с шаблоном YYYYMMDDx
|
||||||
|
for fname, ver in versions.items():
|
||||||
|
if not re.match(r"^\d{8}[a-z]$", ver):
|
||||||
|
ISSUES.append(
|
||||||
|
f" ⚠️ index.html: {fname} версия «{ver}» не соответствует формату "
|
||||||
|
f"YYYYMMDDx (например 20260517j)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Main ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("🔍 CSS-линтер miniapp/assets/\n")
|
||||||
|
|
||||||
|
css_files = list(ROOT.glob("*.css"))
|
||||||
|
if not css_files:
|
||||||
|
print(" Нет CSS-файлов для анализа.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
for f in sorted(css_files):
|
||||||
|
lint_file(f)
|
||||||
|
|
||||||
|
lint_versions()
|
||||||
|
|
||||||
|
if ISSUES:
|
||||||
|
print("Найдены проблемы:\n")
|
||||||
|
for iss in ISSUES:
|
||||||
|
print(iss)
|
||||||
|
print(f"\n🚫 Итого: {len(ISSUES)} замечание(й). Исправь перед коммитом.\n")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print("✅ Всё чисто — замечаний нет.\n")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
247
tests/smoke_api.py
Normal file
247
tests/smoke_api.py
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
"""
|
||||||
|
Smoke-тесты API на боевом сервере.
|
||||||
|
Тестирует только публичные/анонимные эндпоинты (без initData).
|
||||||
|
Запуск: python tests/smoke_api.py [--url https://api.wasrusgen1.pro]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import argparse
|
||||||
|
import time
|
||||||
|
|
||||||
|
BASE_URL = "https://api.wasrusgen1.pro"
|
||||||
|
|
||||||
|
RESULTS = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str, ok: bool, detail: str = ""):
|
||||||
|
icon = "✅" if ok else "❌"
|
||||||
|
msg = f" {icon} {name}"
|
||||||
|
if detail:
|
||||||
|
msg += f" ({detail})"
|
||||||
|
RESULTS.append((ok, msg))
|
||||||
|
print(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def get(path: str, timeout=10):
|
||||||
|
url = f"{BASE_URL}{path}"
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "zov-smoke/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
body = r.read().decode()
|
||||||
|
return r.status, body
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, e.read().decode()
|
||||||
|
except Exception as e:
|
||||||
|
return None, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def post(path: str, payload: dict, timeout=10):
|
||||||
|
url = f"{BASE_URL}{path}"
|
||||||
|
data = json.dumps(payload).encode()
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, data=data,
|
||||||
|
headers={"Content-Type": "application/json", "User-Agent": "zov-smoke/1.0"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
body = r.read().decode()
|
||||||
|
return r.status, body
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, e.read().decode()
|
||||||
|
except Exception as e:
|
||||||
|
return None, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Тесты ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_healthz():
|
||||||
|
status, body = get("/healthz")
|
||||||
|
check("GET /healthz → 200", status == 200, f"status={status}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_root():
|
||||||
|
status, body = get("/")
|
||||||
|
check("GET / → 200", status == 200, f"status={status}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_no_auth():
|
||||||
|
"""Без initData должен вернуть ошибку аутентификации, но не 500."""
|
||||||
|
status, body = post("/api/me", {"initData": "", "role": "manager"})
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
has_error_field = "error" in data
|
||||||
|
except Exception:
|
||||||
|
has_error_field = False
|
||||||
|
check(
|
||||||
|
"POST /api/me без initData → ошибка аутентификации (не 500)",
|
||||||
|
status in (200, 400, 403) and has_error_field,
|
||||||
|
f"status={status} error={data.get('error', '?') if has_error_field else body[:60]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clients_no_auth():
|
||||||
|
status, body = post("/api/clients", {"initData": ""})
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
ok = "error" in data and status != 500
|
||||||
|
except Exception:
|
||||||
|
ok = False
|
||||||
|
check("POST /api/clients без initData → auth-ошибка (не 500)", ok, f"status={status}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_assembly_list_no_auth():
|
||||||
|
status, body = post("/api/assembly_list", {"initData": ""})
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
ok = "error" in data and status != 500
|
||||||
|
except Exception:
|
||||||
|
ok = False
|
||||||
|
check("POST /api/assembly_list без initData → auth-ошибка (не 500)", ok, f"status={status}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_measurement_request_no_auth():
|
||||||
|
status, body = post("/api/measurement_request", {
|
||||||
|
"initData": "",
|
||||||
|
"client_name": "Тест",
|
||||||
|
"client_phone": "79001234567",
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
ok = "error" in data and status != 500
|
||||||
|
except Exception:
|
||||||
|
ok = False
|
||||||
|
check("POST /api/measurement_request без initData → auth-ошибка (не 500)", ok, f"status={status}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_assembly_create_no_auth():
|
||||||
|
status, body = post("/api/assembly_create", {
|
||||||
|
"initData": "",
|
||||||
|
"client_name": "Тест",
|
||||||
|
"address": "Тест",
|
||||||
|
"scope_of_work": "Тест",
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
ok = "error" in data and status != 500
|
||||||
|
except Exception:
|
||||||
|
ok = False
|
||||||
|
check("POST /api/assembly_create без initData → auth-ошибка (не 500)", ok, f"status={status}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_proposal_list_no_auth():
|
||||||
|
status, body = post("/api/proposal_list", {"initData": ""})
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
ok = "error" in data and status != 500
|
||||||
|
except Exception:
|
||||||
|
ok = False
|
||||||
|
check("POST /api/proposal_list без initData → auth-ошибка (не 500)", ok, f"status={status}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_staff_list_no_auth():
|
||||||
|
status, body = post("/api/staff_list", {"initData": "", "role": "measurer"})
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
# staff_list может вернуть пустой список без аутентификации — это ок
|
||||||
|
ok = status != 500
|
||||||
|
except Exception:
|
||||||
|
ok = False
|
||||||
|
check("POST /api/staff_list → не 500", ok, f"status={status}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_photo_missing():
|
||||||
|
status, body = get("/api/photo/nonexistent_id/nonexistent.jpg")
|
||||||
|
check(
|
||||||
|
"GET /api/photo/несуществующий → 404 (не 500)",
|
||||||
|
status == 404,
|
||||||
|
f"status={status}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_github_pages():
|
||||||
|
"""Проверяем что MiniApp доступен на GitHub Pages."""
|
||||||
|
import urllib.request
|
||||||
|
url = "https://wasrusgen.github.io/zov-tech/index.html"
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "zov-smoke/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as r:
|
||||||
|
body = r.read().decode()
|
||||||
|
has_app = 'id="app"' in body
|
||||||
|
check(
|
||||||
|
"GitHub Pages MiniApp доступен",
|
||||||
|
r.status == 200 and has_app,
|
||||||
|
f"status={r.status} has_app={has_app}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
check("GitHub Pages MiniApp доступен", False, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def test_miniapp_css_version():
|
||||||
|
"""CSS в index.html имеет версию (не закешируется по-старому)."""
|
||||||
|
import urllib.request
|
||||||
|
import re
|
||||||
|
url = "https://wasrusgen.github.io/zov-tech/index.html"
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "zov-smoke/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as r:
|
||||||
|
body = r.read().decode()
|
||||||
|
has_version = bool(re.search(r'styles\.css\?v=\d{8}[a-z]', body))
|
||||||
|
check(
|
||||||
|
"index.html: styles.css имеет версию вида ?v=YYYYMMDDx",
|
||||||
|
has_version,
|
||||||
|
f"found={has_version}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
check("index.html: проверка версии", False, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Main ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global BASE_URL
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--url", default=BASE_URL, help="Base URL бэкенда")
|
||||||
|
args = parser.parse_args()
|
||||||
|
BASE_URL = args.url.rstrip("/")
|
||||||
|
|
||||||
|
print(f"🔥 Smoke-тесты → {BASE_URL}\n")
|
||||||
|
t0 = time.time()
|
||||||
|
|
||||||
|
test_healthz()
|
||||||
|
test_root()
|
||||||
|
test_me_no_auth()
|
||||||
|
test_clients_no_auth()
|
||||||
|
test_assembly_list_no_auth()
|
||||||
|
test_measurement_request_no_auth()
|
||||||
|
test_assembly_create_no_auth()
|
||||||
|
test_proposal_list_no_auth()
|
||||||
|
test_staff_list_no_auth()
|
||||||
|
test_photo_missing()
|
||||||
|
test_github_pages()
|
||||||
|
test_miniapp_css_version()
|
||||||
|
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
passed = sum(1 for ok, _ in RESULTS if ok)
|
||||||
|
failed = len(RESULTS) - passed
|
||||||
|
|
||||||
|
print(f"\n{'─'*50}")
|
||||||
|
print(f" Итого: {passed} пройдено / {failed} упало ({elapsed:.1f}s)")
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
print(f"\n🚫 Замечания к устранению:")
|
||||||
|
for ok, msg in RESULTS:
|
||||||
|
if not ok:
|
||||||
|
print(msg)
|
||||||
|
print()
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print("\n✅ Все тесты прошли.\n")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue
Block a user