#!/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()