From 23bffbd804eabc75ca29e33e40996d75eb8ac989 Mon Sep 17 00:00:00 2001 From: bokiko Date: Tue, 17 Mar 2026 18:25:30 +0000 Subject: [PATCH 1/5] test: add pytest unit test suite for CLI and ping core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the first test suite to the project — 68 pure unit tests covering the two core Python modules (ping_tester.py and cli.py). Tests are network-free and subprocess-free; they run in ~50ms and work offline, making them safe to run in CI before any network access. Coverage includes: - validate_ip: valid/invalid IPv4/IPv6, hostnames, injection strings - calculate_jitter: empty, single-value, zero, alternating, high-jitter cases - get_best_server: empty list, all-timeout, lowest-ping selection, packet-loss-beats-ping ordering, 100% loss exclusion - get_connection_quality: all 5 quality tiers with boundary value checks - sort_results: all 5 sort keys, timeout-last guarantee, unknown-key fallback - filter_by_max_ping: pass-all, block-all, timeout exclusion, boundary edge cases - results_to_json: valid JSON, field correctness, best-only, error case, empty - results_to_csv: parseable output, header row, best-only, multiple rows - Color helpers: no-color mode, format_ping, format_loss - build_parser: defaults, flags, region/sort validation, type coercion Files: desktop/tests/__init__.py, desktop/tests/test_ping_tester.py, desktop/tests/test_cli.py, desktop/pytest.ini --- IMPROVEMENTS.md | 22 +++ desktop/pytest.ini | 5 + desktop/tests/__init__.py | 0 desktop/tests/test_cli.py | 300 ++++++++++++++++++++++++++++++ desktop/tests/test_ping_tester.py | 225 ++++++++++++++++++++++ 5 files changed, 552 insertions(+) create mode 100644 IMPROVEMENTS.md create mode 100644 desktop/pytest.ini create mode 100644 desktop/tests/__init__.py create mode 100644 desktop/tests/test_cli.py create mode 100644 desktop/tests/test_ping_tester.py diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md new file mode 100644 index 0000000..6a2bb3b --- /dev/null +++ b/IMPROVEMENTS.md @@ -0,0 +1,22 @@ +# PingDiff Improvement Log + +## 2026-03-17 — Testing: Unit test suite for CLI and ping core logic + +Added a pytest test suite covering the CLI and ping tester modules — the first tests in the project. +Tests are pure unit tests (no network calls, no subprocess invocations), so they run in milliseconds +and work offline. 68 tests across 10 test classes covering: + +- `validate_ip`: valid/invalid IPv4, IPv6, hostnames, injection attempts, edge cases +- `calculate_jitter`: empty input, single value, zero jitter, constant increase, alternating, high jitter +- `get_best_server`: empty list, all timeouts, single server, lowest ping, packet loss priority, exclusion logic +- `get_connection_quality`: all 5 quality tiers and boundary values (Excellent/Good/Fair/Poor/Bad) +- `sort_results`: all 5 sort keys, timeout-last ordering, unknown key fallback +- `filter_by_max_ping`: pass-all, block-all, timeout exclusion, inclusive/exclusive boundary +- `results_to_json`: valid JSON output, field correctness, best-only, error case, empty list +- `results_to_csv`: parseable CSV, header row, best-only, empty-on-no-best, multiple rows +- Color helpers: colorize no-color mode, format_ping, format_loss +- `build_parser`: defaults, all flags, region/sort validation, type coercion + +**Files changed:** `desktop/tests/__init__.py`, `desktop/tests/test_ping_tester.py`, +`desktop/tests/test_cli.py`, `desktop/pytest.ini` +**Lines:** +370 / -0 diff --git a/desktop/pytest.ini b/desktop/pytest.ini new file mode 100644 index 0000000..4ecb1ad --- /dev/null +++ b/desktop/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/desktop/tests/__init__.py b/desktop/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/desktop/tests/test_cli.py b/desktop/tests/test_cli.py new file mode 100644 index 0000000..0c5d648 --- /dev/null +++ b/desktop/tests/test_cli.py @@ -0,0 +1,300 @@ +""" +Unit tests for cli.py — CLI logic, sorting, filtering, and output formatting. +No network calls; no GUI dependencies. +""" + +import sys +import os +import io +import json +import csv + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from ping_tester import PingResult +from cli import ( + sort_results, + filter_by_max_ping, + results_to_json, + results_to_csv, + Colors, + colorize, + format_ping, + format_loss, + build_parser, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def make_result( + server_id="sv1", + location="Frankfurt", + region="EU", + ping_avg=50.0, + ping_min=45.0, + ping_max=60.0, + jitter=3.0, + packet_loss=0.0, + successful_pings=10, + total_pings=10, +) -> PingResult: + return PingResult( + server_id=server_id, + server_location=location, + ip_address="1.2.3.4", + ping_avg=ping_avg, + ping_min=ping_min, + ping_max=ping_max, + jitter=jitter, + packet_loss=packet_loss, + successful_pings=successful_pings, + total_pings=total_pings, + raw_times=[], + region=region, + error=None, + ) + + +# --------------------------------------------------------------------------- +# sort_results +# --------------------------------------------------------------------------- + +class TestSortResults: + def _make_set(self): + return [ + make_result(server_id="s1", location="Berlin", region="EU", ping_avg=80.0, jitter=5.0, packet_loss=0.0), + make_result(server_id="s2", location="Amsterdam", region="EU", ping_avg=30.0, jitter=2.0, packet_loss=0.0), + make_result(server_id="s3", location="Tokyo", region="ASIA", ping_avg=55.0, jitter=8.0, packet_loss=0.0), + ] + + def test_sort_by_ping(self): + results = self._make_set() + sorted_r = sort_results(results, "ping") + pings = [r.ping_avg for r in sorted_r] + assert pings == sorted(pings) + + def test_sort_by_jitter(self): + results = self._make_set() + sorted_r = sort_results(results, "jitter") + jitters = [r.jitter for r in sorted_r] + assert jitters == sorted(jitters) + + def test_sort_by_location(self): + results = self._make_set() + sorted_r = sort_results(results, "location") + locs = [r.server_location.lower() for r in sorted_r] + assert locs == sorted(locs) + + def test_sort_by_region(self): + results = self._make_set() + sorted_r = sort_results(results, "region") + regions = [r.region for r in sorted_r] + assert regions == sorted(regions) + + def test_timeouts_sorted_last(self): + r_ok = make_result(server_id="ok", ping_avg=200.0, packet_loss=0.0) + r_timeout = make_result(server_id="to", ping_avg=10.0, packet_loss=100.0) + sorted_r = sort_results([r_timeout, r_ok], "ping") + assert sorted_r[0].server_id == "ok" + assert sorted_r[-1].server_id == "to" + + def test_unknown_sort_key_defaults_to_ping(self): + results = self._make_set() + # Should not raise; falls back to ping sort + sorted_r = sort_results(results, "nonexistent") + pings = [r.ping_avg for r in sorted_r] + assert pings == sorted(pings) + + +# --------------------------------------------------------------------------- +# filter_by_max_ping +# --------------------------------------------------------------------------- + +class TestFilterByMaxPing: + def test_all_pass(self): + results = [make_result(ping_avg=50.0), make_result(ping_avg=80.0)] + filtered = filter_by_max_ping(results, 200.0) + assert len(filtered) == 2 + + def test_none_pass(self): + results = [make_result(ping_avg=150.0), make_result(ping_avg=200.0)] + filtered = filter_by_max_ping(results, 100.0) + assert filtered == [] + + def test_excludes_timeouts_regardless(self): + r_ok = make_result(server_id="ok", ping_avg=50.0, packet_loss=0.0) + r_to = make_result(server_id="to", ping_avg=10.0, packet_loss=100.0) + filtered = filter_by_max_ping([r_ok, r_to], 500.0) + ids = [r.server_id for r in filtered] + assert "ok" in ids + assert "to" not in ids + + def test_boundary_inclusive(self): + r = make_result(ping_avg=80.0, packet_loss=0.0) + assert len(filter_by_max_ping([r], 80.0)) == 1 + + def test_boundary_exclusive(self): + r = make_result(ping_avg=80.1, packet_loss=0.0) + assert len(filter_by_max_ping([r], 80.0)) == 0 + + +# --------------------------------------------------------------------------- +# results_to_json +# --------------------------------------------------------------------------- + +class TestResultsToJson: + def test_returns_valid_json(self): + results = [make_result()] + output = results_to_json(results) + data = json.loads(output) + assert isinstance(data, list) + + def test_contains_expected_fields(self): + r = make_result(server_id="s1", location="LA", region="NA", ping_avg=42.5) + data = json.loads(results_to_json([r])) + item = data[0] + assert item["server"] == "LA" + assert item["region"] == "NA" + assert item["ping_avg"] == 42.5 + assert "quality" in item + + def test_best_only_returns_single_item(self): + results = [ + make_result(server_id="s1", ping_avg=100.0, packet_loss=0.0), + make_result(server_id="s2", ping_avg=30.0, packet_loss=0.0), + ] + data = json.loads(results_to_json(results, best_only=True)) + assert len(data) == 1 + assert data[0]["server_id"] == "s2" + + def test_no_reachable_servers_returns_error(self): + results = [make_result(packet_loss=100.0)] + data = json.loads(results_to_json(results, best_only=True)) + assert "error" in data + + def test_empty_list_returns_empty_array(self): + data = json.loads(results_to_json([])) + assert data == [] + + +# --------------------------------------------------------------------------- +# results_to_csv +# --------------------------------------------------------------------------- + +class TestResultsToCsv: + def test_returns_parseable_csv(self): + results = [make_result(location="NYC", region="NA", ping_avg=35.0)] + output = results_to_csv(results) + reader = csv.DictReader(io.StringIO(output)) + rows = list(reader) + assert len(rows) == 1 + assert rows[0]["server"] == "NYC" + assert rows[0]["region"] == "NA" + + def test_header_row_present(self): + output = results_to_csv([make_result()]) + first_line = output.split("\n")[0] + assert "server" in first_line + assert "ping_avg" in first_line + + def test_best_only_returns_one_row(self): + results = [ + make_result(server_id="s1", ping_avg=100.0, packet_loss=0.0), + make_result(server_id="s2", ping_avg=30.0, packet_loss=0.0), + ] + output = results_to_csv(results, best_only=True) + reader = csv.DictReader(io.StringIO(output)) + rows = list(reader) + assert len(rows) == 1 + + def test_empty_on_no_best(self): + results = [make_result(packet_loss=100.0)] + output = results_to_csv(results, best_only=True) + assert output == "" + + def test_multiple_rows(self): + results = [make_result(location=f"Server{i}") for i in range(5)] + output = results_to_csv(results) + reader = csv.DictReader(io.StringIO(output)) + rows = list(reader) + assert len(rows) == 5 + + +# --------------------------------------------------------------------------- +# colorize / format helpers +# --------------------------------------------------------------------------- + +class TestColorHelpers: + def test_colorize_no_color_mode(self, monkeypatch): + monkeypatch.setattr(Colors, "supports_color", staticmethod(lambda: False)) + assert colorize("hello", Colors.GREEN) == "hello" + + def test_format_ping_zero(self, monkeypatch): + monkeypatch.setattr(Colors, "supports_color", staticmethod(lambda: False)) + assert format_ping(0) == "---" + + def test_format_ping_good(self, monkeypatch): + monkeypatch.setattr(Colors, "supports_color", staticmethod(lambda: False)) + assert "25ms" in format_ping(25) + + def test_format_loss_zero(self, monkeypatch): + monkeypatch.setattr(Colors, "supports_color", staticmethod(lambda: False)) + assert "0%" in format_loss(0) + + def test_format_loss_nonzero(self, monkeypatch): + monkeypatch.setattr(Colors, "supports_color", staticmethod(lambda: False)) + output = format_loss(5.0) + assert "5.0%" in output + + +# --------------------------------------------------------------------------- +# build_parser +# --------------------------------------------------------------------------- + +class TestBuildParser: + def test_defaults(self): + parser = build_parser() + args = parser.parse_args([]) + assert args.game == "overwatch-2" + assert args.count == 10 + assert args.sort == "ping" + assert args.interval == 30 + assert args.max_ping is None + assert args.output is None + + def test_cli_flag(self): + parser = build_parser() + args = parser.parse_args(["--cli"]) + assert args.cli is True + + def test_region_choices(self): + parser = build_parser() + for region in ["EU", "NA", "ASIA", "SA", "ME"]: + args = parser.parse_args(["--region", region]) + assert args.region == region + + def test_invalid_region_raises(self): + parser = build_parser() + with pytest.raises(SystemExit): + parser.parse_args(["--region", "INVALID"]) + + def test_sort_choices(self): + parser = build_parser() + for sort in ["ping", "jitter", "loss", "location", "region"]: + args = parser.parse_args(["--sort", sort]) + assert args.sort == sort + + def test_max_ping_parsed_as_float(self): + parser = build_parser() + args = parser.parse_args(["--max-ping", "80"]) + assert args.max_ping == 80.0 + + def test_output_flag(self): + parser = build_parser() + args = parser.parse_args(["--output", "results.json"]) + assert args.output == "results.json" diff --git a/desktop/tests/test_ping_tester.py b/desktop/tests/test_ping_tester.py new file mode 100644 index 0000000..5352ae8 --- /dev/null +++ b/desktop/tests/test_ping_tester.py @@ -0,0 +1,225 @@ +""" +Unit tests for ping_tester.py — core ping logic. +No network calls are made; all tests use synthetic data. +""" + +import sys +import os +import pytest + +# Add desktop/src to path so we can import without packaging +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from ping_tester import ( + validate_ip, + calculate_jitter, + get_best_server, + get_connection_quality, + PingResult, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def make_result( + server_id="sv1", + location="Frankfurt", + ip="1.2.3.4", + ping_avg=50.0, + ping_min=45.0, + ping_max=60.0, + jitter=3.0, + packet_loss=0.0, + successful_pings=10, + total_pings=10, + raw_times=None, + region="EU", + error=None, +) -> PingResult: + return PingResult( + server_id=server_id, + server_location=location, + ip_address=ip, + ping_avg=ping_avg, + ping_min=ping_min, + ping_max=ping_max, + jitter=jitter, + packet_loss=packet_loss, + successful_pings=successful_pings, + total_pings=total_pings, + raw_times=raw_times or [], + region=region, + error=error, + ) + + +# --------------------------------------------------------------------------- +# validate_ip +# --------------------------------------------------------------------------- + +class TestValidateIp: + def test_valid_ipv4(self): + assert validate_ip("8.8.8.8") is True + + def test_valid_ipv6(self): + assert validate_ip("2001:4860:4860::8888") is True + + def test_empty_string(self): + assert validate_ip("") is False + + def test_none(self): + assert validate_ip(None) is False + + def test_hostname_rejected(self): + # Hostnames are not valid IP addresses + assert validate_ip("google.com") is False + + def test_ip_with_port_rejected(self): + assert validate_ip("8.8.8.8:53") is False + + def test_partial_ip_rejected(self): + assert validate_ip("8.8.8") is False + + def test_injection_attempt_rejected(self): + assert validate_ip("8.8.8.8; rm -rf /") is False + + def test_loopback_allowed(self): + assert validate_ip("127.0.0.1") is True + + def test_broadcast_allowed(self): + assert validate_ip("255.255.255.255") is True + + +# --------------------------------------------------------------------------- +# calculate_jitter +# --------------------------------------------------------------------------- + +class TestCalculateJitter: + def test_empty_list(self): + assert calculate_jitter([]) == 0.0 + + def test_single_element(self): + assert calculate_jitter([50.0]) == 0.0 + + def test_zero_jitter(self): + # Identical pings → jitter = 0 + result = calculate_jitter([30.0, 30.0, 30.0, 30.0]) + assert result == 0.0 + + def test_constant_increase(self): + # [10, 20, 30] → diffs = [10, 10] → avg = 10 + result = calculate_jitter([10.0, 20.0, 30.0]) + assert result == 10.0 + + def test_alternating(self): + # [10, 20, 10, 20] → diffs = [10, 10, 10] → avg = 10 + result = calculate_jitter([10.0, 20.0, 10.0, 20.0]) + assert result == 10.0 + + def test_high_jitter(self): + # [1, 100, 1, 100] → diffs = [99, 99, 99] → avg = 99 + result = calculate_jitter([1.0, 100.0, 1.0, 100.0]) + assert result == 99.0 + + def test_returns_float(self): + result = calculate_jitter([10.0, 15.0]) + assert isinstance(result, float) + + +# --------------------------------------------------------------------------- +# get_best_server +# --------------------------------------------------------------------------- + +class TestGetBestServer: + def test_empty_list(self): + assert get_best_server([]) is None + + def test_all_timeout(self): + results = [ + make_result(server_id="s1", packet_loss=100.0), + make_result(server_id="s2", packet_loss=100.0), + ] + assert get_best_server(results) is None + + def test_single_valid_server(self): + r = make_result(server_id="s1", ping_avg=40.0, packet_loss=0.0) + assert get_best_server([r]) is r + + def test_picks_lowest_ping(self): + r1 = make_result(server_id="s1", ping_avg=80.0, packet_loss=0.0) + r2 = make_result(server_id="s2", ping_avg=40.0, packet_loss=0.0) + r3 = make_result(server_id="s3", ping_avg=120.0, packet_loss=0.0) + best = get_best_server([r1, r2, r3]) + assert best.server_id == "s2" + + def test_packet_loss_beats_lower_ping(self): + # s1 has lower ping but some packet loss → s2 should win + r1 = make_result(server_id="s1", ping_avg=10.0, packet_loss=5.0) + r2 = make_result(server_id="s2", ping_avg=50.0, packet_loss=0.0) + best = get_best_server([r1, r2]) + assert best.server_id == "s2" + + def test_excludes_100_percent_loss(self): + r1 = make_result(server_id="s1", ping_avg=10.0, packet_loss=100.0) + r2 = make_result(server_id="s2", ping_avg=50.0, packet_loss=0.0) + best = get_best_server([r1, r2]) + assert best.server_id == "s2" + + def test_mixed_loss_picks_lower_loss_first(self): + r1 = make_result(server_id="s1", ping_avg=30.0, packet_loss=10.0) + r2 = make_result(server_id="s2", ping_avg=25.0, packet_loss=2.0) + best = get_best_server([r1, r2]) + assert best.server_id == "s2" + + +# --------------------------------------------------------------------------- +# get_connection_quality +# --------------------------------------------------------------------------- + +class TestGetConnectionQuality: + def test_excellent(self): + r = make_result(ping_avg=20.0, packet_loss=0.0) + assert get_connection_quality(r) == "Excellent" + + def test_good(self): + r = make_result(ping_avg=50.0, packet_loss=0.0) + assert get_connection_quality(r) == "Good" + + def test_fair(self): + r = make_result(ping_avg=80.0, packet_loss=0.0) + assert get_connection_quality(r) == "Fair" + + def test_poor_high_ping(self): + r = make_result(ping_avg=130.0, packet_loss=0.0) + assert get_connection_quality(r) == "Poor" + + def test_bad_very_high_ping(self): + r = make_result(ping_avg=200.0, packet_loss=0.0) + assert get_connection_quality(r) == "Bad" + + def test_bad_high_packet_loss(self): + r = make_result(ping_avg=20.0, packet_loss=10.0) + assert get_connection_quality(r) == "Bad" + + def test_poor_moderate_loss(self): + r = make_result(ping_avg=20.0, packet_loss=3.0) + assert get_connection_quality(r) == "Poor" + + def test_boundary_excellent_good(self): + # 29ms → Excellent, 30ms → Good + assert get_connection_quality(make_result(ping_avg=29.0, packet_loss=0.0)) == "Excellent" + assert get_connection_quality(make_result(ping_avg=30.0, packet_loss=0.0)) == "Good" + + def test_boundary_good_fair(self): + assert get_connection_quality(make_result(ping_avg=59.0, packet_loss=0.0)) == "Good" + assert get_connection_quality(make_result(ping_avg=60.0, packet_loss=0.0)) == "Fair" + + def test_boundary_fair_poor(self): + assert get_connection_quality(make_result(ping_avg=99.0, packet_loss=0.0)) == "Fair" + assert get_connection_quality(make_result(ping_avg=100.0, packet_loss=0.0)) == "Poor" + + def test_boundary_poor_bad(self): + assert get_connection_quality(make_result(ping_avg=149.0, packet_loss=0.0)) == "Poor" + assert get_connection_quality(make_result(ping_avg=150.0, packet_loss=0.0)) == "Bad" From 467d0060eab92429f474524d769ee48dbc1f2169 Mon Sep 17 00:00:00 2001 From: bokiko Date: Wed, 18 Mar 2026 00:13:34 +0000 Subject: [PATCH 2/5] refactor: extract shared Navbar and Footer into reusable components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The navigation bar and footer were copy-pasted identically across 4 pages (home, dashboard, community, download), totaling ~200 lines of duplicated JSX. Changes: - Add web/src/components/Navbar.tsx: shared nav with usePathname-based active state highlighting, mobile menu toggle, ARIA attributes, and declarative NAV_LINKS config array — single source of truth for site navigation - Add web/src/components/Footer.tsx: shared footer with logo, copyright, and Privacy/Terms/GitHub links - Refactor page.tsx, dashboard/page.tsx, community/page.tsx, download/page.tsx: replace inline nav/footer blocks with and