From e1383c4d56640f3bee509f83f3b94226980e88ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Bregar?= Date: Fri, 10 Apr 2026 22:08:01 +0200 Subject: [PATCH] Add label printing functionality and error handling in stock and label flows --- AGENTS.md | 3 +- README.md | 4 +- src/api/labels.js | 54 ++++ src/features/labels/label-create-page.js | 62 ++++- src/features/stock/stock-detail-page.js | 292 +++++++++++++++++++- src/features/stock/stock-list-page.js | 48 +++- src/styles/app.css | 39 +++ tests/api/labels.test.js | 23 ++ tests/features/labels/upsert-submit.test.js | 49 +++- tests/features/stock/print-label.test.js | 74 +++++ 10 files changed, 627 insertions(+), 21 deletions(-) create mode 100644 tests/api/labels.test.js create mode 100644 tests/features/stock/print-label.test.js diff --git a/AGENTS.md b/AGENTS.md index 268a14c..7803e4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,8 @@ These are current project assumptions and should not be casually changed. - Preview uses label-preview flags - Submit/create flow uses upsert apply (`/kitchen/items/upsert?mode=apply`) -- Auto-print is deferred and should not be assumed in current UI submit flow +- UI exposes a `Print` checkbox next to save (default on for current page session) +- If `Print` is enabled and save succeeds, label printing uses `/kitchen/items/{uuid_b64}/print-label` ### Item-definition search for label creation diff --git a/README.md b/README.md index e61e87b..437fb22 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,8 @@ Expected shapes today: Updates measured or descriptive stock state using `{ quantity }` or `{ level }`. - `POST /{database}/kitchen/items/{uuid_b64}/use` Marks an item used up (`gone`) via stock-event semantics. +- `POST /{database}/kitchen/items/{uuid_b64}/print-label` + Prints label for an existing item; called from the save flow when `Print` is enabled. - `DELETE /{database}/kitchen/items/{uuid_b64}` Compatibility fallback when `/use` is not available on the backend. - `GET /{database}/kitchen/locations` @@ -188,4 +190,4 @@ Expected shapes today: - Local storage only keeps non-sensitive app config, session payload, active kitchen, and label draft state. - Kitchen context now lives in the URL path instead of a custom header. - The API client now builds database-scoped kitchen routes by default; it always keeps bearer authentication handling separate from URL shaping. -- Label submit now uses upsert-first apply semantics; auto-print is intentionally deferred. +- Label submit uses upsert-first apply semantics and an optional `Print` checkbox (default on for the current page session). diff --git a/src/api/labels.js b/src/api/labels.js index fab528d..514635f 100644 --- a/src/api/labels.js +++ b/src/api/labels.js @@ -52,3 +52,57 @@ export async function previewLabel(store, body) { throw new Error('Label preview response did not include an image.'); } + +export async function printItemLabel(store, uuidB64) { + return apiRequest(store, `${getPath('items')}/${uuidB64}/print-label`, { + method: 'POST', + }); +} + +function flattenDetails(details) { + if (!details) { + return ''; + } + + if (typeof details === 'string') { + return details; + } + + if (Array.isArray(details)) { + return details + .map((entry) => (typeof entry === 'string' ? entry : JSON.stringify(entry))) + .join(' | '); + } + + if (typeof details === 'object') { + return Object.entries(details) + .map(([key, value]) => `${key}: ${value}`) + .join(' | '); + } + + return String(details); +} + +export function formatPrintErrorMessage(error) { + const status = error?.status || error?.cause?.status; + const payload = error?.payload || error?.cause?.payload || {}; + const code = String(payload?.code || '').toLowerCase(); + const detailsText = flattenDetails(payload?.details || error?.details || error?.cause?.details); + + let message; + if (code === 'printer_unavailable') { + message = 'Printer is unavailable.'; + } else if (code === 'print_failed') { + message = 'Label printing failed.'; + } else if (status === 503) { + message = 'Printer service is unavailable.'; + } else if (status === 404) { + message = 'Saved item could not be found for printing.'; + } else if (status === 400) { + message = 'Print request was invalid.'; + } else { + message = error?.message || 'Printing failed.'; + } + + return detailsText ? `${message} (${detailsText})` : message; +} diff --git a/src/features/labels/label-create-page.js b/src/features/labels/label-create-page.js index 4b08c7d..a7c09e6 100644 --- a/src/features/labels/label-create-page.js +++ b/src/features/labels/label-create-page.js @@ -4,7 +4,11 @@ import { searchItemDefinitions, } from '../../api/stock.js'; import { fetchLocations } from '../../api/locations.js'; -import { previewLabel } from '../../api/labels.js'; +import { + formatPrintErrorMessage, + previewLabel, + printItemLabel, +} from '../../api/labels.js'; import { STORAGE_KEYS } from '../../app/config.js'; import { debounce, normalizeValidationError } from '../shared/form-utils.js'; import { loadStoredValue, saveStoredValue } from '../shared/storage.js'; @@ -404,7 +408,7 @@ export function renderLabelCreatePage() {
- -
-
- - +
+ + + Print + + +
- +
* Required field @@ -561,6 +575,8 @@ export function labelCreatePageData(store) { submitError: '', fieldErrors: {}, upsertPreview: null, + printLabelOnSave: true, + printIssue: '', form: { ...loadLabelDraft(), }, @@ -997,6 +1013,10 @@ export function labelCreatePageData(store) { return ''; } + if (this.upsertPreview.mode !== 'preview') { + return ''; + } + if (this.upsertPreview.operation === 'update') { const name = this.upsertPreview.matchedItem?.name || this.form.name; const matchType = this.upsertPreview.matchType ? ` (matched by ${this.upsertPreview.matchType})` : ''; @@ -1009,6 +1029,7 @@ export function labelCreatePageData(store) { this.submitError = ''; this.fieldErrors = {}; this.upsertPreview = null; + this.printIssue = ''; if (!this.validateBeforeSubmit()) { this.previewState.error = 'Please fill out the required fields before previewing the label.'; @@ -1035,6 +1056,7 @@ export function labelCreatePageData(store) { async create() { this.submitError = ''; this.fieldErrors = {}; + this.printIssue = ''; if (!this.validateBeforeSubmit()) { this.submitError = 'Please fill out the required fields before saving the stock entry.'; @@ -1044,12 +1066,23 @@ export function labelCreatePageData(store) { await runAsyncState(this.createState, async () => { try { const entry = await applyItemUpsert(store, this.buildUpsertPayload()); - if (this.previewUrl && this.previewUrl.startsWith('blob:')) { - URL.revokeObjectURL(this.previewUrl); - } - this.previewUrl = ''; const entryName = entry.item?.name || this.form.name; const operationVerb = entry.operation === 'update' ? 'updated' : 'created'; + const createdUuidB64 = entry.item?.uuid_b64 || null; + + if (this.printLabelOnSave && createdUuidB64) { + try { + await printItemLabel(store, createdUuidB64); + } catch (printError) { + const parsedPrintMessage = formatPrintErrorMessage(printError); + this.printIssue = parsedPrintMessage; + store.addAlert({ + type: 'warning', + message: `${entryName} was ${operationVerb}, but printing has an issue: ${parsedPrintMessage}`, + }); + } + } + this.successMessage = `${entryName} was ${operationVerb} successfully.`; store.addAlert({ type: 'success', @@ -1074,6 +1107,7 @@ export function labelCreatePageData(store) { this.submitError = ''; this.fieldErrors = {}; this.upsertPreview = null; + this.printIssue = ''; saveStoredValue(STORAGE_KEYS.labelDraft, this.form); if (revokePreview && this.previewUrl.startsWith('blob:')) { URL.revokeObjectURL(this.previewUrl); diff --git a/src/features/stock/stock-detail-page.js b/src/features/stock/stock-detail-page.js index 453329b..a577ae7 100644 --- a/src/features/stock/stock-detail-page.js +++ b/src/features/stock/stock-detail-page.js @@ -3,10 +3,86 @@ import { getStockEntry, useStockItem, } from '../../api/stock.js'; +import { formatPrintErrorMessage, printItemLabel } from '../../api/labels.js'; +import { fetchLocations } from '../../api/locations.js'; import { getRouteContext } from '../../app/router.js'; import { createAsyncState, runAsyncState } from '../shared/ui-state.js'; import { formatDate } from '../shared/date-utils.js'; +function todayAtMidnight() { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), now.getDate()); +} + +function parseDateValue(value) { + if (!value) { + return null; + } + + const [year, month, day] = String(value).split('-').map(Number); + if (!year || !month || !day) { + return null; + } + + return new Date(year, month - 1, day); +} + +function expirationInfo(entry) { + if (!entry?.expire_date) { + return { + key: 'none', + label: 'No expiration date', + detail: 'No expiration date', + }; + } + + const expireDate = parseDateValue(entry.expire_date); + const expireIn = + typeof entry.expire_in === 'number' + ? entry.expire_in + : expireDate + ? Math.round((expireDate - todayAtMidnight()) / (24 * 60 * 60 * 1000)) + : null; + + if (expireIn === null) { + return { + key: 'none', + label: 'No expiration date', + detail: 'No expiration date', + }; + } + + if (expireIn < 0) { + return { + key: 'expired', + label: 'Expired', + detail: `Expired ${Math.abs(expireIn)} day${Math.abs(expireIn) === 1 ? '' : 's'} ago`, + }; + } + + if (expireIn <= 2) { + return { + key: 'use-first', + label: expireIn === 0 ? 'Use today' : 'Use first', + detail: expireIn === 0 ? 'Expires today' : `Expires in ${expireIn} day${expireIn === 1 ? '' : 's'}`, + }; + } + + if (expireIn <= 7) { + return { + key: 'upcoming', + label: 'Upcoming expiration', + detail: `Expires in ${expireIn} days`, + }; + } + + return { + key: 'within-date', + label: 'Within date', + detail: `Expires in ${expireIn} days`, + }; +} + export function renderStockDetailPage() { return `
@@ -42,14 +118,45 @@ export function renderStockDetailPage() {
Quantity
Location
-
+
Production date
Expiration date
+
Expiration status
+
+
+ +
+
+
Stock type
+ +
+

Nutrition

+
+
Nutri-Score
+
+
Nutriments
+
+ + +
+
+
@@ -83,12 +190,23 @@ export function renderStockDetailPage() { +
+ + +
@@ -157,7 +297,13 @@ export function stockDetailPageData(store) { return { state: createAsyncState(), adjustmentState: createAsyncState(), + printState: createAsyncState(), + printFeedback: { + type: '', + message: '', + }, entry: null, + locationPathByUuid: {}, adjustment: { mode: 'increment', quantity: '1', @@ -170,7 +316,16 @@ export function stockDetailPageData(store) { const { params } = getRouteContext(); await runAsyncState(this.state, async () => { - this.entry = await getStockEntry(store, params.id); + const [entry, locations] = await Promise.all([ + getStockEntry(store, params.id), + fetchLocations(store).catch(() => ({ flat: [] })), + ]); + this.entry = entry; + this.locationPathByUuid = Object.fromEntries( + (locations.flat || []) + .filter((location) => location.uuid_b64) + .map((location) => [location.uuid_b64, location.pathLabel || location.name]), + ); this.adjustment.level = this.entry?.level || 'plenty'; }).catch(() => {}); }, @@ -236,11 +391,144 @@ export function stockDetailPageData(store) { window.__loncApp.navigate('/stock'); }).catch(() => {}); }, + async printLabel() { + if (!this.entry?.uuid_b64) { + return; + } + + this.printFeedback = { + type: '', + message: '', + }; + + await runAsyncState(this.printState, async () => { + try { + await printItemLabel(store, this.entry.uuid_b64); + this.printFeedback = { + type: 'success', + message: 'Label printed successfully.', + }; + store.addAlert({ + type: 'success', + message: `${this.entry.name} label sent to printer.`, + }); + } catch (error) { + const parsed = formatPrintErrorMessage(error); + this.printFeedback = { + type: 'warning', + message: parsed, + }; + store.addAlert({ + type: 'warning', + message: `Could not print ${this.entry.name} label: ${parsed}`, + }); + } + }).catch(() => {}); + }, quickAdjust(step) { const current = Number(this.adjustment.quantity || 0); this.adjustment.quantity = String(Math.max(current + step, 0)); }, formatDate, + expirationFor(entry) { + return expirationInfo(entry); + }, + expirationBadgeClass(entry) { + const key = this.expirationFor(entry).key; + if (key === 'expired') { + return 'text-bg-danger'; + } + if (key === 'use-first') { + return 'text-bg-warning'; + } + if (key === 'upcoming') { + return 'text-bg-secondary'; + } + if (key === 'within-date') { + return 'text-bg-success'; + } + return 'text-bg-light border'; + }, + locationLabel(entry) { + const locationUuid = entry?.location_initial_uuid_b64; + if (!locationUuid) { + return 'Unassigned'; + } + + return this.locationPathByUuid[locationUuid] || 'Location not resolved'; + }, + nutriScoreLabel(entry) { + const value = entry?.nutriscore_grade; + if (!value) { + return 'Not available'; + } + + return String(value).toUpperCase(); + }, + nutritionFactsRows(entry) { + const facts = entry?.nutrition_facts; + if (!facts || typeof facts !== 'object' || Array.isArray(facts)) { + return []; + } + + const preferredOrder = [ + 'per', + 'serving_size', + 'energy_kj', + 'energy_kcal', + 'fat', + 'saturated_fat', + 'carbohydrates', + 'sugars', + 'fibers', + 'proteins', + 'salt', + 'sodium', + ]; + const rankByKey = new Map(preferredOrder.map((key, index) => [key, index])); + + return Object.entries(facts) + .sort(([leftKey], [rightKey]) => { + const leftRank = rankByKey.has(leftKey) ? rankByKey.get(leftKey) : Number.POSITIVE_INFINITY; + const rightRank = rankByKey.has(rightKey) ? rankByKey.get(rightKey) : Number.POSITIVE_INFINITY; + + if (leftRank !== rightRank) { + return leftRank - rightRank; + } + + return leftKey.localeCompare(rightKey); + }) + .map(([key, value]) => ({ + key, + label: this.nutritionLabel(key), + value: this.formatNutritionValue(value), + })); + }, + nutritionLabel(key) { + const labels = { + per: 'Per', + serving_size: 'Serving size', + energy_kj: 'Energy (kJ)', + energy_kcal: 'Energy (kcal)', + fat: 'Fat', + saturated_fat: 'Saturated fat', + carbohydrates: 'Carbohydrates', + sugars: 'Sugars', + fibers: 'Fibers', + proteins: 'Proteins', + salt: 'Salt', + sodium: 'Sodium', + }; + + return labels[key] || key.replace(/_/g, ' '); + }, + formatNutritionValue(value) { + if (value === null || value === undefined || value === '') { + return 'n/a'; + } + + return String(value); + }, formatQuantity(entry) { return `${entry.quantity ?? 0} ${entry.uom_symbol || ''}`.trim(); }, diff --git a/src/features/stock/stock-list-page.js b/src/features/stock/stock-list-page.js index bb85855..677091e 100644 --- a/src/features/stock/stock-list-page.js +++ b/src/features/stock/stock-list-page.js @@ -660,7 +660,7 @@ export function renderStockListPage() {
@@ -1231,6 +1236,26 @@ export function stockListPageData(store) { async markGone(entry) { await this.useEntry(entry); }, + async markGoneFromGroup(item, group) { + this.editErrors[item.id] = ''; + + try { + const result = await useStockItem(store, item.uuid_b64); + const alreadyGone = result.status === 'already_gone'; + this.removeGroupedItem(group.id, item.id); + this.entries = this.entries.filter((candidate) => candidate.id !== item.id); + delete this.editForms[item.id]; + delete this.editErrors[item.id]; + store.addAlert({ + type: alreadyGone ? 'info' : 'success', + message: alreadyGone + ? `${item.name} was already out of stock and removed from the group.` + : `${item.name} was marked gone and removed from the group.`, + }); + } catch (error) { + this.editErrors[item.id] = error.message || 'Mark gone failed.'; + } + }, async saveEntryUpdate(entry, payload, localPatch) { this.editErrors[entry.id] = ''; @@ -1273,5 +1298,24 @@ export function stockListPageData(store) { quantity: nextEntry.quantity ?? '', }; }, + removeGroupedItem(groupId, itemId) { + this.groupedEntries = this.groupedEntries + .map((group) => { + if (group.id !== groupId) { + return group; + } + + const nextItems = (group.items || []).filter((candidate) => candidate.id !== itemId); + if (!nextItems.length) { + return null; + } + + return { + ...group, + items: nextItems, + }; + }) + .filter(Boolean); + }, }; } diff --git a/src/styles/app.css b/src/styles/app.css index f5665fc..28d0d38 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -31,6 +31,40 @@ body { backdrop-filter: blur(4px); } +.input-group-label-submit .input-group-text { + background: rgba(255, 255, 255, 0.9); + white-space: nowrap; +} + +.label-actions-row .btn { + white-space: nowrap; +} + +.input-group-label-submit { + width: auto; + flex: 0 0 auto; +} + +.input-group-label-submit .btn { + white-space: nowrap; +} + +.label-action-btn { + white-space: nowrap !important; + flex: 0 0 auto; + min-width: max-content; +} + +@media (min-width: 768px) { + .label-actions-row { + flex-wrap: nowrap !important; + } + + .label-actions-primary { + flex-wrap: nowrap !important; + } +} + .brand-mark { display: inline-grid; place-items: center; @@ -647,6 +681,11 @@ button.legend-card:focus-visible { justify-content: flex-start; } +.grouped-stock-mark-gone { + align-self: center; + white-space: nowrap; +} + .grouped-stock-item-subline { display: flex; flex-wrap: wrap; diff --git a/tests/api/labels.test.js b/tests/api/labels.test.js new file mode 100644 index 0000000..cf1e8b9 --- /dev/null +++ b/tests/api/labels.test.js @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { formatPrintErrorMessage } from '../../src/api/labels.js'; + +describe('api/labels formatPrintErrorMessage', () => { + it('maps printer_unavailable payload to user-friendly message', () => { + const message = formatPrintErrorMessage({ + status: 503, + payload: { + code: 'printer_unavailable', + message: 'Backend says unavailable', + details: { printer: 'Office Zebra' }, + }, + }); + + expect(message).toBe('Printer is unavailable. (printer: Office Zebra)'); + }); + + it('falls back to generic message when payload is missing', () => { + const message = formatPrintErrorMessage(new Error('Something failed')); + expect(message).toBe('Something failed'); + }); +}); diff --git a/tests/features/labels/upsert-submit.test.js b/tests/features/labels/upsert-submit.test.js index ed6acea..5a42434 100644 --- a/tests/features/labels/upsert-submit.test.js +++ b/tests/features/labels/upsert-submit.test.js @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; const applyItemUpsertMock = vi.fn(); const previewItemUpsertMock = vi.fn(); +const printItemLabelMock = vi.fn(); vi.mock('../../../src/api/stock.js', () => ({ applyItemUpsert: (...args) => applyItemUpsertMock(...args), @@ -11,6 +12,8 @@ vi.mock('../../../src/api/stock.js', () => ({ vi.mock('../../../src/api/labels.js', () => ({ previewLabel: vi.fn(async () => ({ objectUrl: 'blob:preview' })), + printItemLabel: (...args) => printItemLabelMock(...args), + formatPrintErrorMessage: (error) => error?.message || 'Printing failed.', })); vi.mock('../../../src/api/locations.js', () => ({ @@ -20,6 +23,15 @@ vi.mock('../../../src/api/locations.js', () => ({ const { labelCreatePageData } = await import('../../../src/features/labels/label-create-page.js'); describe('label create upsert-first submit', () => { + it('defaults print checkbox to enabled', () => { + const data = labelCreatePageData({ + isConnected: false, + activeKitchen: { id: 1 }, + addAlert: vi.fn(), + }); + expect(data.printLabelOnSave).toBe(true); + }); + it('builds upsert payload with selected template uuid', () => { const store = { isConnected: false, @@ -53,8 +65,9 @@ describe('label create upsert-first submit', () => { it('create uses applyItemUpsert and sets operation-aware success message', async () => { applyItemUpsertMock.mockResolvedValueOnce({ operation: 'update', - item: { name: 'Rice' }, + item: { name: 'Rice', uuid_b64: 'uuid-rice-1' }, }); + printItemLabelMock.mockResolvedValueOnce(null); const addAlert = vi.fn(); const store = { @@ -77,10 +90,44 @@ describe('label create upsert-first submit', () => { expect(applyItemUpsertMock).toHaveBeenCalledTimes(1); expect(applyItemUpsertMock.mock.calls[0][1].uuid_b64).toBe('uuid-rice-1'); + expect(printItemLabelMock).toHaveBeenCalledWith(store, 'uuid-rice-1'); expect(data.successMessage).toBe('Rice was updated successfully.'); expect(addAlert).toHaveBeenCalledWith({ type: 'success', message: 'Rice was updated successfully.', }); }); + + it('create shows parsed print issue warning when printing fails', async () => { + applyItemUpsertMock.mockResolvedValueOnce({ + operation: 'create', + item: { name: 'Beans', uuid_b64: 'uuid-beans-1' }, + }); + printItemLabelMock.mockRejectedValueOnce(new Error('Printer is unavailable.')); + + const addAlert = vi.fn(); + const store = { + isConnected: false, + activeKitchen: { id: 3 }, + addAlert, + }; + const data = labelCreatePageData(store); + data.validateBeforeSubmit = () => true; + data.form = { + ...data.form, + name: 'Beans', + stockType: 'binary', + locationId: '', + productionDate: '2026-04-10', + itemUuidB64: '', + }; + + await data.create(); + + expect(data.printIssue).toBe('Printer is unavailable.'); + expect(addAlert).toHaveBeenCalledWith({ + type: 'warning', + message: 'Beans was created, but printing has an issue: Printer is unavailable.', + }); + }); }); diff --git a/tests/features/stock/print-label.test.js b/tests/features/stock/print-label.test.js new file mode 100644 index 0000000..8caccf7 --- /dev/null +++ b/tests/features/stock/print-label.test.js @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const printItemLabelMock = vi.fn(); +const formatPrintErrorMessageMock = vi.fn(); + +vi.mock('../../../src/api/labels.js', () => ({ + printItemLabel: (...args) => printItemLabelMock(...args), + formatPrintErrorMessage: (...args) => formatPrintErrorMessageMock(...args), +})); + +vi.mock('../../../src/api/stock.js', () => ({ + getStockEntry: vi.fn(), + adjustStockEntry: vi.fn(), + useStockItem: vi.fn(), + listStockEntries: vi.fn(async () => []), + listGroupedStockEntries: vi.fn(async () => []), + updateStockItem: vi.fn(), +})); + +vi.mock('../../../src/api/locations.js', () => ({ + fetchLocations: vi.fn(async () => ({ flat: [], tree: [] })), +})); + +const { stockDetailPageData } = await import('../../../src/features/stock/stock-detail-page.js'); + +describe('stock print label actions', () => { + beforeEach(() => { + printItemLabelMock.mockReset(); + formatPrintErrorMessageMock.mockReset(); + globalThis.window = { + __loncApp: { + navigate: vi.fn(), + }, + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete globalThis.window; + }); + + it('prints from stock detail and shows success alert', async () => { + printItemLabelMock.mockResolvedValueOnce(null); + const addAlert = vi.fn(); + const store = { addAlert }; + const data = stockDetailPageData(store); + data.entry = { uuid_b64: 'uuid-1', name: 'Rice' }; + + await data.printLabel(); + + expect(printItemLabelMock).toHaveBeenCalledWith(store, 'uuid-1'); + expect(addAlert).toHaveBeenCalledWith({ + type: 'success', + message: 'Rice label sent to printer.', + }); + }); + + it('shows parsed warning when detail printing fails', async () => { + printItemLabelMock.mockRejectedValueOnce(new Error('boom')); + formatPrintErrorMessageMock.mockReturnValueOnce('Printer unavailable.'); + const addAlert = vi.fn(); + const store = { addAlert }; + const data = stockDetailPageData(store); + data.entry = { uuid_b64: 'uuid-1', name: 'Rice' }; + + await data.printLabel(); + + expect(addAlert).toHaveBeenCalledWith({ + type: 'warning', + message: 'Could not print Rice label: Printer unavailable.', + }); + }); + +});