188 lines
7.5 KiB
Python
188 lines
7.5 KiB
Python
#!/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)
|