forked from viewit/KX-Bridge-Release
feat(browser): add second tab for files on the printer's own storage
The GCode browser previously only showed files the bridge itself had
stored (its own SQLite GCodeStore, uploaded through the bridge). Files
printed directly via Anycubic Slicer Next (bypassing the bridge) land
on the printer's internal storage instead, and were only visible/
manageable from the printer's own display.
Adds a second sub-tab ("On Printer") using the same master-detail
tab pattern already used for Settings categories (showSettingsCat),
backed by the printer's file/listLocal and file/deleteBatch MQTT
actions - verified live against a real Kobra X, see memory
reference_mqtt_listlocal.md.
Key implementation detail: publish()'s own return value for these
actions is just a generic immediate ACK skeleton (code=0, empty
fields) - the real response arrives asynchronously via the file/report
callback (_on_file), same as the existing fileDetails fire-and-forget
pattern. Added _wait_for_file_action() as a small reusable bridge
between that async callback delivery and the synchronous HTTP handler,
via a per-action threading.Event registered in _on_file.
New endpoints: GET /kx/printer-files, POST /kx/printer-files/delete
(single endpoint for both single- and multi-select delete, since
deleteBatch natively accepts a filename list).
Frontend mirrors the existing store multi-select pattern (Issue #94):
select mode, select-all (scoped to what's loaded), bulk delete with
confirmation. No print/download actions in this tab for now - printing
a file already on the printer without re-uploading needs its own MQTT
schema that hasn't been verified yet.
Verified end-to-end against the real printer: listed 146 files,
deleted one, confirmed via a follow-up list that it was gone and
nothing else was affected. Also verified visually (Playwright):
tab switching, card rendering, multi-select mode, and cancel all work
as expected.
This commit is contained in:
144
tests/test_printer_files_endpoint.py
Normal file
144
tests/test_printer_files_endpoint.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Tests für /kx/printer-files (list) und /kx/printer-files/delete —
|
||||
der zweite Browser-Tab, der Dateien auf dem Drucker selbst zeigt
|
||||
(via MQTT file/listLocal + file/deleteBatch, live gegen den echten
|
||||
Kobra X verifiziert, siehe Memory reference_mqtt_listlocal.md).
|
||||
|
||||
Important: publish()'s own return value for these actions is just a
|
||||
generic immediate ACK skeleton (code=0, empty fields) - the real answer
|
||||
arrives asynchronously via the file/report callback (_on_file), same as
|
||||
the existing fileDetails fire-and-forget pattern. So publish() itself
|
||||
returns None/skeleton here, and the "real" response is delivered by
|
||||
firing bridge._on_file(...) from a background thread, simulating what
|
||||
the MQTT reader thread would do when the printer's file/report arrives.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
LISTLOCAL_SUCCESS = {
|
||||
"action": "listLocal",
|
||||
"code": 200,
|
||||
"state": "success",
|
||||
"data": {
|
||||
"list_mode": 0,
|
||||
"records": [
|
||||
{"filename": "a.gcode", "is_dir": False, "size": 100, "timestamp": 1700000000000},
|
||||
{"filename": "subdir", "is_dir": True, "size": 0, "timestamp": 1700000001000},
|
||||
{"filename": "b.gcode", "is_dir": False, "size": 200, "timestamp": 1700000002000},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
LISTLOCAL_FAILED = {
|
||||
"action": "listLocal",
|
||||
"code": 10112,
|
||||
"state": "failed",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
DELETEBATCH_SUCCESS = {
|
||||
"action": "deleteBatch",
|
||||
"code": 200,
|
||||
"state": "success",
|
||||
"data": None,
|
||||
"msg": "done",
|
||||
}
|
||||
|
||||
DELETEBATCH_FAILED = {
|
||||
"action": "deleteBatch",
|
||||
"code": 10112,
|
||||
"state": "failed",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
|
||||
def _deliver_async(bridge, payload, delay=0.05):
|
||||
"""Simulates the MQTT reader thread delivering a file/report a moment
|
||||
after the fire-and-forget publish() call returns."""
|
||||
def _fire():
|
||||
time.sleep(delay)
|
||||
bridge._on_file(payload)
|
||||
threading.Thread(target=_fire, daemon=True).start()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_files_lists_files_and_excludes_dirs(client):
|
||||
c, bridge = client
|
||||
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_SUCCESS), None)[1]
|
||||
resp = await c.get("/kx/printer-files")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
filenames = [f["filename"] for f in data["result"]]
|
||||
assert filenames == ["a.gcode", "b.gcode"] # "subdir" (is_dir=True) excluded
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_files_uses_correct_mqtt_payload(client):
|
||||
c, bridge = client
|
||||
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_SUCCESS), None)[1]
|
||||
await c.get("/kx/printer-files")
|
||||
args, kwargs = bridge.client.publish.call_args
|
||||
assert args[0] == "file"
|
||||
assert args[1] == "listLocal"
|
||||
assert args[2] == {"page_num": 1, "page_size": 200, "path": "/"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_files_returns_502_on_printer_failure(client):
|
||||
c, bridge = client
|
||||
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_FAILED), None)[1]
|
||||
resp = await c.get("/kx/printer-files")
|
||||
assert resp.status == 502
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_files_returns_502_on_timeout(client):
|
||||
c, bridge = client
|
||||
bridge.client.publish.return_value = None # no file/report ever arrives
|
||||
resp = await c.get("/kx/printer-files")
|
||||
assert resp.status == 502
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_file_delete_success(client):
|
||||
c, bridge = client
|
||||
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_SUCCESS), None)[1]
|
||||
resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]})
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["result"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_file_delete_uses_correct_mqtt_payload(client):
|
||||
c, bridge = client
|
||||
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_SUCCESS), None)[1]
|
||||
await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode", "b.gcode"]})
|
||||
args, kwargs = bridge.client.publish.call_args
|
||||
assert args[0] == "file"
|
||||
assert args[1] == "deleteBatch"
|
||||
assert args[2] == {
|
||||
"root": "local",
|
||||
"files": [
|
||||
{"path": "/", "filename": "a.gcode"},
|
||||
{"path": "/", "filename": "b.gcode"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_file_delete_empty_filenames_returns_400(client):
|
||||
c, _ = client
|
||||
resp = await c.post("/kx/printer-files/delete", json={"filenames": []})
|
||||
assert resp.status == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_printer_file_delete_returns_502_on_printer_rejection(client):
|
||||
c, bridge = client
|
||||
bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_FAILED), None)[1]
|
||||
resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]})
|
||||
assert resp.status == 502
|
||||
Reference in New Issue
Block a user