mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
feat(notifications): dedicate AI Failure Detection notification event (#1794)
Split Obico failure-detection dispatch out of the multiplexed
on_printer_error event onto its own on_ai_failure_detection event so
users can subscribe to AI alerts without also enabling HMS hardware-
error pages, and so the discoverable label "AI Failure Detection" is
what subscribes them rather than the unrelated "Printer Error" toggle.
New column on notification_providers (default False, branched
SQLite/Postgres migration), new notification_service.on_ai_failure_detection
method, new ai_failure_detection template, obico_actions._notify swap.
Frontend gets a summary badge, a toggle row with description, and ntfy
priority surfacing. 14 new tests pin the routing + the regression guard
("Printer Error" alone must NOT receive AI notifications now). 11 locales
covered.
Existing providers keep working: HMS hardware errors continue to ride
on_printer_error unchanged; users who want spaghetti alerts opt in via
the new toggle.
This commit is contained in:
@@ -40,6 +40,7 @@ function buildProvider(overrides: Partial<NotificationProvider> = {}): Notificat
|
||||
on_print_missing_spool_assignment: false,
|
||||
on_printer_offline: false,
|
||||
on_printer_error: false,
|
||||
on_ai_failure_detection: false,
|
||||
on_filament_low: false,
|
||||
on_maintenance_due: false,
|
||||
on_ams_humidity_high: false,
|
||||
@@ -336,3 +337,52 @@ describe('AddNotificationModal — stock alert toggles', () => {
|
||||
void user; // referenced to avoid unused-var lint warning
|
||||
});
|
||||
});
|
||||
|
||||
describe('AddNotificationModal — AI Failure Detection toggle (#1794)', () => {
|
||||
it('renders the toggle in the Printer Status section', async () => {
|
||||
render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
|
||||
|
||||
expect(await screen.findByText('AI Failure Detection')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('persists on_ai_failure_detection on save (and does NOT touch on_printer_error)', async () => {
|
||||
let captured: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.patch('*/api/v1/notifications/1', async ({ request }) => {
|
||||
captured = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ id: 1 });
|
||||
}),
|
||||
);
|
||||
|
||||
const onClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
|
||||
|
||||
const label = await screen.findByText('AI Failure Detection');
|
||||
const row = label.closest('div.flex')!;
|
||||
const toggle = within(row).getByRole('switch');
|
||||
await user.click(toggle);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured!.on_ai_failure_detection).toBe(true);
|
||||
// Critical regression guard: don't accidentally flip the legacy multiplexed field.
|
||||
expect(captured!.on_printer_error).toBe(false);
|
||||
});
|
||||
|
||||
it('AI Failure Detection appears in ntfy priority section when enabled', async () => {
|
||||
render(
|
||||
<AddNotificationModal
|
||||
provider={buildProvider({ on_ai_failure_detection: true })}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const priorityHeader = await screen.findByText(/ntfy priority/i);
|
||||
const priorityRoot = priorityHeader.closest('div')!;
|
||||
|
||||
expect(within(priorityRoot).getByText('AI Failure Detection')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,7 @@ const createMockProvider = (
|
||||
on_print_progress: false,
|
||||
on_printer_offline: false,
|
||||
on_printer_error: false,
|
||||
on_ai_failure_detection: false,
|
||||
on_filament_low: false,
|
||||
on_maintenance_due: false,
|
||||
on_ams_humidity_high: false,
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Tests for the AI Failure Detection toggle on NotificationProviderCard (#1794).
|
||||
*
|
||||
* Before #1794, Obico failure detection rode the multiplexed
|
||||
* on_printer_error toggle so users couldn't subscribe to one without the
|
||||
* other. These tests pin the standalone toggle:
|
||||
* - Summary badge renders when enabled.
|
||||
* - The toggle row appears in the expanded settings panel.
|
||||
* - Flipping the toggle PATCHes the correct field.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render } from '../utils';
|
||||
import { server } from '../mocks/server';
|
||||
import { NotificationProviderCard } from '../../components/NotificationProviderCard';
|
||||
import type { NotificationProvider } from '../../api/client';
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function buildProvider(overrides: Partial<NotificationProvider> = {}): NotificationProvider {
|
||||
return {
|
||||
id: 1,
|
||||
name: 'Test Provider',
|
||||
provider_type: 'ntfy',
|
||||
enabled: true,
|
||||
config: { server: 'https://ntfy.sh', topic: 'bambuddy' },
|
||||
on_print_start: false,
|
||||
on_print_complete: false,
|
||||
on_print_failed: false,
|
||||
on_print_stopped: false,
|
||||
on_print_progress: false,
|
||||
on_print_missing_spool_assignment: false,
|
||||
on_printer_offline: false,
|
||||
on_printer_error: false,
|
||||
on_ai_failure_detection: false,
|
||||
on_filament_low: false,
|
||||
on_maintenance_due: false,
|
||||
on_ams_humidity_high: false,
|
||||
on_ams_temperature_high: false,
|
||||
on_ams_ht_humidity_high: false,
|
||||
on_ams_ht_temperature_high: false,
|
||||
on_plate_not_empty: false,
|
||||
on_bed_cooled: false,
|
||||
on_first_layer_complete: false,
|
||||
on_queue_job_added: false,
|
||||
on_queue_job_assigned: false,
|
||||
on_queue_job_started: false,
|
||||
on_queue_job_waiting: false,
|
||||
on_queue_job_skipped: false,
|
||||
on_queue_job_failed: false,
|
||||
on_queue_completed: false,
|
||||
on_stock_reorder_alert: false,
|
||||
on_stock_break_alert: false,
|
||||
quiet_hours_enabled: false,
|
||||
quiet_hours_start: null,
|
||||
quiet_hours_end: null,
|
||||
daily_digest_enabled: false,
|
||||
daily_digest_time: null,
|
||||
printer_id: null,
|
||||
last_success: null,
|
||||
last_error: null,
|
||||
last_error_at: null,
|
||||
created_at: '2026-06-22T00:00:00Z',
|
||||
updated_at: '2026-06-22T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('NotificationProviderCard — AI Failure Detection badge', () => {
|
||||
it('renders the badge when on_ai_failure_detection is true', async () => {
|
||||
render(
|
||||
<NotificationProviderCard
|
||||
provider={buildProvider({ on_ai_failure_detection: true })}
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText('AI Failure Detection')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the badge when on_ai_failure_detection is false', async () => {
|
||||
render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
|
||||
await screen.findByText('Test Provider');
|
||||
expect(screen.queryByText('AI Failure Detection')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationProviderCard — AI Failure Detection toggle', () => {
|
||||
it('renders the toggle in the expanded settings panel', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
|
||||
|
||||
await user.click(await screen.findByText(/event settings/i));
|
||||
|
||||
expect(await screen.findByText('AI Failure Detection')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('PATCHes on_ai_failure_detection (NOT on_printer_error) when toggled on — #1794 regression guard', async () => {
|
||||
let captured: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.patch('*/api/v1/notifications/1', async ({ request }) => {
|
||||
captured = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json(buildProvider({ on_ai_failure_detection: true }));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
|
||||
|
||||
await user.click(await screen.findByText(/event settings/i));
|
||||
|
||||
// The toggle label "AI Failure Detection" is unique to this row.
|
||||
const label = await screen.findByText('AI Failure Detection');
|
||||
const row = label.closest('div.flex')!;
|
||||
const toggle = within(row).getByRole('switch');
|
||||
await user.click(toggle);
|
||||
|
||||
await waitFor(() => expect(captured).not.toBeNull());
|
||||
expect(captured).toMatchObject({ on_ai_failure_detection: true });
|
||||
// Critical: must NOT also flip the legacy multiplexed field.
|
||||
expect(captured).not.toHaveProperty('on_printer_error');
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ function buildProvider(overrides: Partial<NotificationProvider> = {}): Notificat
|
||||
on_print_missing_spool_assignment: false,
|
||||
on_printer_offline: false,
|
||||
on_printer_error: false,
|
||||
on_ai_failure_detection: false,
|
||||
on_filament_low: false,
|
||||
on_maintenance_due: false,
|
||||
on_ams_humidity_high: false,
|
||||
|
||||
@@ -50,6 +50,7 @@ const mockNotificationProviders = [
|
||||
on_print_progress: false,
|
||||
on_printer_offline: false,
|
||||
on_printer_error: false,
|
||||
on_ai_failure_detection: false,
|
||||
on_filament_low: false,
|
||||
on_maintenance_due: false,
|
||||
on_ams_humidity_high: false,
|
||||
|
||||
@@ -2172,6 +2172,7 @@ export interface NotificationProvider {
|
||||
// Printer status events
|
||||
on_printer_offline: boolean;
|
||||
on_printer_error: boolean;
|
||||
on_ai_failure_detection: boolean;
|
||||
on_filament_low: boolean;
|
||||
on_maintenance_due: boolean;
|
||||
// AMS environmental alarms (regular AMS)
|
||||
@@ -2230,6 +2231,7 @@ export interface NotificationProviderCreate {
|
||||
// Printer status events
|
||||
on_printer_offline?: boolean;
|
||||
on_printer_error?: boolean;
|
||||
on_ai_failure_detection?: boolean;
|
||||
on_filament_low?: boolean;
|
||||
on_maintenance_due?: boolean;
|
||||
// AMS environmental alarms (regular AMS)
|
||||
@@ -2281,6 +2283,7 @@ export interface NotificationProviderUpdate {
|
||||
// Printer status events
|
||||
on_printer_offline?: boolean;
|
||||
on_printer_error?: boolean;
|
||||
on_ai_failure_detection?: boolean;
|
||||
on_filament_low?: boolean;
|
||||
on_maintenance_due?: boolean;
|
||||
// AMS environmental alarms (regular AMS)
|
||||
|
||||
@@ -38,6 +38,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
|
||||
const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false);
|
||||
const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false);
|
||||
const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);
|
||||
const [onAiFailureDetection, setOnAiFailureDetection] = useState(provider?.on_ai_failure_detection ?? false);
|
||||
const [onFilamentLow, setOnFilamentLow] = useState(provider?.on_filament_low ?? false);
|
||||
const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);
|
||||
const [onStockReorderAlert, setOnStockReorderAlert] = useState(provider?.on_stock_reorder_alert ?? false);
|
||||
@@ -167,6 +168,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
|
||||
on_print_progress: onPrintProgress,
|
||||
on_printer_offline: onPrinterOffline,
|
||||
on_printer_error: onPrinterError,
|
||||
on_ai_failure_detection: onAiFailureDetection,
|
||||
on_filament_low: onFilamentLow,
|
||||
on_maintenance_due: onMaintenanceDue,
|
||||
on_stock_reorder_alert: onStockReorderAlert,
|
||||
@@ -547,6 +549,10 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
|
||||
<span className="text-sm text-white">{t('notifications.error')}</span>
|
||||
<Toggle checked={onPrinterError} onChange={setOnPrinterError} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-white">{t('notifications.aiFailureDetection')}</span>
|
||||
<Toggle checked={onAiFailureDetection} onChange={setOnAiFailureDetection} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-white">{t('notifications.lowFilament')}</span>
|
||||
<Toggle checked={onFilamentLow} onChange={setOnFilamentLow} />
|
||||
@@ -591,6 +597,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
|
||||
if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
|
||||
if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });
|
||||
if (onPrinterError) enabledEvents.push({ key: 'on_printer_error', label: t('notifications.error') });
|
||||
if (onAiFailureDetection) enabledEvents.push({ key: 'on_ai_failure_detection', label: t('notifications.aiFailureDetection') });
|
||||
if (onFilamentLow) enabledEvents.push({ key: 'on_filament_low', label: t('notifications.lowFilament') });
|
||||
if (onMaintenanceDue) enabledEvents.push({ key: 'on_maintenance_due', label: t('notifications.maintenance') });
|
||||
if (onStockReorderAlert) enabledEvents.push({ key: 'on_stock_reorder_alert', label: t('notifications.stockReorderAlert') });
|
||||
|
||||
@@ -138,6 +138,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
|
||||
{provider.on_printer_error && (
|
||||
<span className="px-2 py-0.5 bg-rose-500/20 text-rose-400 text-xs rounded">{t('notifications.error')}</span>
|
||||
)}
|
||||
{provider.on_ai_failure_detection && (
|
||||
<span className="px-2 py-0.5 bg-fuchsia-500/20 text-fuchsia-300 text-xs rounded">{t('notifications.aiFailureDetection')}</span>
|
||||
)}
|
||||
{provider.on_filament_low && (
|
||||
<span className="px-2 py-0.5 bg-cyan-500/20 text-cyan-400 text-xs rounded">{t('notifications.lowFilament')}</span>
|
||||
)}
|
||||
@@ -366,6 +369,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-white">{t('notifications.aiFailureDetection')}</p>
|
||||
<p className="text-xs text-bambu-gray">{t('notifications.aiFailureDetectionDescription')}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={provider.on_ai_failure_detection ?? false}
|
||||
onChange={(checked) => updateMutation.mutate({ on_ai_failure_detection: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-white">{t('notifications.lowFilamentLabel')}</p>
|
||||
<Toggle
|
||||
|
||||
@@ -5018,6 +5018,8 @@ export default {
|
||||
progressMilestonesDescription: 'Benachrichtigung bei 25%, 50%, 75%',
|
||||
printerOffline: 'Drucker offline',
|
||||
printerError: 'Druckerfehler',
|
||||
aiFailureDetection: 'KI-Fehlererkennung',
|
||||
aiFailureDetectionDescription: 'Benachrichtigen, wenn die Obico-KI einen möglichen Druckfehler erkennt',
|
||||
lowFilamentLabel: 'Filament niedrig',
|
||||
maintenanceDue: 'Wartung fällig',
|
||||
maintenanceDueDescription: 'Benachrichtigen, wenn Wartung erforderlich ist',
|
||||
|
||||
@@ -5043,6 +5043,8 @@ export default {
|
||||
progressMilestonesDescription: 'Notify at 25%, 50%, 75%',
|
||||
printerOffline: 'Printer Offline',
|
||||
printerError: 'Printer Error',
|
||||
aiFailureDetection: 'AI Failure Detection',
|
||||
aiFailureDetectionDescription: 'Notify when Obico AI detects a possible print failure',
|
||||
lowFilamentLabel: 'Low Filament',
|
||||
maintenanceDue: 'Maintenance Due',
|
||||
maintenanceDueDescription: 'Notify when maintenance is needed',
|
||||
|
||||
@@ -5027,6 +5027,8 @@ export default {
|
||||
progressMilestonesDescription: 'Notificar al 25%, 50% y 75%',
|
||||
printerOffline: 'Impresora desconectada',
|
||||
printerError: 'Error de la impresora',
|
||||
aiFailureDetection: 'Detección de fallos por IA',
|
||||
aiFailureDetectionDescription: 'Notificar cuando la IA de Obico detecte un posible fallo de impresión',
|
||||
lowFilamentLabel: 'Filamento bajo',
|
||||
maintenanceDue: 'Mantenimiento pendiente',
|
||||
maintenanceDueDescription: 'Notificar cuando se necesite mantenimiento',
|
||||
|
||||
@@ -5008,6 +5008,8 @@ export default {
|
||||
progressMilestonesDescription: 'Notifier à 25 %, 50 %, 75 %',
|
||||
printerOffline: 'Imprimante hors ligne',
|
||||
printerError: 'Erreur de l\'imprimante',
|
||||
aiFailureDetection: 'Détection de défaillance par IA',
|
||||
aiFailureDetectionDescription: 'Notifier lorsque l\'IA Obico détecte une défaillance d\'impression possible',
|
||||
lowFilamentLabel: 'Filament bas',
|
||||
maintenanceDue: 'Maintenance requise',
|
||||
maintenanceDueDescription: 'Notifier lorsqu\'une maintenance est nécessaire',
|
||||
|
||||
@@ -5007,6 +5007,8 @@ export default {
|
||||
progressMilestonesDescription: 'Notifica al 25%, 50%, 75%',
|
||||
printerOffline: 'Stampante offline',
|
||||
printerError: 'Errore stampante',
|
||||
aiFailureDetection: 'Rilevamento guasti IA',
|
||||
aiFailureDetectionDescription: 'Notifica quando l\'IA di Obico rileva un possibile guasto di stampa',
|
||||
lowFilamentLabel: 'Filamento scarso',
|
||||
maintenanceDue: 'Manutenzione necessaria',
|
||||
maintenanceDueDescription: 'Notifica quando è necessaria la manutenzione',
|
||||
|
||||
@@ -5019,6 +5019,8 @@ export default {
|
||||
progressMilestonesDescription: '25%、50%、75%で通知',
|
||||
printerOffline: 'プリンターオフライン',
|
||||
printerError: 'プリンターエラー',
|
||||
aiFailureDetection: 'AI 故障検出',
|
||||
aiFailureDetectionDescription: 'Obico AI が印刷の不具合の可能性を検出したときに通知',
|
||||
lowFilamentLabel: 'フィラメント残量低下',
|
||||
maintenanceDue: 'メンテナンス期限',
|
||||
maintenanceDueDescription: 'メンテナンスが必要な場合に通知',
|
||||
|
||||
@@ -4740,6 +4740,8 @@ export default {
|
||||
progressMilestonesDescription: '25%, 50%, 75%에서 알림',
|
||||
printerOffline: '프린터 오프라인',
|
||||
printerError: '프린터 오류',
|
||||
aiFailureDetection: 'AI 실패 감지',
|
||||
aiFailureDetectionDescription: 'Obico AI가 인쇄 실패 가능성을 감지하면 알림',
|
||||
lowFilamentLabel: '필라멘트 부족',
|
||||
maintenanceDue: '유지 관리 필요',
|
||||
maintenanceDueDescription: '유지 관리가 필요할 때 알림',
|
||||
|
||||
@@ -5007,6 +5007,8 @@ export default {
|
||||
progressMilestonesDescription: 'Notificar em 25%, 50%, 75%',
|
||||
printerOffline: 'Impressora Offline',
|
||||
printerError: 'Erro da Impressora',
|
||||
aiFailureDetection: 'Detecção de Falhas por IA',
|
||||
aiFailureDetectionDescription: 'Notificar quando a IA do Obico detectar uma possível falha de impressão',
|
||||
lowFilamentLabel: 'Filamento Baixo',
|
||||
maintenanceDue: 'Manutenção Necessária',
|
||||
maintenanceDueDescription: 'Notificar quando manutenção for necessária',
|
||||
|
||||
@@ -4974,6 +4974,8 @@ export default {
|
||||
progressMilestonesDescription: '%25, %50, %75\'te bildir',
|
||||
printerOffline: 'Yazıcı Çevrimdışı',
|
||||
printerError: 'Yazıcı Hatası',
|
||||
aiFailureDetection: 'AI Hata Tespiti',
|
||||
aiFailureDetectionDescription: 'Obico AI olası bir baskı hatası tespit ettiğinde bildir',
|
||||
lowFilamentLabel: 'Az Filament',
|
||||
maintenanceDue: 'Bakım Zamanı',
|
||||
maintenanceDueDescription: 'Bakım gerektiğinde bildir',
|
||||
|
||||
@@ -5007,6 +5007,8 @@ export default {
|
||||
progressMilestonesDescription: '在 25%、50%、75% 时通知',
|
||||
printerOffline: '打印机离线',
|
||||
printerError: '打印机错误',
|
||||
aiFailureDetection: 'AI 失败检测',
|
||||
aiFailureDetectionDescription: '当 Obico AI 检测到可能的打印失败时通知',
|
||||
lowFilamentLabel: '耗材不足',
|
||||
maintenanceDue: '需要维护',
|
||||
maintenanceDueDescription: '需要维护时通知',
|
||||
|
||||
@@ -5007,6 +5007,8 @@ export default {
|
||||
progressMilestonesDescription: '在 25%、50%、75% 時通知',
|
||||
printerOffline: '印表機離線',
|
||||
printerError: '印表機錯誤',
|
||||
aiFailureDetection: 'AI 失敗偵測',
|
||||
aiFailureDetectionDescription: '當 Obico AI 偵測到可能的列印失敗時通知',
|
||||
lowFilamentLabel: '耗材不足',
|
||||
maintenanceDue: '需要維護',
|
||||
maintenanceDueDescription: '需要維護時通知',
|
||||
|
||||
Reference in New Issue
Block a user