Grotti 3.6.0: Lauf-Identität nach Filament-Pause, robustere Queue, Projekte/Aufträge list-first und API-Polish.
This commit is contained in:
1 parent
d1eb87ba25
commit
2b1e36634a
53 files changed
+4254
-636
No files matched your search
@@ -0,0 +1,107 @@
|
||||
"""Leere History-Hülle, Resume-Beweis und Chargen-Tag."""
|
||||
|
||||
import unittest
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from grotti.jobs import (
|
||||
history_in_order,
|
||||
history_shell_is_empty,
|
||||
is_filament_resume,
|
||||
)
|
||||
from grotti.store import chargen_day, history_job_retired
|
||||
|
||||
|
||||
class HistoryShellTests(unittest.TestCase):
|
||||
def test_pause_shell_is_empty(self):
|
||||
self.assertTrue(history_shell_is_empty({"duration_s": 0, "ended_at": None}))
|
||||
|
||||
def test_recorded_cancel_is_not_a_shell(self):
|
||||
self.assertFalse(history_shell_is_empty({
|
||||
"duration_s": 653,
|
||||
"ended_at": datetime(2026, 10, 18, 13, 18, tzinfo=timezone.utc),
|
||||
}))
|
||||
|
||||
def test_missing_row_is_not_a_shell(self):
|
||||
self.assertFalse(history_shell_is_empty(None))
|
||||
|
||||
|
||||
class FilamentResumeProofTests(unittest.TestCase):
|
||||
def test_duration_continued_with_tiny_gap(self):
|
||||
# 2026-10-25 Kobra 3 A1: 000040 → 000041, 0,04 s Lücke, gleiche print_duration
|
||||
end = datetime(2026, 10, 25, 5, 48, 27, 569483, tzinfo=timezone.utc)
|
||||
start = end + timedelta(milliseconds=40)
|
||||
self.assertTrue(is_filament_resume(
|
||||
end, start, prev_duration_s=11711, new_duration_s=11711
|
||||
))
|
||||
|
||||
def test_fresh_restart_after_cancel_is_not_resume(self):
|
||||
# Lauf 27941: 8 s abgebrochen, 45 s später Neustart mit Dauer ~0
|
||||
end = datetime(2026, 10, 24, 11, 46, 8, tzinfo=timezone.utc)
|
||||
start = end + timedelta(seconds=45)
|
||||
self.assertFalse(is_filament_resume(
|
||||
end, start, prev_duration_s=8, new_duration_s=5
|
||||
))
|
||||
|
||||
def test_two_arg_call_without_proof_is_false(self):
|
||||
end = datetime(2026, 10, 21, 19, 32, 20, tzinfo=timezone.utc)
|
||||
start = end + timedelta(milliseconds=40)
|
||||
self.assertFalse(is_filament_resume(end, start))
|
||||
|
||||
def test_watcher_signal_allows_longer_gap(self):
|
||||
end = datetime(2026, 10, 25, 1, 0, 0, tzinfo=timezone.utc)
|
||||
start = end + timedelta(hours=4)
|
||||
self.assertTrue(is_filament_resume(
|
||||
end, start, prev_duration_s=1000, new_duration_s=1000, watcher_resume=True
|
||||
))
|
||||
|
||||
def test_duration_reset_is_not_resume(self):
|
||||
end = datetime(2026, 10, 25, 5, 48, 27, tzinfo=timezone.utc)
|
||||
start = end + timedelta(milliseconds=40)
|
||||
self.assertFalse(is_filament_resume(
|
||||
end, start, prev_duration_s=11711, new_duration_s=12
|
||||
))
|
||||
|
||||
|
||||
class HistoryOrderTests(unittest.TestCase):
|
||||
def test_cancelled_part_before_resume(self):
|
||||
items = [
|
||||
{"job_id": "000041", "start_time": 1792907307.607},
|
||||
{"job_id": "000040", "start_time": 1792879062.392},
|
||||
]
|
||||
self.assertEqual([j["job_id"] for j in history_in_order(items)], ["000040", "000041"])
|
||||
|
||||
def test_missing_start_first(self):
|
||||
items = [{"job_id": "b", "start_time": 5}, {"job_id": "a"}]
|
||||
self.assertEqual([j["job_id"] for j in history_in_order(items)], ["a", "b"])
|
||||
|
||||
|
||||
class HistoryJobRetiredTests(unittest.TestCase):
|
||||
def _conn(self, row):
|
||||
conn = MagicMock()
|
||||
conn.execute.return_value.fetchone.return_value = row
|
||||
return conn
|
||||
|
||||
def test_folded_predecessor_is_retired(self):
|
||||
conn = self._conn({"moonraker_job_id": "000041"})
|
||||
self.assertTrue(history_job_retired(conn, "h", "000040"))
|
||||
|
||||
def test_own_job_is_not_retired(self):
|
||||
conn = self._conn({"moonraker_job_id": "000041"})
|
||||
self.assertFalse(history_job_retired(conn, "h", "000041"))
|
||||
|
||||
def test_unknown_job_is_not_retired(self):
|
||||
self.assertFalse(history_job_retired(self._conn(None), "h", "000042"))
|
||||
|
||||
|
||||
class ChargenDayTests(unittest.TestCase):
|
||||
def test_old_print_keeps_its_day(self):
|
||||
started = datetime(2026, 10, 18, 13, 4, 16, tzinfo=timezone.utc)
|
||||
self.assertEqual(chargen_day(started, today=date(2026, 10, 24)), date(2026, 10, 18))
|
||||
|
||||
def test_without_start_uses_today(self):
|
||||
self.assertEqual(chargen_day(None, today=date(2026, 10, 24)), date(2026, 10, 24))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -69,6 +69,16 @@ class ChooseSpoolTests(unittest.TestCase):
|
||||
):
|
||||
self.assertEqual(jobs.resolve_tracking_spool(conn, "h", 4), 14)
|
||||
|
||||
def test_ace_target_skips_spool_created_after_end(self):
|
||||
end = datetime(2026, 10, 15, 22, 0, tzinfo=timezone.utc)
|
||||
future = {"id": 18, "created_at": end + timedelta(days=6)}
|
||||
conn = MagicMock()
|
||||
with patch("grotti.jobs.store.spool_at_slot", return_value=future):
|
||||
self.assertEqual(
|
||||
jobs._targets_from_ace(conn, "h", [{"ams_index": 3}], ended_at=end),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_spool_valid_for_end_naive(self):
|
||||
end = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
|
||||
spool = {"created_at": datetime(2026, 9, 1, 12, 0)}
|
||||
|
||||
@@ -53,13 +53,29 @@ class MatchSpoolsTests(unittest.TestCase):
|
||||
},
|
||||
]
|
||||
|
||||
def test_base_sunlu_pla_matches_plain_pla_not_meta(self):
|
||||
def test_base_sunlu_pla_matches_any_pla(self):
|
||||
need = {"profile": "Sunlu PLA", "material_type": "pla", "color": "", "paint_index": 0}
|
||||
matched = store._match_spools_for_need(need, self.have)
|
||||
ids = {m["spool_id"] for m in matched}
|
||||
self.assertEqual(ids, {3}) # Eryone PLA; Meta/Matte nicht
|
||||
self.assertEqual(ids, {1, 2, 3})
|
||||
|
||||
def test_pla_lite_hex_matches_plain_pla(self):
|
||||
def test_base_pla_matches_matte_alone(self):
|
||||
need = {"profile": "Sunlu PLA", "material_type": "pla", "color": "#f2a900", "paint_index": 0}
|
||||
have = [self.have[1]]
|
||||
matched = store._match_spools_for_need(need, have)
|
||||
self.assertEqual([m["spool_id"] for m in matched], [2])
|
||||
|
||||
def test_meta_falls_back_to_any_pla(self):
|
||||
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
|
||||
have = [self.have[1]]
|
||||
matched = store._match_spools_for_need(need, have)
|
||||
self.assertEqual([m["spool_id"] for m in matched], [2])
|
||||
|
||||
def test_petg_does_not_match_pla(self):
|
||||
need = {"profile": "Sunlu PETG", "material_type": "petg", "color": "", "paint_index": 0}
|
||||
self.assertEqual(store._match_spools_for_need(need, self.have), [])
|
||||
|
||||
def test_pla_lite_hex_matches_any_pla(self):
|
||||
# Orca: Profil „Eryone PLA Lite“, Farbe #26A69A — Lager: Eryone/Sunlu PLA „Grau“
|
||||
need = {
|
||||
"profile": "Eryone PLA Lite",
|
||||
@@ -86,7 +102,7 @@ class MatchSpoolsTests(unittest.TestCase):
|
||||
},
|
||||
]
|
||||
matched = store._match_spools_for_need(need, have)
|
||||
self.assertEqual({m["spool_id"] for m in matched}, {15})
|
||||
self.assertEqual({m["spool_id"] for m in matched}, {15, 1})
|
||||
|
||||
def test_picks_meta_not_matte(self):
|
||||
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
|
||||
|
||||
@@ -8,7 +8,15 @@ from unittest import mock
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from grotti.print_queue import PRINT_START_TIMEOUT_S, PrintQueueDispatcher, _cool_wait_text
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from grotti.jobs import is_filament_resume
|
||||
from grotti.print_queue import (
|
||||
PRINT_START_TIMEOUT_S,
|
||||
TERMINAL_CONFIRM_HITS,
|
||||
PrintQueueDispatcher,
|
||||
_cool_wait_text,
|
||||
)
|
||||
|
||||
|
||||
class CoolTextTests(unittest.TestCase):
|
||||
@@ -20,9 +28,26 @@ class CoolTextTests(unittest.TestCase):
|
||||
self.assertNotIn(" · ", text)
|
||||
|
||||
|
||||
class FilamentResumeTests(unittest.TestCase):
|
||||
def test_same_moment_with_duration_is_resume(self):
|
||||
end = datetime(2026, 10, 21, 19, 32, 20, tzinfo=timezone.utc)
|
||||
start = end + timedelta(milliseconds=40)
|
||||
self.assertTrue(is_filament_resume(
|
||||
end, start, prev_duration_s=500, new_duration_s=520
|
||||
))
|
||||
|
||||
def test_later_reprint_is_not_resume(self):
|
||||
end = datetime(2026, 10, 21, 19, 32, 20, tzinfo=timezone.utc)
|
||||
start = end + timedelta(minutes=10)
|
||||
self.assertFalse(is_filament_resume(
|
||||
end, start, prev_duration_s=500, new_duration_s=10
|
||||
))
|
||||
|
||||
|
||||
class DispatcherHelpers(unittest.TestCase):
|
||||
def test_timeout_constant(self):
|
||||
self.assertGreaterEqual(PRINT_START_TIMEOUT_S, 300)
|
||||
self.assertGreaterEqual(TERMINAL_CONFIRM_HITS, 2)
|
||||
|
||||
def test_worker_busy_empty(self):
|
||||
d = PrintQueueDispatcher("postgresql://x", {}, watcher=None)
|
||||
@@ -34,6 +59,21 @@ class DispatcherHelpers(unittest.TestCase):
|
||||
d.cfg = {"auto_queue": False}
|
||||
self.assertFalse(d._auto_enabled())
|
||||
|
||||
def test_no_retry_after_abort(self):
|
||||
d = PrintQueueDispatcher("postgresql://x", {}, watcher=None)
|
||||
self.assertFalse(d._retryable_message("Druck abgebrochen."))
|
||||
self.assertTrue(d._retryable_message("Start-Timeout"))
|
||||
|
||||
def test_advance_simple_ignores_single_cancelled(self):
|
||||
d = PrintQueueDispatcher("postgresql://x", {}, watcher=None)
|
||||
item = {"id": 1, "phase": "print_active", "filename": "a.gcode"}
|
||||
printer = {"host": "h", "name": "P"}
|
||||
live = {"state": "cancelled", "print_active": False}
|
||||
with mock.patch("grotti.print_queue.store.update_print_queue") as upd:
|
||||
d._advance_simple(mock.Mock(), item, printer, live)
|
||||
upd.assert_not_called()
|
||||
self.assertEqual(d._term_hits.get("1:end"), 1)
|
||||
|
||||
|
||||
class ResolveSpoolTests(unittest.TestCase):
|
||||
def test_material_match(self):
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Szenarien für einen physischen Druck = ein Lauf (ohne DB, mit Mocks)."""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from grotti import jobs
|
||||
|
||||
|
||||
class SyncOneJobShellTests(unittest.TestCase):
|
||||
def test_empty_shell_skipped(self):
|
||||
conn = MagicMock()
|
||||
printer = {"host": "h", "name": "P"}
|
||||
job = {
|
||||
"job_id": "000041",
|
||||
"filename": "a.gcode",
|
||||
"status": "in_progress",
|
||||
"start_time": 1000,
|
||||
"end_time": None,
|
||||
"print_duration": 0,
|
||||
"total_duration": 0,
|
||||
"metadata": {},
|
||||
}
|
||||
self.assertEqual(jobs._sync_one_job(conn, {"energy": {}}, printer, job), 0)
|
||||
conn.execute.assert_not_called()
|
||||
|
||||
|
||||
class FoldProofIntegrationLike(unittest.TestCase):
|
||||
def test_fold_requires_duration_proof(self):
|
||||
end = datetime(2026, 10, 25, 5, 48, 27, tzinfo=timezone.utc)
|
||||
start = end + timedelta(milliseconds=40)
|
||||
pred = {
|
||||
"id": 1,
|
||||
"ended_at": end,
|
||||
"duration_s": 11711,
|
||||
"moonraker_job_id": "000040",
|
||||
"verdict": "",
|
||||
}
|
||||
conn = MagicMock()
|
||||
conn.execute.return_value.fetchone.return_value = None
|
||||
with patch("grotti.jobs.store.find_resume_predecessor", return_value=pred), \
|
||||
patch("grotti.jobs.store.remember_run_job"), \
|
||||
patch("grotti.jobs.store.move_run_jobs"), \
|
||||
patch("grotti.jobs.store.set_printer_active_run"), \
|
||||
patch("grotti.jobs.undo_deduction"):
|
||||
# Frischer Neustart (Dauer 12) — kein Fold
|
||||
self.assertIsNone(jobs.fold_filament_resume(
|
||||
conn, "h", "a.gcode", "000041", start, print_duration_s=12
|
||||
))
|
||||
# Fortgesetzte Dauer — Fold
|
||||
self.assertEqual(
|
||||
jobs.fold_filament_resume(
|
||||
conn, "h", "a.gcode", "000041", start, print_duration_s=11711
|
||||
),
|
||||
1,
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("GROTTI_TEST_DB"), "GROTTI_TEST_DB nicht gesetzt")
|
||||
class LiveDbScenarios(unittest.TestCase):
|
||||
"""Gegen echte Test-DB: GROTTI_TEST_DB=postgresql://…"""
|
||||
|
||||
def setUp(self):
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
self.conn = psycopg.connect(os.environ["GROTTI_TEST_DB"], row_factory=dict_row)
|
||||
|
||||
def tearDown(self):
|
||||
self.conn.rollback()
|
||||
self.conn.close()
|
||||
|
||||
def test_connection(self):
|
||||
self.assertTrue(self.conn.execute("SELECT 1").fetchone())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Kontaktverlust beendet einen Druck nicht."""
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from grotti.watcher import StateWatcher, contact_gap_keeps_run
|
||||
|
||||
HOST = "10.10.1.88"
|
||||
FILE = "boden_PETG_0.2_2h15m.gcode"
|
||||
|
||||
|
||||
class ContactGapTests(unittest.TestCase):
|
||||
def test_same_file_stays(self):
|
||||
self.assertTrue(contact_gap_keeps_run(FILE, FILE, "printing"))
|
||||
self.assertTrue(contact_gap_keeps_run(FILE, FILE, "paused"))
|
||||
|
||||
def test_other_file_does_not_stay(self):
|
||||
self.assertFalse(contact_gap_keeps_run(FILE, "andere.gcode", "printing"))
|
||||
|
||||
def test_terminal_state_does_not_stay(self):
|
||||
self.assertFalse(contact_gap_keeps_run(FILE, FILE, "standby"))
|
||||
self.assertFalse(contact_gap_keeps_run(FILE, FILE, "complete"))
|
||||
self.assertFalse(contact_gap_keeps_run(FILE, FILE, "error"))
|
||||
|
||||
def test_missing_name_is_not_a_change(self):
|
||||
self.assertTrue(contact_gap_keeps_run("", FILE, "printing"))
|
||||
self.assertTrue(contact_gap_keeps_run(FILE, "", "paused"))
|
||||
|
||||
|
||||
class WatcherOfflineTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.watcher = StateWatcher("postgresql://x", {})
|
||||
self.watcher._prev[HOST] = {
|
||||
"state": "printing",
|
||||
"offline": False,
|
||||
"filename": FILE,
|
||||
"run_id": 42,
|
||||
}
|
||||
|
||||
def test_two_misses_keep_the_run(self):
|
||||
live = {
|
||||
"host": HOST,
|
||||
"name": "Kobra S1",
|
||||
"state": "offline",
|
||||
"ok": False,
|
||||
"filename": "",
|
||||
}
|
||||
with mock.patch.object(self.watcher, "_emit") as emit, \
|
||||
mock.patch("grotti.watcher.store.complete_open_run") as close, \
|
||||
mock.patch("grotti.watcher.jobs.apply_deduction") as deduct:
|
||||
self.watcher._one(mock.Mock(), live)
|
||||
self.watcher._one(mock.Mock(), live)
|
||||
close.assert_not_called()
|
||||
deduct.assert_not_called()
|
||||
remembered = self.watcher._prev[HOST]
|
||||
self.assertEqual(remembered["state"], "printing")
|
||||
self.assertTrue(remembered["offline"])
|
||||
self.assertEqual(remembered["run_id"], 42)
|
||||
self.assertEqual(remembered["filename"], FILE)
|
||||
self.assertEqual([call.args[3] for call in emit.call_args_list], ["offline"])
|
||||
|
||||
def test_same_print_after_gap_is_the_same_run(self):
|
||||
self.watcher._prev[HOST]["offline"] = True
|
||||
live = {
|
||||
"host": HOST,
|
||||
"name": "Kobra S1",
|
||||
"state": "printing",
|
||||
"ok": True,
|
||||
"filename": FILE,
|
||||
"weight": 0,
|
||||
}
|
||||
run = {"id": 42, "filename": FILE}
|
||||
with mock.patch.object(self.watcher, "_emit") as emit, \
|
||||
mock.patch.object(self.watcher, "_track_print", return_value=run) as track, \
|
||||
mock.patch.object(self.watcher, "_check_spool", return_value=False), \
|
||||
mock.patch("grotti.watcher.store.find_live_run", return_value=run), \
|
||||
mock.patch("grotti.watcher.store.abandon_live_runs") as abandon, \
|
||||
mock.patch("grotti.watcher.store.complete_open_run") as close, \
|
||||
mock.patch("grotti.watcher.jobs.sync_history") as sync, \
|
||||
mock.patch("grotti.watcher.jobs.apply_deduction") as deduct:
|
||||
self.watcher._one(mock.Mock(), live)
|
||||
close.assert_not_called()
|
||||
deduct.assert_not_called()
|
||||
abandon.assert_not_called()
|
||||
sync.assert_not_called()
|
||||
track.assert_called_once()
|
||||
self.assertEqual([call.args[3] for call in emit.call_args_list], ["offline"])
|
||||
self.assertIn("wieder erreichbar", emit.call_args_list[0].args[4])
|
||||
self.assertEqual(self.watcher._prev[HOST]["state"], "printing")
|
||||
self.assertFalse(self.watcher._prev[HOST]["offline"])
|
||||
|
||||
def test_standby_after_gap_closes(self):
|
||||
self.watcher._prev[HOST]["offline"] = True
|
||||
live = {
|
||||
"host": HOST,
|
||||
"name": "Kobra S1",
|
||||
"state": "standby",
|
||||
"ok": True,
|
||||
"filename": "",
|
||||
}
|
||||
run = {"id": 42, "filename": FILE}
|
||||
with mock.patch.object(self.watcher, "_emit") as emit, \
|
||||
mock.patch.object(self.watcher, "_track_print", return_value=None), \
|
||||
mock.patch.object(self.watcher, "_check_spool", return_value=False), \
|
||||
mock.patch("grotti.watcher.store.find_live_run", return_value=run), \
|
||||
mock.patch("grotti.watcher.store.running_print_on_host", return_value=None), \
|
||||
mock.patch("grotti.watcher.store.abandon_live_runs") as abandon, \
|
||||
mock.patch("grotti.watcher.store.set_bed_needs_clear"), \
|
||||
mock.patch("grotti.watcher.jobs.sync_history") as sync:
|
||||
self.watcher._one(mock.Mock(), live)
|
||||
abandon.assert_called_once()
|
||||
sync.assert_called_once()
|
||||
self.assertIn("abbruch", [call.args[3] for call in emit.call_args_list])
|
||||
|
||||
def test_other_file_after_gap_releases_the_old_run(self):
|
||||
self.watcher._prev[HOST]["offline"] = True
|
||||
live = {
|
||||
"host": HOST,
|
||||
"name": "Kobra S1",
|
||||
"state": "printing",
|
||||
"ok": True,
|
||||
"filename": "andere.gcode",
|
||||
"weight": 0,
|
||||
}
|
||||
old = {"id": 42, "filename": FILE}
|
||||
new = {"id": 99, "filename": "andere.gcode"}
|
||||
with mock.patch.object(self.watcher, "_emit") as emit, \
|
||||
mock.patch.object(self.watcher, "_track_print", return_value=new), \
|
||||
mock.patch.object(self.watcher, "_ensure_start_segment"), \
|
||||
mock.patch.object(self.watcher, "_check_spool", return_value=False), \
|
||||
mock.patch("grotti.watcher.store.find_live_run", side_effect=[old, old, None]), \
|
||||
mock.patch("grotti.watcher.store.abandon_live_runs") as abandon, \
|
||||
mock.patch("grotti.watcher.jobs.sync_history") as sync:
|
||||
self.watcher._one(mock.Mock(), live)
|
||||
sync.assert_called_once()
|
||||
self.assertGreaterEqual(abandon.call_count, 1)
|
||||
kinds = [call.args[3] for call in emit.call_args_list]
|
||||
self.assertEqual(kinds, ["abbruch", "offline", "start"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user