342 lines
11 KiB
Python
342 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Tests für das Terminal-Dashboard (web/tui.py), ohne Terminal und ohne DB.
|
|
|
|
Geprüft werden Datei-Lesen, Zeitfenster, Sparkline, die Textdarstellung
|
|
und der Aufruf über die Kommandozeile (`--once`).
|
|
"""
|
|
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from datetime import datetime, timedelta
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
ROOT = os.path.dirname(HERE)
|
|
|
|
_spec = importlib.util.spec_from_file_location(
|
|
"jonassearch_stats_tui", os.path.join(HERE, "tui.py")
|
|
)
|
|
tui = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(tui)
|
|
|
|
try:
|
|
_stats_spec = importlib.util.spec_from_file_location(
|
|
"jonassearch_stats_app_for_tui", os.path.join(HERE, "app.py")
|
|
)
|
|
stats_app = importlib.util.module_from_spec(_stats_spec)
|
|
_stats_spec.loader.exec_module(stats_app)
|
|
except Exception: # z. B. Flask nicht installiert
|
|
stats_app = None
|
|
|
|
NOW = datetime(2026, 10, 17, 12, 0, 0)
|
|
|
|
|
|
def entry(timestamp, count, images=None, exact=None):
|
|
data = {"timestamp": timestamp.isoformat(), "count": count}
|
|
if images is not None:
|
|
data["images"] = images
|
|
if exact is not None:
|
|
data["images_exact"] = exact
|
|
return data
|
|
|
|
|
|
def sample_entries():
|
|
return [
|
|
entry(NOW - timedelta(hours=2), 980_000, images=100, exact=True),
|
|
entry(NOW - timedelta(hours=1), 981_000),
|
|
entry(NOW - timedelta(minutes=30), 981_950, images=120, exact=False),
|
|
]
|
|
|
|
|
|
def normalized(entries):
|
|
"""Rohdaten so aufbereiten, wie load_entries() sie liefert."""
|
|
result = []
|
|
for item in entries:
|
|
copy = dict(item)
|
|
copy["timestamp"] = tui.parse_timestamp(copy["timestamp"])
|
|
result.append(copy)
|
|
return result
|
|
|
|
|
|
class FormatTest(unittest.TestCase):
|
|
|
|
def test_fmt_int_uses_dots(self):
|
|
self.assertEqual(tui.fmt_int(987832), "987.832")
|
|
self.assertEqual(tui.fmt_int(None), "-")
|
|
|
|
def test_fmt_delta(self):
|
|
self.assertEqual(tui.fmt_delta(950), "+950")
|
|
self.assertEqual(tui.fmt_delta(-12), "-12")
|
|
self.assertEqual(tui.fmt_delta(0), "0")
|
|
self.assertEqual(tui.fmt_delta(None), "-")
|
|
|
|
def test_fmt_duration(self):
|
|
self.assertEqual(tui.fmt_duration(30), "30 s")
|
|
self.assertEqual(tui.fmt_duration(90), "1 min")
|
|
self.assertEqual(tui.fmt_duration(5400), "1 h 30 min")
|
|
self.assertEqual(tui.fmt_duration(90000), "1 d 1 h")
|
|
|
|
def test_samples_label(self):
|
|
self.assertEqual(tui._samples_label(1), "1 sample")
|
|
self.assertEqual(tui._samples_label(2), "2 samples")
|
|
|
|
def test_relative_time(self):
|
|
self.assertEqual(tui.relative_time(NOW, NOW), "just now")
|
|
self.assertEqual(
|
|
tui.relative_time(NOW - timedelta(minutes=3), NOW), "3 min ago"
|
|
)
|
|
|
|
|
|
class ParseTimestampTest(unittest.TestCase):
|
|
|
|
def test_iso_and_z(self):
|
|
self.assertEqual(
|
|
tui.parse_timestamp("2026-10-17T20:44:20.187761"),
|
|
datetime(2026, 10, 17, 20, 44, 20, 187761),
|
|
)
|
|
parsed = tui.parse_timestamp("2026-10-17T20:44:20Z")
|
|
self.assertIsNone(parsed.tzinfo)
|
|
self.assertEqual(
|
|
parsed,
|
|
datetime.fromisoformat("2026-10-17T20:44:20+00:00")
|
|
.astimezone().replace(tzinfo=None),
|
|
)
|
|
|
|
def test_invalid(self):
|
|
self.assertIsNone(tui.parse_timestamp("kaputt"))
|
|
self.assertIsNone(tui.parse_timestamp(""))
|
|
self.assertIsNone(tui.parse_timestamp(None))
|
|
|
|
|
|
class LoadEntriesTest(unittest.TestCase):
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.path = os.path.join(self.tmp.name, "page_counts.json")
|
|
|
|
def tearDown(self):
|
|
self.tmp.cleanup()
|
|
|
|
def _write(self, data):
|
|
with open(self.path, "w", encoding="utf-8") as handle:
|
|
if isinstance(data, str):
|
|
handle.write(data)
|
|
else:
|
|
json.dump(data, handle)
|
|
|
|
def test_missing_file_is_empty(self):
|
|
self.assertEqual(tui.load_entries(self.path), [])
|
|
|
|
def test_sorts_and_skips_bad_rows(self):
|
|
self._write([
|
|
entry(NOW, 20),
|
|
"kaputt",
|
|
{"timestamp": "kaputt", "count": 1},
|
|
{"timestamp": NOW.isoformat(), "count": "x"},
|
|
entry(NOW - timedelta(minutes=30), 10),
|
|
])
|
|
entries = tui.load_entries(self.path)
|
|
self.assertEqual([e["count"] for e in entries], [10, 20])
|
|
self.assertIsInstance(entries[0]["timestamp"], datetime)
|
|
|
|
def test_api_format_is_accepted(self):
|
|
self._write({"values": [entry(NOW, 5, images=7, exact=True)]})
|
|
entries = tui.load_entries(self.path)
|
|
self.assertEqual(len(entries), 1)
|
|
self.assertEqual(entries[0]["images"], 7)
|
|
self.assertTrue(entries[0]["images_exact"])
|
|
|
|
def test_invalid_images_becomes_none(self):
|
|
self._write([{"timestamp": NOW.isoformat(), "count": 1,
|
|
"images": "viele"}])
|
|
self.assertIsNone(tui.load_entries(self.path)[0]["images"])
|
|
|
|
def test_corrupt_json_raises(self):
|
|
self._write("{not json")
|
|
with self.assertRaises(tui.StatsDataError):
|
|
tui.load_entries(self.path)
|
|
|
|
def test_wrong_toplevel_type_raises(self):
|
|
self._write(42)
|
|
with self.assertRaises(tui.StatsDataError):
|
|
tui.load_entries(self.path)
|
|
|
|
|
|
class SharedScheduleTest(unittest.TestCase):
|
|
|
|
def test_exact_interval_matches_statserver(self):
|
|
if stats_app is None:
|
|
self.skipTest("web/app.py nicht importierbar (Flask fehlt)")
|
|
self.assertEqual(
|
|
tui.EXACT_IMAGE_INTERVAL, stats_app.EXACT_IMAGE_INTERVAL
|
|
)
|
|
|
|
|
|
class WindowTest(unittest.TestCase):
|
|
|
|
def test_window_filters_old_entries(self):
|
|
entries = normalized([
|
|
entry(NOW - timedelta(days=3), 1),
|
|
entry(NOW - timedelta(hours=1), 2),
|
|
])
|
|
self.assertEqual(
|
|
[e["count"] for e in tui.select_window(entries, "24h", NOW)], [2]
|
|
)
|
|
self.assertEqual(len(tui.select_window(entries, "all", NOW)), 2)
|
|
|
|
def test_latest_images_falls_back(self):
|
|
entries = normalized([
|
|
entry(NOW - timedelta(hours=1), 1, images=10, exact=True),
|
|
entry(NOW, 2),
|
|
])
|
|
images, exact, timestamp = tui.latest_images(entries)
|
|
self.assertEqual(images, 10)
|
|
self.assertTrue(exact)
|
|
self.assertEqual(timestamp, entries[0]["timestamp"])
|
|
|
|
def test_next_exact_text(self):
|
|
recent = normalized([
|
|
entry(NOW - timedelta(minutes=30), 1, images=1, exact=True)
|
|
])
|
|
self.assertEqual(
|
|
tui.next_exact_text(recent, NOW), "next exact in 1 h 30 min"
|
|
)
|
|
overdue = normalized([
|
|
entry(NOW - timedelta(hours=3), 1, images=1, exact=True)
|
|
])
|
|
self.assertEqual(
|
|
tui.next_exact_text(overdue, NOW), "exact count due now"
|
|
)
|
|
self.assertEqual(
|
|
tui.next_exact_text(normalized([entry(NOW, 1)]), NOW),
|
|
"no exact count yet",
|
|
)
|
|
|
|
|
|
class SparklineTest(unittest.TestCase):
|
|
|
|
def test_width_and_padding(self):
|
|
line = tui.sparkline([1, 2, 3], 10)
|
|
self.assertEqual(len(line), 10)
|
|
self.assertTrue(line.startswith(" "))
|
|
|
|
def test_single_sample(self):
|
|
self.assertEqual(tui.sparkline([5], 1), tui.SPARK_BLOCKS[4])
|
|
|
|
def test_increasing_values_use_increasing_blocks(self):
|
|
line = tui.sparkline([1, 2, 3, 4], 4)
|
|
indices = [tui.SPARK_BLOCKS.index(char) for char in line]
|
|
self.assertEqual(indices, sorted(indices))
|
|
|
|
def test_constant_values_do_not_crash(self):
|
|
self.assertEqual(tui.sparkline([7, 7, 7], 3), tui.SPARK_BLOCKS[4] * 3)
|
|
|
|
def test_empty(self):
|
|
self.assertEqual(tui.sparkline([], 10), "")
|
|
|
|
|
|
class RenderTest(unittest.TestCase):
|
|
|
|
def test_shows_numbers_and_hints(self):
|
|
lines = tui.render_dashboard(
|
|
normalized(sample_entries()), now=NOW, width=100
|
|
)
|
|
text = "\n".join(lines)
|
|
self.assertIn("Pages 981.950", text)
|
|
self.assertIn("Images 120", text)
|
|
self.assertIn("estimated", text)
|
|
self.assertIn("exact count due now", text)
|
|
self.assertIn("+950 since previous", text)
|
|
self.assertIn("q quit", text)
|
|
self.assertIn("t window (24h)", text)
|
|
self.assertIn("Recent samples", text)
|
|
|
|
def test_lines_fit_width(self):
|
|
for width in (32, 48, 60, 80):
|
|
lines = tui.render_dashboard(
|
|
normalized(sample_entries()), now=NOW, width=width, rows=24
|
|
)
|
|
for line in lines:
|
|
self.assertLessEqual(len(line), width, repr(line))
|
|
|
|
def test_row_budget_keeps_footer(self):
|
|
lines = tui.render_dashboard(
|
|
normalized(sample_entries()), now=NOW, width=80, rows=12
|
|
)
|
|
self.assertLessEqual(len(lines), 12)
|
|
self.assertIn("q quit", lines[-1])
|
|
|
|
def test_unreadable_file_error(self):
|
|
temp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(temp.cleanup)
|
|
path = os.path.join(temp.name, "page_counts.json")
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
handle.write("{kaputt")
|
|
|
|
try:
|
|
tui.load_entries(path)
|
|
except tui.StatsDataError as exc:
|
|
lines = tui.render_dashboard([], now=NOW, width=90, error=str(exc))
|
|
else:
|
|
self.fail("StatsDataError erwartet")
|
|
|
|
self.assertIn("invalid JSON", "\n".join(lines))
|
|
self.assertIn("q quit", "\n".join(lines))
|
|
|
|
def test_empty_data(self):
|
|
text = "\n".join(tui.render_dashboard([], now=NOW, width=80))
|
|
self.assertIn("No samples yet", text)
|
|
|
|
|
|
class OnceModeTest(unittest.TestCase):
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmp.cleanup)
|
|
self.path = os.path.join(self.tmp.name, "page_counts.json")
|
|
with open(self.path, "w", encoding="utf-8") as handle:
|
|
json.dump(sample_entries(), handle)
|
|
|
|
def _run(self, *extra):
|
|
return subprocess.run(
|
|
[sys.executable, os.path.join(HERE, "tui.py"), "--once",
|
|
"--file", self.path, "--width", "100"] + list(extra),
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=ROOT,
|
|
)
|
|
|
|
def test_once_prints_dashboard(self):
|
|
result = self._run()
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn("Pages", result.stdout)
|
|
self.assertIn("981.950", result.stdout)
|
|
self.assertIn("q quit", result.stdout)
|
|
|
|
def test_once_window_flag(self):
|
|
result = self._run("--window", "all", "--ascii")
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn("all data", result.stdout)
|
|
|
|
def test_once_corrupt_file_exits_nonzero(self):
|
|
with open(self.path, "w", encoding="utf-8") as handle:
|
|
handle.write("{kaputt")
|
|
result = self._run()
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertIn("error:", result.stderr)
|
|
|
|
def test_once_missing_file_is_empty_state(self):
|
|
os.unlink(self.path)
|
|
result = self._run()
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn("No samples yet", result.stdout)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|