Grotti 3.0.0: Aufträge, Wartung, SSE, Ausschuss-Dialog, UI-Umbau.

Migrationen 003–009, Kundaufträge mit Lieferschein, Abräumen mit Urteil,
ntfy/Backup/Zeitfenster, Live-Stream und Mobile-Mehr-Menü.
This commit is contained in:
Dominic Gundel committed 2026-10-18 00:54:51 +02:00
1 parent 0236278929
commit 8e88efc094
45 files changed
+3121 -122

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
"""Urteil beim Abräumen (reine Hilfslogik ohne DB)."""
import unittest
from grotti.verdict import normalize_abraeumen_verdict
class AbraeumenVerdictTests(unittest.TestCase):
def test_gut_clears_reason(self):
v, reason, ok, bad = normalize_abraeumen_verdict("gut", "warping", 2, 1)
self.assertEqual(v, "gut")
self.assertEqual(reason, "")
self.assertEqual(bad, 0)
def test_ausschuss_requires_known_reason_if_set(self):
with self.assertRaises(ValueError):
normalize_abraeumen_verdict("ausschuss", "xyz", 0, 0)
def test_ausschuss_defaults_bad_piece(self):
v, reason, ok, bad = normalize_abraeumen_verdict("ausschuss", "spaghetti", 0, 0)
self.assertEqual(v, "ausschuss")
self.assertEqual(reason, "spaghetti")
self.assertEqual(bad, 1)
if __name__ == "__main__":
unittest.main()
+70
View File
@@ -0,0 +1,70 @@
"""Filament-Bedarf aus Queue und Spulen."""
import unittest
from unittest.mock import MagicMock
from grotti.bedarf import compute
class BedarfTests(unittest.TestCase):
def _rows(self, *lists):
mocks = []
for rows in lists:
m = MagicMock()
m.fetchall.return_value = rows
mocks.append(m)
return mocks
def test_shortfall_when_queue_exceeds_stock(self):
conn = MagicMock()
conn.execute.side_effect = self._rows(
[
{
"id": 1,
"spool_id": 10,
"repeat_total": 2,
"repeat_done": 0,
"weight_g": 50.0,
"file_material": "PLA",
"file_color": "Schwarz",
"spool_material": "PLA",
"spool_color": "Schwarz",
},
],
[
{"material_type": "PLA", "color": "Schwarz", "remaining_g": 40.0},
],
)
out = compute(conn)
self.assertEqual(len(out["needed"]), 1)
self.assertAlmostEqual(out["needed"][0]["weight_g"], 100.0)
self.assertEqual(len(out["shortfall"]), 1)
self.assertAlmostEqual(out["shortfall"][0]["shortfall_g"], 60.0)
def test_no_shortfall_when_enough_filament(self):
conn = MagicMock()
conn.execute.side_effect = self._rows(
[
{
"id": 1,
"spool_id": None,
"repeat_total": 1,
"repeat_done": 0,
"weight_g": 20.0,
"file_material": "PETG",
"file_color": "",
"spool_material": None,
"spool_color": None,
},
],
[
{"material_type": "PETG", "color": "", "remaining_g": 500.0},
],
)
out = compute(conn)
self.assertEqual(out["shortfall"], [])
if __name__ == "__main__":
unittest.main()
+28
View File
@@ -0,0 +1,28 @@
"""Wartungs-Zähler."""
import unittest
from unittest.mock import MagicMock
from grotti.maintenance import counters_for_host
class MaintenanceCounterTests(unittest.TestCase):
def test_counters_from_aggregate_row(self):
conn = MagicMock()
conn.execute.return_value.fetchone.return_value = {
"duration_s": 7200.0,
"meters": 150000.0,
"runs": 12,
}
out = counters_for_host(conn, "10.0.0.1")
self.assertAlmostEqual(out["hours"], 2.0)
self.assertAlmostEqual(out["meters"], 150000.0)
self.assertEqual(out["runs"], 12)
def test_empty_host(self):
out = counters_for_host(MagicMock(), "")
self.assertEqual(out, {"hours": 0.0, "meters": 0.0, "runs": 0})
if __name__ == "__main__":
unittest.main()
+48
View File
@@ -0,0 +1,48 @@
"""Auftrags-Status und Angebotsrechnung."""
import unittest
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from grotti.cost import offer_from_file
from grotti.orders import ORDER_STATUSES, IDEA_TO_ORDER, PROJECT_TO_ORDER, normalize_status
class OrderStatusTests(unittest.TestCase):
def test_all_status_values(self):
for s in ("idee", "angebot", "bestaetigt", "druckt", "fertig", "geliefert", "bezahlt"):
self.assertIn(s, ORDER_STATUSES)
self.assertEqual(normalize_status(s), s)
def test_unknown_status(self):
with self.assertRaises(ValueError):
normalize_status("xyz")
def test_legacy_project_map(self):
self.assertEqual(PROJECT_TO_ORDER["bereit"], "angebot")
self.assertEqual(PROJECT_TO_ORDER["druckt"], "druckt")
def test_legacy_idea_map(self):
self.assertEqual(IDEA_TO_ORDER["erledigt"], "fertig")
self.assertEqual(IDEA_TO_ORDER["spaeter"], "idee")
class OfferFromFileTests(unittest.TestCase):
def test_basic_offer(self):
file_row = {"weight_g": 50, "estimated_s": 3600}
out = offer_from_file(file_row, material_price=20, machine_rate_per_h=10, setup_min=15, margin=20)
self.assertAlmostEqual(out["material_cost"], 1.0)
self.assertAlmostEqual(out["hours"], 1.0)
self.assertAlmostEqual(out["machine_cost"], 10 + 2.5)
self.assertAlmostEqual(out["total"], out["subtotal"] * 1.2)
def test_empty_file(self):
out = offer_from_file({}, 0, 0, 0, 0)
self.assertEqual(out["total"], 0.0)
if __name__ == "__main__":
unittest.main()
+24
View File
@@ -0,0 +1,24 @@
"""Start-Zeitfenster."""
import unittest
from datetime import datetime
from zoneinfo import ZoneInfo
from grotti.time_window import allowed_now
class TimeWindowTests(unittest.TestCase):
def test_overnight_window(self):
window = {"allowed": [{"from": "22:00", "to": "06:00"}]}
inside = datetime(2026, 10, 18, 23, 0, tzinfo=ZoneInfo("Europe/Berlin"))
outside = datetime(2026, 10, 18, 12, 0, tzinfo=ZoneInfo("Europe/Berlin"))
self.assertTrue(allowed_now(window, now=inside))
self.assertFalse(allowed_now(window, now=outside))
def test_empty_allows_always(self):
self.assertTrue(allowed_now({}))
self.assertTrue(allowed_now(None))
if __name__ == "__main__":
unittest.main()