From db407595d61dd5743ace74234939575fc961fbda Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 9 Aug 2026 04:09:01 +0300 Subject: [PATCH] interpret: russian title/detail/advice alongside english findings --- src/netcheck/interpret.py | 48 ++++++++++++++++-- tests/test_interpret.py | 103 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 3 deletions(-) diff --git a/src/netcheck/interpret.py b/src/netcheck/interpret.py index 53daa2b..4f8d5b0 100644 --- a/src/netcheck/interpret.py +++ b/src/netcheck/interpret.py @@ -54,6 +54,9 @@ def latency_findings(pings: list[PingResult], t: Thresholds) -> list[Finding]: value=0, threshold=1, advice="If every reference host is unreachable the link is down or filtering the probe method.", + title_ru=f"{p.host} не отвечает", + detail_ru=f"{p.sent} проб отправлено через {p.method}, ни одна не вернулась.", + advice_ru="Если все эталонные хосты недоступны, значит канал не работает или фильтрует метод пробы.", ) ) continue @@ -69,6 +72,9 @@ def latency_findings(pings: list[PingResult], t: Thresholds) -> list[Finding]: value=p.avg_ms, threshold=t.latency_ms.warn, advice="Check for a saturated uplink, a distant route, or a congested Wi-Fi link.", + title_ru=f"Задержка до {p.host} выше целевой", + detail_ru=f"Средняя {p.avg_ms} мс за {p.received} проб через {p.method}.", + advice_ru="Проверьте, не насыщен ли аплинк, не слишком ли далёкий маршрут или не перегружен ли Wi-Fi.", ) ) severity = severity_for(p.jitter_ms, t.jitter_ms) @@ -83,11 +89,15 @@ def latency_findings(pings: list[PingResult], t: Thresholds) -> list[Finding]: value=p.jitter_ms, threshold=t.jitter_ms.warn, advice="Unstable latency hurts calls and games more than raw latency does.", + title_ru=f"Джиттер до {p.host} выше целевого", + detail_ru=f"Джиттер {p.jitter_ms} мс за {p.received} проб через {p.method}.", + advice_ru="Нестабильная задержка вредит звонкам и играм сильнее, чем сама по себе высокая задержка.", ) ) severity = severity_for(p.loss_pct, t.loss_pct) if severity not in ("ok", "info"): kind = "connection failures" if p.method == "tcp" else "packet loss" + kind_ru = "неудачных подключений" if p.method == "tcp" else "потерь пакетов" findings.append( Finding( id=f"latency.loss.{p.label}", @@ -98,6 +108,9 @@ def latency_findings(pings: list[PingResult], t: Thresholds) -> list[Finding]: value=p.loss_pct, threshold=t.loss_pct.warn, advice="Sustained loss on every host points at the local link or the first upstream hop.", + title_ru=f"Потери до {p.host}", + detail_ru=f"{p.loss_pct}% {kind_ru} за {p.sent} проб через {p.method}.", + advice_ru="Устойчивые потери на всех хостах указывают на локальный канал или первый аплинк.", ) ) return findings @@ -112,6 +125,9 @@ def path_findings(trace: TraceResult) -> list[Finding]: title="No path data", detail=f"The traceroute to {trace.target} returned no hops (backend {trace.backend}).", advice="ICMP may be filtered end to end; try --tcp-trace.", + title_ru="Нет данных о маршруте", + detail_ru=f"Трассировка до {trace.target} не вернула ни одного хопа (бэкенд {trace.backend}).", + advice_ru="ICMP может быть отфильтрован на всём пути; попробуйте --tcp-trace.", ) ] findings: list[Finding] = [] @@ -130,6 +146,9 @@ def path_findings(trace: TraceResult) -> list[Finding]: value=hop.loss_pct, threshold=20.0, advice="This hop and everything after it share the problem; the hop before it is the last clean one.", + title_ru="Устойчивые потери начинаются в середине маршрута", + detail_ru=f"Потери появляются на хопе {hop.ttl} ({hop.ip or 'нет ответа'}) на уровне {hop.loss_pct}% и продолжаются дальше.", + advice_ru="Этот хоп и всё, что после него, делят одну проблему; хоп перед ним — последний чистый.", ) ) break @@ -141,6 +160,9 @@ def path_findings(trace: TraceResult) -> list[Finding]: title="Path did not reach the target", detail=f"The trace to {trace.target} stopped at hop {trace.hops[-1].ttl}.", advice="Many networks drop the final ICMP reply; this alone is not a fault.", + title_ru="Маршрут не достиг цели", + detail_ru=f"Трассировка до {trace.target} остановилась на хопе {trace.hops[-1].ttl}.", + advice_ru="Многие сети отбрасывают финальный ответ ICMP; само по себе это не является неисправностью.", ) ) return findings @@ -256,6 +278,15 @@ _CONSEQUENCES = { "F": "A single download makes a call unusable; this is queue bloat, not a slow line.", "?": "Not measured — the speedtest tier that measures it did not run.", } +_CONSEQUENCES_RU = { + "A": "Видеозвонки и игры остаются плавными, даже когда канал полностью загружен.", + "B": "Почти незаметно; большая отдача может слегка споткнуть видеозвонок.", + "C": "Звонки и игры начинают дёргаться, как только что-то ещё скачивается.", + "D": "Любая крупная передача заставляет звонки заикаться, а страницы — зависать.", + "E": "Соединение кажется сломанным, пока оно занято, хотя пропускная способность в порядке.", + "F": "Одна закачка делает звонок непригодным; это раздутие очереди, а не медленный канал.", + "?": "Не измерено — уровень speedtest-каскада, который это измеряет, не запускался.", +} _GRADE_SEVERITY = {"A": "ok", "B": "ok", "C": "warn", "D": "crit", "E": "crit", "F": "crit", "?": "info"} @@ -269,8 +300,9 @@ def grade_bufferbloat(delta_ms: float | None, bands: BufferbloatBands) -> str: return "F" -def bufferbloat_consequence(grade: str) -> str: - return _CONSEQUENCES.get(grade, _CONSEQUENCES["?"]) +def bufferbloat_consequence(grade: str, lang: str = "en") -> str: + table = _CONSEQUENCES_RU if lang == "ru" else _CONSEQUENCES + return table.get(grade, table["?"]) def speed_findings(speed: SpeedResult, bands: BufferbloatBands) -> list[Finding]: @@ -283,10 +315,16 @@ def speed_findings(speed: SpeedResult, bands: BufferbloatBands) -> list[Finding] title="No bandwidth measurement", detail=f"Every speedtest tier failed or was disabled (tried: {tried}).", advice="Install the Ookla speedtest binary, or rerun without --quick.", + title_ru="Нет замера скорости", + detail_ru=f"Все уровни speedtest-каскада завершились неудачей или были отключены (пробовали: {tried}).", + advice_ru="Установите бинарник Ookla speedtest или перезапустите без --quick.", ) ] findings: list[Finding] = [] - for direction, delta in (("down", speed.bufferbloat_down_ms), ("up", speed.bufferbloat_up_ms)): + for direction, direction_ru, delta in ( + ("down", "приём", speed.bufferbloat_down_ms), + ("up", "передача", speed.bufferbloat_up_ms), + ): grade = grade_bufferbloat(delta, bands) severity = _GRADE_SEVERITY[grade] if severity in ("ok", "info"): @@ -302,6 +340,10 @@ def speed_findings(speed: SpeedResult, bands: BufferbloatBands) -> list[Finding] threshold=bands.c, advice=bufferbloat_consequence(grade) + " Enabling SQM/fq_codel on the router is the standard fix.", + title_ru=f"Bufferbloat под нагрузкой ({direction_ru}): оценка {grade}", + detail_ru=f"Задержка выросла на {delta} мс при насыщении канала на {direction_ru}.", + advice_ru=bufferbloat_consequence(grade, lang="ru") + + " Включение SQM/fq_codel на роутере — стандартное решение.", ) ) return findings diff --git a/tests/test_interpret.py b/tests/test_interpret.py index 516d913..3c798b6 100644 --- a/tests/test_interpret.py +++ b/tests/test_interpret.py @@ -391,3 +391,106 @@ def test_overall_score_never_goes_below_zero(): status, score, _ = overall_verdict(findings) assert status == "crit" assert score == 0 + + +_CYRILLIC = set("абвгдеёжзийклмнопрстуфхцчшщъыьэюя") + + +def has_cyrillic(text: str) -> bool: + return any(ch in _CYRILLIC for ch in text.lower()) + + +def test_latency_findings_carry_russian_siblings(): + findings = latency_findings( + [ + ping(received=0, loss_pct=100.0, avg_ms=None, min_ms=None, max_ms=None, jitter_ms=None), + ping(avg_ms=130.0, jitter_ms=25.0, loss_pct=5.0, received=19), + ], + thresholds_fixture(), + ) + assert findings + for f in findings: + assert f.title_ru and has_cyrillic(f.title_ru) + assert f.detail_ru and has_cyrillic(f.detail_ru) + if f.advice: + assert f.advice_ru and has_cyrillic(f.advice_ru) + + +def test_path_findings_carry_russian_siblings_for_every_case(): + no_hops = TraceResult(target="1.1.1.1", resolved_ip="1.1.1.1", backend="icmplib", hops=[]) + findings = path_findings(no_hops) + assert findings[0].title_ru and has_cyrillic(findings[0].title_ru) + assert findings[0].detail_ru and has_cyrillic(findings[0].detail_ru) + assert findings[0].advice_ru and has_cyrillic(findings[0].advice_ru) + + loss_jump_trace = TraceResult( + target="1.1.1.1", + resolved_ip="1.1.1.1", + backend="mtr_json", + hops=[ + TraceHop(ttl=1, ip="192.168.1.1", loss_pct=0.0, avg_ms=1.0), + TraceHop(ttl=2, ip="10.64.0.1", loss_pct=0.0, avg_ms=9.0), + TraceHop(ttl=3, ip="198.51.100.7", loss_pct=60.0, avg_ms=40.0), + TraceHop(ttl=4, ip="1.1.1.1", loss_pct=55.0, avg_ms=41.0), + ], + completed=True, + ) + loss_findings = path_findings(loss_jump_trace) + loss = [f for f in loss_findings if f.id == "path.loss_jump"][0] + assert loss.title_ru and has_cyrillic(loss.title_ru) + assert loss.detail_ru and has_cyrillic(loss.detail_ru) + assert loss.advice_ru and has_cyrillic(loss.advice_ru) + + incomplete_trace = TraceResult( + target="1.1.1.1", + resolved_ip="1.1.1.1", + backend="mtr_json", + hops=[TraceHop(ttl=1, ip="192.168.1.1", loss_pct=0.0, avg_ms=1.0)], + completed=False, + ) + incomplete = path_findings(incomplete_trace) + incomplete_finding = [f for f in incomplete if f.id == "path.incomplete"][0] + assert incomplete_finding.title_ru and has_cyrillic(incomplete_finding.title_ru) + assert incomplete_finding.detail_ru and has_cyrillic(incomplete_finding.detail_ru) + assert incomplete_finding.advice_ru and has_cyrillic(incomplete_finding.advice_ru) + + +def thresholds_fixture() -> Thresholds: + return Thresholds() + + +def test_speed_findings_carry_russian_siblings(): + unavailable = speed_findings(SpeedResult(method="none"), BufferbloatBands()) + assert unavailable[0].title_ru and has_cyrillic(unavailable[0].title_ru) + assert unavailable[0].detail_ru and has_cyrillic(unavailable[0].detail_ru) + assert unavailable[0].advice_ru and has_cyrillic(unavailable[0].advice_ru) + + speed = SpeedResult( + method="cloudflare", + download_mbps=300.0, + upload_mbps=40.0, + bufferbloat_down_ms=150.0, + bufferbloat_up_ms=150.0, + ) + findings = speed_findings(speed, BufferbloatBands()) + assert len(findings) == 2 + for f in findings: + assert f.title_ru and has_cyrillic(f.title_ru) + assert f.detail_ru and has_cyrillic(f.detail_ru) + assert f.advice_ru and has_cyrillic(f.advice_ru) + down = [f for f in findings if f.id == "speed.bufferbloat_down"][0] + up = [f for f in findings if f.id == "speed.bufferbloat_up"][0] + assert "приём" in down.title_ru + assert "передача" in up.title_ru + + +def test_bufferbloat_consequence_russian_covers_every_grade(): + for grade in "ABCDEF?": + text = bufferbloat_consequence(grade, lang="ru") + assert text and has_cyrillic(text) + + +def test_bufferbloat_consequence_default_english_is_unchanged(): + for grade in "ABCDEF?": + assert bufferbloat_consequence(grade) == bufferbloat_consequence(grade, lang="en") + assert "call" in bufferbloat_consequence("F").lower()