Add notification for no spool assigned for active trays, improve usage tracker logic in edge cases (#789)

Add notification for no spool assigned for active trays, improve usage tracker logic in edge cases (#789)
This commit is contained in:
Keybored
2026-03-24 11:31:32 +01:00
committed by GitHub
parent dd75c8f0c9
commit 3f7d0e2d55
28 changed files with 726 additions and 70 deletions
@@ -9,11 +9,26 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastProvider } from '../../contexts/ToastContext';
// Track WebSocket instances created during tests
let wsInstances: MockWebSocket[] = [];
let originalWebSocket: typeof WebSocket;
// Mock react-i18next BEFORE any modules that use it are imported
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (key === 'printers.toast.missingSpoolAssignment' && options) {
const { printer, slots } = options as { printer: string; slots: string };
return `Missing assignments for ${printer}: ${slots}`;
}
return key;
},
i18n: {},
}),
}));
// Enhanced MockWebSocket that tracks instances
class MockWebSocket {
static readonly CONNECTING = 0;
@@ -77,13 +92,17 @@ function createTestQueryClient() {
});
}
// Wrapper with QueryClient for hook testing
// Wrapper with QueryClient and ToastProvider for hook testing
function createWrapper(queryClient: QueryClient) {
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(
QueryClientProvider,
{ client: queryClient },
children
ToastProvider,
{},
React.createElement(
QueryClientProvider,
{ client: queryClient },
children
)
);
};
}
@@ -99,6 +118,7 @@ describe('useWebSocket hook', () => {
vi.clearAllMocks();
wsInstances = [];
queryClient = createTestQueryClient();
// Save original and install mock
originalWebSocket = globalThis.WebSocket;
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
@@ -164,8 +184,6 @@ describe('useWebSocket hook', () => {
describe('hook connection', () => {
it('connects to WebSocket on mount', async () => {
// Reset module cache to get fresh import with our mock
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
renderHook(() => useWebSocket(), {
@@ -178,7 +196,6 @@ describe('useWebSocket hook', () => {
});
it('reports connected state when WebSocket opens', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const { result } = renderHook(() => useWebSocket(), {
@@ -260,7 +277,6 @@ describe('useWebSocket hook', () => {
cb(0);
return 0;
});
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -303,7 +319,6 @@ describe('useWebSocket hook', () => {
cb(0);
return 0;
});
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -345,7 +360,6 @@ describe('useWebSocket hook', () => {
cb(0);
return 0;
});
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -380,8 +394,39 @@ describe('useWebSocket hook', () => {
vi.unstubAllGlobals();
});
it('handles missing_spool_assignment message without error', async () => {
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
const { useWebSocket } = await import('../../hooks/useWebSocket');
renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
act(() => {
ws.open();
});
// This test verifies that the hook properly handles missing_spool_assignment messages
// without throwing an error. The actual toast display is tested via the UI.
expect(() => {
act(() => {
ws.simulateMessage({
type: 'missing_spool_assignment',
printer_id: 7,
printer_name: 'Printer B',
missing_slots: [{ slot: 'A2' }, { slot: 'Ext-L' }],
});
});
}).not.toThrow();
vi.unstubAllGlobals();
});
it('ignores pong messages without error', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -409,7 +454,6 @@ describe('useWebSocket hook', () => {
});
it('handles malformed JSON gracefully', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
renderHook(() => useWebSocket(), {
@@ -438,7 +482,6 @@ describe('useWebSocket hook', () => {
});
it('handles unknown message types gracefully', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -470,7 +513,6 @@ describe('useWebSocket hook', () => {
describe('sendMessage', () => {
it('sends JSON message when connected', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const { result } = renderHook(() => useWebSocket(), {
@@ -494,7 +536,6 @@ describe('useWebSocket hook', () => {
});
it('does not send when disconnected', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const { result } = renderHook(() => useWebSocket(), {
@@ -516,7 +557,6 @@ describe('useWebSocket hook', () => {
describe('reconnection', () => {
it('reconnects after connection closes', async () => {
vi.useFakeTimers();
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
@@ -551,7 +591,6 @@ describe('useWebSocket hook', () => {
});
it('cleans up on unmount', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const { unmount } = renderHook(() => useWebSocket(), {
+3
View File
@@ -1469,6 +1469,7 @@ export interface NotificationProvider {
on_print_failed: boolean;
on_print_stopped: boolean;
on_print_progress: boolean;
on_print_missing_spool_assignment: boolean;
// Printer status events
on_printer_offline: boolean;
on_printer_error: boolean;
@@ -1523,6 +1524,7 @@ export interface NotificationProviderCreate {
on_print_failed?: boolean;
on_print_stopped?: boolean;
on_print_progress?: boolean;
on_print_missing_spool_assignment?: boolean;
// Printer status events
on_printer_offline?: boolean;
on_printer_error?: boolean;
@@ -1570,6 +1572,7 @@ export interface NotificationProviderUpdate {
on_print_failed?: boolean;
on_print_stopped?: boolean;
on_print_progress?: boolean;
on_print_missing_spool_assignment?: boolean;
// Printer status events
on_printer_offline?: boolean;
on_printer_error?: boolean;
@@ -161,6 +161,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{provider.on_first_layer_complete && (
<span className="px-2 py-0.5 bg-emerald-600/20 text-emerald-300 text-xs rounded">{t('notifications.firstLayer')}</span>
)}
{provider.on_print_missing_spool_assignment && (
<span className="px-2 py-0.5 bg-amber-500/20 text-amber-300 text-xs rounded">{t('notifications.missingSpoolAssignmentLabel')}</span>
)}
{provider.quiet_hours_enabled && (
<span className="px-2 py-0.5 bg-indigo-500/20 text-indigo-400 text-xs rounded flex items-center gap-1">
<Moon className="w-3 h-3" />
@@ -296,6 +299,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">{t('notifications.missingSpoolAssignmentLabel')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.missingSpoolAssignmentDescription')}</p>
</div>
<Toggle
checked={provider.on_print_missing_spool_assignment ?? false}
onChange={(checked) => updateMutation.mutate({ on_print_missing_spool_assignment: checked })}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">{t('notifications.printFailed')}</p>
<Toggle
+4 -1
View File
@@ -6,6 +6,8 @@ import { formatFileSize } from '../utils/file';
type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
type ShowPersistentToast = (id: string, message: string, type?: ToastType) => void;
interface Toast {
id: string;
message: string;
@@ -38,7 +40,7 @@ interface DispatchToastData {
interface ToastContextType {
showToast: (message: string, type?: ToastType) => void;
showPersistentToast: (id: string, message: string, type?: ToastType) => void;
showPersistentToast: ShowPersistentToast;
dismissToast: (id: string) => void;
}
@@ -460,6 +462,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener('background-dispatch', onDispatchEvent);
}, [t]);
return (
<ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast }}>
{children}
+37 -1
View File
@@ -1,10 +1,14 @@
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useToast } from '../contexts/ToastContext';
import { useTranslation } from 'react-i18next';
interface WebSocketMessage {
type: string;
printer_id?: number;
data?: Record<string, unknown>;
printer_name?: string;
missing_slots?: Array<{ slot?: string }>;
}
export function useWebSocket() {
@@ -12,6 +16,9 @@ export function useWebSocket() {
const reconnectTimeoutRef = useRef<number | null>(null);
const queryClient = useQueryClient();
const [isConnected, setIsConnected] = useState(false);
const lastMissingSpoolWarningRef = useRef<Map<number, string>>(new Map());
const { showToast } = useToast();
const { t } = useTranslation();
// Debounce invalidations to prevent rapid re-render cascades
const pendingInvalidations = useRef<Set<string>>(new Set());
@@ -195,6 +202,35 @@ export function useWebSocket() {
}
break;
case 'missing_spool_assignment': {
if (message.printer_id === undefined || !Array.isArray(message.missing_slots)) {
break;
}
const missingSlotLabels = message.missing_slots
.map((slot) => (slot && typeof slot.slot === 'string' ? slot.slot : 'Unknown'))
.filter((slot) => slot.length > 0);
if (missingSlotLabels.length === 0) {
lastMissingSpoolWarningRef.current.delete(message.printer_id);
break;
}
const signature = missingSlotLabels.join('|');
if (lastMissingSpoolWarningRef.current.get(message.printer_id) === signature) {
break;
}
lastMissingSpoolWarningRef.current.set(message.printer_id, signature);
const printerName = message.printer_name || `Printer ${message.printer_id}`;
const toastMsg = t('printers.toast.missingSpoolAssignment', {
printer: printerName,
slots: missingSlotLabels.join(', '),
});
showToast(toastMsg, 'warning');
break;
}
case 'print_complete':
// Don't invalidate printerStatus here - it causes re-render cascade and browser freeze
// The printer_status websocket messages will naturally update the status
@@ -301,7 +337,7 @@ export function useWebSocket() {
debouncedInvalidate('spoolbuddy-update-check');
break;
}
}, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate]);
}, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate, showToast, t]);
// Keep the ref updated with latest handleMessage
useEffect(() => {
+3
View File
@@ -265,6 +265,7 @@ export default {
// Toast messages
toast: {
printerDeleted: 'Drucker gelöscht',
missingSpoolAssignment: 'Druck gestartet auf {{printer}}. Fehlende Spulenzuordnung für: {{slots}}',
printerAdded: 'Drucker hinzugefügt',
printerUpdated: 'Drucker aktualisiert',
failedToDelete: 'Drucker konnte nicht gelöscht werden',
@@ -3821,6 +3822,8 @@ export default {
bedCooledDescription: 'Bett nach dem Druck unter Schwellenwert abgekühlt',
firstLayerCompleteLabel: 'Erste Schicht fertig',
firstLayerCompleteDescription: 'Benachrichtigung mit Foto nach erster Schicht',
missingSpoolAssignmentLabel: 'Fehlende Spulenzuordnung',
missingSpoolAssignmentDescription: 'Benachrichtigen, wenn ein Druck startet und benoetigte Schaechte keine zugeordnete Spule haben',
printFailed: 'Druck fehlgeschlagen',
printStopped: 'Druck gestoppt',
progressMilestones: 'Fortschrittsmeilensteine',
+3
View File
@@ -265,6 +265,7 @@ export default {
// Toast messages
toast: {
printerDeleted: 'Printer deleted',
missingSpoolAssignment: 'Print started on {{printer}}. Missing spool assignment for: {{slots}}',
printerAdded: 'Printer added',
printerUpdated: 'Printer updated',
failedToDelete: 'Failed to delete printer',
@@ -3826,6 +3827,8 @@ export default {
bedCooledDescription: 'Bed cooled below threshold after print',
firstLayerCompleteLabel: 'First Layer Complete',
firstLayerCompleteDescription: 'Notify with snapshot when first layer finishes',
missingSpoolAssignmentLabel: 'Missing Spool Assignment',
missingSpoolAssignmentDescription: 'Notify when print starts and required trays have no assigned spool',
printFailed: 'Print Failed',
printStopped: 'Print Stopped',
progressMilestones: 'Progress Milestones',
+3
View File
@@ -265,6 +265,7 @@ export default {
// Toast messages
toast: {
printerDeleted: 'Imprimante supprimée',
missingSpoolAssignment: 'Impression démarrée sur {{printer}}. Attribution de bobine manquante pour : {{slots}}',
printerAdded: 'Imprimante ajoutée',
printerUpdated: 'Imprimante mise à jour',
failedToDelete: 'Échec de la suppression',
@@ -3813,6 +3814,8 @@ export default {
bedCooledDescription: 'Plateau refroidi sous le seuil après l\'impression',
firstLayerCompleteLabel: 'Première couche terminée',
firstLayerCompleteDescription: 'Notification avec photo après la première couche',
missingSpoolAssignmentLabel: 'Affectation de bobine manquante',
missingSpoolAssignmentDescription: 'Notifier quand une impression démarre et que des bacs requis n\'ont pas de bobine assignée',
printFailed: 'Impression échouée',
printStopped: 'Impression arrêtée',
progressMilestones: 'Jalons de progression',
+3
View File
@@ -265,6 +265,7 @@ export default {
// Toast messages
toast: {
printerDeleted: 'Stampante eliminata',
missingSpoolAssignment: 'Stampa avviata su {{printer}}. Mancano assegnazioni bobina per: {{slots}}',
printerAdded: 'Stampante aggiunta',
printerUpdated: 'Stampante aggiornata',
failedToDelete: 'Impossibile eliminare stampante',
@@ -3812,6 +3813,8 @@ export default {
bedCooledDescription: 'Piatto raffreddato sotto la soglia dopo la stampa',
firstLayerCompleteLabel: 'Primo strato completato',
firstLayerCompleteDescription: 'Notifica con foto al termine del primo strato',
missingSpoolAssignmentLabel: 'Assegnazione bobina mancante',
missingSpoolAssignmentDescription: 'Notifica quando una stampa parte e i vassoi richiesti non hanno una bobina assegnata',
printFailed: 'Stampa fallita',
printStopped: 'Stampa interrotta',
progressMilestones: 'Traguardi di avanzamento',
+3
View File
@@ -264,6 +264,7 @@ export default {
// Toast messages
toast: {
printerDeleted: 'プリンターを削除しました',
missingSpoolAssignment: '{{printer}}で印刷を開始しました。以下のスプール割り当てがありません: {{slots}}',
printerAdded: 'プリンターを追加しました',
printerUpdated: 'プリンターを更新しました',
failedToDelete: 'プリンターの削除に失敗しました',
@@ -3825,6 +3826,8 @@ export default {
bedCooledDescription: '印刷後にベッドがしきい値以下に冷却',
firstLayerCompleteLabel: '第1層完了',
firstLayerCompleteDescription: '第1層完了時にスナップショット付きで通知',
missingSpoolAssignmentLabel: 'スプール割り当て不足',
missingSpoolAssignmentDescription: '印刷開始時に必要トレイへスプールが未割り当ての場合に通知',
printFailed: '印刷失敗',
printStopped: '印刷停止',
progressMilestones: '進捗マイルストーン',
+3
View File
@@ -265,6 +265,7 @@ export default {
// Toast messages
toast: {
printerDeleted: 'Impressora excluída',
missingSpoolAssignment: 'Impressão iniciada em {{printer}}. Atribuição de bobina ausente para: {{slots}}',
printerAdded: 'Impressora adicionada',
printerUpdated: 'Impressora atualizada',
failedToDelete: 'Falha ao excluir impressora',
@@ -3812,6 +3813,8 @@ export default {
bedCooledDescription: 'Mesa resfriou abaixo do limite após a impressão',
firstLayerCompleteLabel: 'Primeira camada concluída',
firstLayerCompleteDescription: 'Notificar com foto quando a primeira camada terminar',
missingSpoolAssignmentLabel: 'Atribuição de bobina ausente',
missingSpoolAssignmentDescription: 'Notificar quando a impressão iniciar e bandejas necessárias não tiverem bobina atribuída',
printFailed: 'Impressão Falhou',
printStopped: 'Impressão Parada',
progressMilestones: 'Marcos de Progresso',
+3
View File
@@ -265,6 +265,7 @@ export default {
// Toast messages
toast: {
printerDeleted: '打印机已删除',
missingSpoolAssignment: '已在{{printer}}上开始打印。以下料槽未分配耗材: {{slots}}',
printerAdded: '打印机已添加',
printerUpdated: '打印机已更新',
failedToDelete: '删除打印机失败',
@@ -3812,6 +3813,8 @@ export default {
bedCooledDescription: '打印后热床温度降至阈值以下',
firstLayerCompleteLabel: '首层打印完成',
firstLayerCompleteDescription: '首层完成时发送带照片的通知',
missingSpoolAssignmentLabel: '缺少料卷分配',
missingSpoolAssignmentDescription: '当打印开始且所需料盘没有分配料卷时发送通知',
printFailed: '打印失败',
printStopped: '打印已停止',
progressMilestones: '进度里程碑',