mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
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:
@@ -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(), {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '進捗マイルストーン',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '进度里程碑',
|
||||
|
||||
Reference in New Issue
Block a user