- One worker per printer (name, IP, CC2 Control URL, own pinned host key); a spool sits in at most one slot of one printer, moves between printers - Spoolman: location "<printer> Slot N" plus extra fields "drucker"/"slot" (created by the bridge), cleared and "Lager" on removal - Full comparison every minute: the printer wins over manual edits of loaded spools; stale printer locations go to storage; own locations untouched - Automatic migration of v1 settings, state and host key - Status hint when a tag is still in a tray but unmapped (e.g. after "Take out" in CC2 Control) - SSH test: connection drops during exec give a clean error, not a 500 - Mock environment with several printers; tests for all of the above Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
116 lines
4.6 KiB
Python
116 lines
4.6 KiB
Python
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from app.cc2control import CC2Control, CC2Error, clean_text, encode_body, spoolman_id_of, tray_statuses
|
|
from app.spoolman import Spoolman
|
|
|
|
|
|
class Recorder:
|
|
def __init__(self, status=200, body=None):
|
|
self.requests: list[httpx.Request] = []
|
|
self.status = status
|
|
self.body = body if body is not None else {"ok": True}
|
|
|
|
def __call__(self, request: httpx.Request):
|
|
self.requests.append(request)
|
|
return httpx.Response(self.status, json=self.body)
|
|
|
|
|
|
def client(rec):
|
|
return CC2Control("http://10.10.2.202:8081", transport=httpx.MockTransport(rec))
|
|
|
|
|
|
def test_write_request_format():
|
|
rec = Recorder(201, {"ok": True, "id": "a1b2"})
|
|
resp = client(rec).save_spool({"name": "SUNLU005", "brand": "Sunlu", "material": "PLA+", "color": "#1E40AF",
|
|
"diameter": 1.75, "net": 1000.0, "remaining": 812.5, "note": "spoolman:5"})
|
|
assert resp == {"ok": True, "id": "a1b2"}
|
|
r = rec.requests[0]
|
|
assert r.method == "POST" and r.url.path == "/api/spools/save"
|
|
assert r.headers["host"] == "10.10.2.202:8081"
|
|
assert "origin" not in r.headers and "sec-fetch-site" not in r.headers
|
|
assert r.headers["x-cc2-request"] == "1"
|
|
assert "transfer-encoding" not in r.headers
|
|
assert r.headers["content-type"].startswith("text/plain")
|
|
assert r.content.decode() == ("name=SUNLU005\nbrand=Sunlu\nmaterial=PLA+\ncolor=#1E40AF\ndiameter=1.75\n"
|
|
"net=1000\nremaining=812.5\nnote=spoolman:5\n")
|
|
# every header only once (CC2 Control fails closed on duplicates)
|
|
names = [k.lower() for k, _ in r.headers.raw]
|
|
assert len(names) == len(set(names))
|
|
|
|
|
|
def test_get_request_has_no_write_marker_or_origin():
|
|
rec = Recorder(body={"enabled": True, "slots": [], "spools": []})
|
|
assert client(rec).spools()["enabled"] is True
|
|
r = rec.requests[0]
|
|
assert r.method == "GET" and r.url.path == "/api/spools"
|
|
assert r.headers["host"] == "10.10.2.202:8081" and "origin" not in r.headers
|
|
|
|
|
|
def test_assign_and_empty_slot():
|
|
rec = Recorder()
|
|
c = client(rec)
|
|
c.assign(2, "a1b2")
|
|
c.assign(2, None)
|
|
assert rec.requests[0].content == b"slot=2\nspool=a1b2\n"
|
|
assert rec.requests[1].content == b"slot=2\nspool=\n"
|
|
|
|
|
|
def test_enable_sends_bare_word():
|
|
rec = Recorder(body={"ok": True, "enabled": True})
|
|
client(rec).enable(True)
|
|
client(rec).enable(False)
|
|
assert rec.requests[0].url.path == "/api/spools/enable"
|
|
assert rec.requests[0].content == b"on" and rec.requests[1].content == b"off"
|
|
assert rec.requests[0].headers["x-cc2-request"] == "1"
|
|
|
|
|
|
def test_errors_are_reported_without_crash():
|
|
rec = Recorder(400, {"ok": False, "error": "Invalid spool"})
|
|
with pytest.raises(CC2Error, match="400 Invalid spool"):
|
|
client(rec).save_spool({"material": "PLA", "color": "#000000"})
|
|
|
|
def boom(request):
|
|
raise httpx.ConnectError("refused")
|
|
with pytest.raises(CC2Error, match="nicht erreichbar"):
|
|
CC2Control("http://10.10.2.202:8081", transport=httpx.MockTransport(boom)).spools()
|
|
|
|
|
|
def test_text_cleaning():
|
|
assert clean_text('Sun"lu\\ \x01PLA\n') == "Sun lu PLA"
|
|
long = "ä" * 60 # 120 bytes
|
|
out = clean_text(long, 96)
|
|
assert len(out.encode()) <= 96 and out == "ä" * 48
|
|
body = encode_body({"name": "x" * 200, "material": "M" * 50})
|
|
name, material = body.splitlines()
|
|
assert len(name) == len("name=") + 96 and len(material) == len("material=") + 32
|
|
assert encode_body({"note": "a=b", "archived": "0", "skip": None}) == "note=a=b\narchived=0\n"
|
|
|
|
|
|
def test_spoolman_note_and_trays():
|
|
assert spoolman_id_of({"note": "spoolman:17"}) == 17
|
|
assert spoolman_id_of({"note": "gekauft 2025 spoolman:3 rest"}) == 3
|
|
assert spoolman_id_of({"note": "spoolman:x"}) is None
|
|
canvas = {"telemetry": {"result": {"canvas_info": {"canvas_list": [{"tray_list": [
|
|
{"tray_id": 0, "status": 1}, {"tray_id": 1, "status": 0}, {"tray_id": 2, "status": 2}, {"tray_id": 3}]}]}}}}
|
|
assert tray_statuses(canvas) == {0: 1, 1: 0, 2: 2}
|
|
assert tray_statuses({}) == {}
|
|
|
|
|
|
def test_spoolman_requests():
|
|
seen = []
|
|
|
|
def handler(request):
|
|
seen.append(request)
|
|
return httpx.Response(200, json={"id": 5})
|
|
sm = Spoolman("http://10.10.6.10:7912", transport=httpx.MockTransport(handler))
|
|
sm.spool(5)
|
|
sm.update_spool(5, {"location": "CC2 Slot 1"})
|
|
sm.use(5, 12.3456)
|
|
assert [(r.method, r.url.path) for r in seen] == [
|
|
("GET", "/api/v1/spool/5"), ("PATCH", "/api/v1/spool/5"), ("PUT", "/api/v1/spool/5/use")]
|
|
assert json.loads(seen[1].content) == {"location": "CC2 Slot 1"}
|
|
assert json.loads(seen[2].content) == {"use_weight": 12.346}
|