add pictures to statcounter

This commit is contained in:
2026-10-18 11:21:25 +02:00
parent ca5af75bbd
commit 3ebfeca7cc
5 changed files with 1225 additions and 32 deletions
+341
View File
@@ -0,0 +1,341 @@
#!/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()