#!/usr/bin/env python3 """ Tests für den Statserver (web/app.py), ohne Datenbank. Geprüft werden die DB-freien Teile: Parsen der psql-Zeile, der 2-Stunden-Rhythmus für den exakten Bild-Count, die Wahl zwischen exakter Zählung und Stichprobe sowie die Einbindung der neuen Images-Anzeige in die HTML-Seite. """ import importlib.util import os import subprocess import unittest from datetime import datetime, timedelta from unittest import mock HERE = os.path.dirname(os.path.abspath(__file__)) # web/app.py unter einem eindeutigen Modulnamen laden, damit es nicht mit # priorityweb/app.py (gleicher Dateiname) kollidiert. _spec = importlib.util.spec_from_file_location( "jonassearch_stats_app", os.path.join(HERE, "app.py") ) stats_app = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(stats_app) class ParseStatsRowTest(unittest.TestCase): def test_count_and_images(self): self.assertEqual( stats_app._parse_stats_row("981981|12345"), (981981, 12345) ) def test_empty_sample_is_none(self): self.assertEqual(stats_app._parse_stats_row("10|"), (10, None)) self.assertEqual(stats_app._parse_stats_row("10|\n"), (10, None)) class ImagesExactDueTest(unittest.TestCase): NOW = datetime(2026, 10, 17, 12, 0, 0) def _entry(self, age, exact): return { "timestamp": (self.NOW - age).isoformat(), "count": 1, "images": 2, "images_exact": exact, } def test_no_data_means_due(self): self.assertTrue(stats_app.images_exact_due([], now=self.NOW)) def test_recent_exact_is_not_due(self): data = [self._entry(timedelta(hours=1), True)] self.assertFalse(stats_app.images_exact_due(data, now=self.NOW)) def test_two_hours_old_is_due(self): data = [self._entry(timedelta(hours=2), True)] self.assertTrue(stats_app.images_exact_due(data, now=self.NOW)) def test_estimates_in_between_do_not_reset_schedule(self): data = [ self._entry(timedelta(hours=1, minutes=30), True), self._entry(timedelta(minutes=30), False), ] self.assertFalse(stats_app.images_exact_due(data, now=self.NOW)) def test_old_entries_without_flag_are_ignored(self): data = [{"timestamp": self.NOW.isoformat(), "count": 1}] self.assertTrue(stats_app.images_exact_due(data, now=self.NOW)) def test_malformed_exact_timestamp_is_ignored(self): data = [{"timestamp": "kaputt", "images_exact": True}] self.assertTrue(stats_app.images_exact_due(data, now=self.NOW)) class GetStatsTest(unittest.TestCase): @mock.patch.object(stats_app, "_psql_scalar") def test_exact_query(self, psql): psql.return_value = "100|250" self.assertEqual(stats_app.get_stats(True), (100, 250, True)) self.assertEqual(psql.call_args[0][0], stats_app.EXACT_IMAGE_SQL) self.assertNotIn("TABLESAMPLE", stats_app.EXACT_IMAGE_SQL) @mock.patch.object(stats_app, "_psql_scalar") def test_estimate_query(self, psql): psql.return_value = "100|250" self.assertEqual(stats_app.get_stats(False), (100, 250, False)) self.assertEqual(psql.call_args[0][0], stats_app.ESTIMATE_IMAGE_SQL) self.assertIn("TABLESAMPLE SYSTEM (1)", stats_app.ESTIMATE_IMAGE_SQL) @mock.patch.object(stats_app, "_psql_scalar") def test_empty_sample_on_small_table_falls_back_to_exact(self, psql): psql.side_effect = ["10|", "10|20"] self.assertEqual(stats_app.get_stats(False), (10, 20, True)) self.assertEqual(psql.call_count, 2) self.assertEqual(psql.call_args_list[1][0][0], stats_app.EXACT_IMAGE_SQL) @mock.patch.object(stats_app, "_psql_scalar") def test_empty_sample_on_large_table_stays_unknown(self, psql): psql.return_value = "200000|" self.assertEqual(stats_app.get_stats(False), (200000, None, False)) @mock.patch.object(stats_app, "_psql_scalar") def test_missing_pictures_column_falls_back_to_page_count(self, psql): error = subprocess.CalledProcessError( 1, ["psql"], stderr='ERROR: column "pictures" does not exist' ) psql.side_effect = [error, "10"] self.assertEqual(stats_app.get_stats(False), (10, None, False)) self.assertEqual(psql.call_args_list[1][0][0], "SELECT COUNT(*) FROM pages;") class StatsPageTest(unittest.TestCase): def test_page_contains_image_counter(self): client = stats_app.app.test_client() response = client.get("/") self.assertEqual(response.status_code, 200) html = response.get_data(as_text=True) self.assertIn('id="current-images"', html) self.assertIn('id="current-images-meta"', html) self.assertIn("Estimated · exact every 2 h", html) self.assertIn("imagesExact", html) def test_page_contains_6h_range_and_card(self): client = stats_app.app.test_client() response = client.get("/") self.assertEqual(response.status_code, 200) html = response.get_data(as_text=True) self.assertIn('data-range="6h"', html) self.assertIn('id="stat-delta-6h"', html) self.assertIn('id="stat-delta-6h-hint"', html) self.assertIn("'6h': 6 * 3600000", html) self.assertIn("closestBefore(all, last.x - 6 * 3600000", html) def test_page_contains_all_image_graphs(self): client = stats_app.app.test_client() response = client.get("/") self.assertEqual(response.status_code, 200) html = response.get_data(as_text=True) self.assertIn("Images – all graphs", html) for canvas in ( "imagesMainChart", "imagesDeltaChart", "imagesCumChart", "imagesHourlyChart", "imagesHodChart", ): self.assertIn('id="' + canvas + '"', html) self.assertIn('id="records-images"', html) self.assertIn('id="heatmap-images"', html) self.assertIn('id="images-empty"', html) # the image series skips entries without an image count (old data) # and only plots exact measurements - no estimates between the 2 h counts self.assertIn("function toImagePoints(points)", html) self.assertIn("p.images !== null && p.images !== undefined", html) self.assertIn(".filter(function (p) { return p.imagesExact; })", html) self.assertIn("only exact measurements are plotted (no estimates)", html) self.assertIn("Only exact measurements (counted every 2 h) are plotted", html) self.assertIn("Only estimated image counts so far", html) self.assertIn("No exact image counts in the selected range", html) # existing page chart ids must stay lowercase (mainChart, not MainChart) self.assertIn("function chartId(prefix, suffix)", html) self.assertIn("chartId(prefix, 'MainChart')", html) # both series use the same time window (anchored to the latest page sample) self.assertIn("filterRange(imageAll, last.x)", html) # both metrics go through the same renderers, with the right units self.assertIn("unit: 'Images'", html) self.assertIn("unit: 'Pages'", html) self.assertIn("renderRecords(deltas, 'records', 'Pages')", html) self.assertIn("renderRecords(imageDeltas, 'records-images', 'Images')", html) self.assertIn("renderHeatmap(all, 'heatmap', 'Pages')", html) self.assertIn("renderHeatmap(imageAll, 'heatmap-images', 'Images')", html) if __name__ == "__main__": unittest.main(verbosity=2)