feat(setup): print a real test slip from the printer "Test connection" modal

healthCheck only opens the transport (TCP connect / USB open) — it proves the
printer is REACHABLE, not that paper feeds and the head fires. Add a "Print test
slip" action so the admin can physically confirm a printer is live (the new
host-net USB /dev/usb/lpN path, or a network printer).

- server: POST /api/setup/test-print — printer-only, re-merges stored secrets like
  /test (so an edited network printer authenticates), creates the device, and pushes
  a short slip via the device-agnostic printReport(). Fail-soft: a print error
  (paper out, head fault, transport drop) is reported, never a 500. Mirrors the
  test-anpr pattern.
- web: testPrint() client + PrintTestResult; a button in the device modal shown for
  category=printer, with ok/fail rendering. i18n keys in sq + en (parity holds).

Server 168 tests pass; web + server typecheck clean.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-27 14:36:39 +02:00
parent 045892bc94
commit 3a60367232
5 changed files with 145 additions and 0 deletions
+48
View File
@@ -9,8 +9,10 @@ import {
fetchState,
testAnpr,
testDevice,
testPrint,
unassignDevice,
type AnprTestResult,
type PrintTestResult,
type Assignment,
type BackendIpCandidate,
type ButtonLightSpec,
@@ -339,6 +341,7 @@ function DeviceForm({
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
const isController = category === "access";
const isCamera = category === "camera";
const isPrinter = category === "printer";
// ANPR opt-in for a camera: when true, the VisionReader polls this camera for plates
// (config.anpr). Off by default. See wiki/entities/opencv-anpr-service.md.
const [anpr, setAnpr] = useState<boolean>(editCfg?.anpr === true);
@@ -380,6 +383,10 @@ function DeviceForm({
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
const [anprTesting, setAnprTesting] = useState(false);
const [anprError, setAnprError] = useState<string | null>(null);
const [printResult, setPrintResult] = useState<PrintTestResult | null>(null);
const [printTesting, setPrintTesting] = useState(false);
const [printError, setPrintError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
@@ -531,6 +538,23 @@ function DeviceForm({
}
}
// Push a real test slip to the printer — proves it physically prints (healthCheck
// only opens the transport). Passes editing?.id so an edited network printer's
// stored secrets re-merge. Never blocks save.
async function testPrintNow() {
if (!selected) return;
setPrintTesting(true);
setPrintError(null);
setPrintResult(null);
try {
setPrintResult(await testPrint(selected.id, mergedScalarConfig(), editing?.id));
} catch (e) {
setPrintError((e as Error).message);
} finally {
setPrintTesting(false);
}
}
async function save() {
if (!selected) return;
// Bound devices must point at a controller relay (binding is optional in the
@@ -867,6 +891,30 @@ function DeviceForm({
</div>
)}
{/* PRINTER: push a real test slip so the admin can confirm it physically
prints (healthCheck only opens the transport / USB node). */}
{isPrinter && (
<div className="mt-3 rounded-term border border-term-border bg-term-bg p-2">
<button type="button" className="btn btn-sm" onClick={testPrintNow} disabled={printTesting}>
{printTesting ? t("setup.printTesting") : t("setup.testPrint")}
</button>
<p className="hint mt-1">{t("setup.testPrintHint")}</p>
{printError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: printError })}</p>}
{printResult &&
(printResult.ok ? (
<div className="mt-2 text-[12px] text-term-green">
{t("setup.printOk", { ms: printResult.tookMs })}
</div>
) : (
<div className="mt-2 text-[12px] text-term-amber">
⚠ {t(`setup.printFail.${printResult.reason}`, { defaultValue: printResult.reason })}
{printResult.detail && <span className="text-term-muted"> — {printResult.detail}</span>}
</div>
))}
</div>
)}
{backendIps && backendIps.length > 0 && (
<div className="mt-3">
<div className="field max-w-md">