Files
search/web/test_stats.py
T
2026-10-18 11:21:25 +02:00

188 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)