mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
Merge branch '0.2.0b' into 0.2.0b
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Tests for the SpoolFormModal weightTouched behavior.
|
||||
*
|
||||
* Verifies that weight_used is only included in the PATCH payload when the user
|
||||
* explicitly changes the remaining weight field. This prevents stale React Query
|
||||
* cache values from overwriting usage-tracked weight data on the backend.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { render } from '../utils';
|
||||
import { SpoolFormModal } from '../../components/SpoolFormModal';
|
||||
import type { InventorySpool } from '../../api/client';
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
|
||||
getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
|
||||
getFilamentPresets: vi.fn().mockResolvedValue([]),
|
||||
getSpoolCatalog: vi.fn().mockResolvedValue([]),
|
||||
getColorCatalog: vi.fn().mockResolvedValue([]),
|
||||
getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
|
||||
getPrinters: vi.fn().mockResolvedValue([]),
|
||||
getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
|
||||
createSpool: vi.fn().mockResolvedValue({ id: 99 }),
|
||||
updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
|
||||
saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock validateForm so we can bypass validation for the create-mode test
|
||||
// (editing tests pass validation naturally since the spool has material + slicer_filament)
|
||||
vi.mock('../../components/spool-form/types', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../components/spool-form/types')>();
|
||||
return {
|
||||
...actual,
|
||||
validateForm: vi.fn().mockReturnValue({ isValid: true, errors: {} }),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the toast context
|
||||
const mockShowToast = vi.fn();
|
||||
vi.mock('../../contexts/ToastContext', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
|
||||
return {
|
||||
...actual,
|
||||
useToast: () => ({ showToast: mockShowToast }),
|
||||
};
|
||||
});
|
||||
|
||||
import { api } from '../../api/client';
|
||||
|
||||
const existingSpool: InventorySpool = {
|
||||
id: 1,
|
||||
material: 'PLA',
|
||||
subtype: 'Basic',
|
||||
brand: 'Polymaker',
|
||||
color_name: 'Red',
|
||||
rgba: 'FF0000FF',
|
||||
label_weight: 1000,
|
||||
core_weight: 250,
|
||||
weight_used: 300,
|
||||
slicer_filament: 'GFL99',
|
||||
slicer_filament_name: 'Generic PLA',
|
||||
nozzle_temp_min: null,
|
||||
nozzle_temp_max: null,
|
||||
note: null,
|
||||
added_full: null,
|
||||
last_used: null,
|
||||
encode_time: null,
|
||||
tag_uid: null,
|
||||
tray_uuid: null,
|
||||
data_origin: null,
|
||||
tag_type: null,
|
||||
archived_at: null,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
k_profiles: [],
|
||||
};
|
||||
|
||||
describe('SpoolFormModal weightTouched', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('excludes weight_used from PATCH when editing without changing weight', async () => {
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
spool={existingSpool}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for the modal to render with the edit title
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit Spool')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click Save without touching the weight field
|
||||
const saveButton = screen.getByRole('button', { name: /save/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.updateSpool).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
|
||||
expect(spoolId).toBe(1);
|
||||
// weight_used must NOT be present in the payload
|
||||
expect(payload).not.toHaveProperty('weight_used');
|
||||
// Other fields should still be present
|
||||
expect(payload).toHaveProperty('material', 'PLA');
|
||||
expect(payload).toHaveProperty('label_weight', 1000);
|
||||
});
|
||||
|
||||
it('includes weight_used in PATCH when editing and changing remaining weight', async () => {
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
spool={existingSpool}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit Spool')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The remaining weight is (label_weight - weight_used) = 1000 - 300 = 700.
|
||||
// The input is a number input displaying 700. Find it by its displayed value.
|
||||
const remainingInput = screen.getByDisplayValue('700');
|
||||
expect(remainingInput).toBeInTheDocument();
|
||||
|
||||
// Change the remaining weight from 700 to 500 (weight_used becomes 1000 - 500 = 500)
|
||||
fireEvent.change(remainingInput, { target: { value: '500' } });
|
||||
|
||||
// Click Save
|
||||
const saveButton = screen.getByRole('button', { name: /save/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.updateSpool).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
|
||||
expect(spoolId).toBe(1);
|
||||
// weight_used MUST be present since the user changed the weight
|
||||
expect(payload).toHaveProperty('weight_used', 500);
|
||||
});
|
||||
|
||||
it('includes weight_used when creating a new spool', async () => {
|
||||
render(
|
||||
<SpoolFormModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for the modal to render with the create title
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the submit button (validation is mocked to always pass).
|
||||
// The default form data has weight_used=0, and for create mode the condition
|
||||
// if (!isEditing || weightTouched) { data.weight_used = formData.weight_used; }
|
||||
// always includes weight_used since isEditing is false.
|
||||
// The submit button also says "Add Spool" — use getAllByText and pick the button.
|
||||
const addButtons = screen.getAllByRole('button', { name: /add spool/i });
|
||||
const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
|
||||
expect(submitButton).toBeTruthy();
|
||||
fireEvent.click(submitButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.createSpool).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const [payload] = vi.mocked(api.createSpool).mock.calls[0];
|
||||
// weight_used MUST be included for new spools (default value 0)
|
||||
expect(payload).toHaveProperty('weight_used', 0);
|
||||
});
|
||||
});
|
||||
@@ -3498,6 +3498,8 @@ export const api = {
|
||||
request<SpoolUsageRecord[]>(`/inventory/usage?limit=${limit}${printerId ? `&printer_id=${printerId}` : ''}`),
|
||||
clearSpoolUsageHistory: (spoolId: number) =>
|
||||
request<{ status: string }>(`/inventory/spools/${spoolId}/usage`, { method: 'DELETE' }),
|
||||
syncWeightsFromAms: () =>
|
||||
request<{ synced: number; skipped: number }>('/inventory/sync-ams-weights', { method: 'POST' }),
|
||||
getFilamentPresets: () =>
|
||||
request<SlicerSetting[]>('/cloud/filaments'),
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
|
||||
const [activeTab, setActiveTab] = useState<TabId>('filament');
|
||||
const [weightTouched, setWeightTouched] = useState(false);
|
||||
|
||||
// Cloud presets
|
||||
const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
|
||||
@@ -195,6 +196,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
}
|
||||
setErrors({});
|
||||
setActiveTab('filament');
|
||||
setWeightTouched(false);
|
||||
}
|
||||
}, [isOpen, spool]);
|
||||
|
||||
@@ -208,6 +210,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
// Update field helper
|
||||
const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
|
||||
setFormData(prev => ({ ...prev, [key]: value }));
|
||||
if (key === 'weight_used') setWeightTouched(true);
|
||||
if (errors[key]) {
|
||||
setErrors(prev => ({ ...prev, [key]: undefined }));
|
||||
}
|
||||
@@ -333,7 +336,6 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
rgba: formData.rgba || null,
|
||||
label_weight: formData.label_weight,
|
||||
core_weight: formData.core_weight,
|
||||
weight_used: formData.weight_used,
|
||||
slicer_filament: formData.slicer_filament || null,
|
||||
slicer_filament_name: presetName,
|
||||
nozzle_temp_min: null,
|
||||
@@ -341,6 +343,12 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
note: formData.note || null,
|
||||
};
|
||||
|
||||
// Only send weight_used when creating or when explicitly changed by the user.
|
||||
// This prevents stale cached values from overwriting usage-tracker data.
|
||||
if (!isEditing || weightTouched) {
|
||||
data.weight_used = formData.weight_used;
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
updateMutation.mutate(data);
|
||||
} else {
|
||||
@@ -403,7 +411,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 overflow-y-auto flex-1">
|
||||
<div className="p-4 overflow-y-auto flex-1" style={{ scrollbarGutter: 'stable' }}>
|
||||
{activeTab === 'filament' ? (
|
||||
<div className="space-y-6">
|
||||
{/* Filament Info Section */}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from '../api/client';
|
||||
import type { SpoolmanSyncResult, Printer } from '../api/client';
|
||||
import { Card, CardContent, CardHeader } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
export function SpoolmanSettings() {
|
||||
@@ -20,6 +21,7 @@ export function SpoolmanSettings() {
|
||||
const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('all');
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [showAllSkipped, setShowAllSkipped] = useState(false);
|
||||
const [showAmsSyncConfirm, setShowAmsSyncConfirm] = useState(false);
|
||||
|
||||
// Fetch Spoolman settings
|
||||
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||
@@ -136,6 +138,21 @@ export function SpoolmanSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
// Inventory AMS weight sync mutation
|
||||
const amsSyncMutation = useMutation({
|
||||
mutationFn: api.syncWeightsFromAms,
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['spools'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
|
||||
showToast(t('settings.amsSyncSuccess', { synced: data.synced, skipped: data.skipped }), 'success');
|
||||
setShowAmsSyncConfirm(false);
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t('settings.amsSyncError'), 'error');
|
||||
setShowAmsSyncConfirm(false);
|
||||
},
|
||||
});
|
||||
|
||||
// Combine mutation states
|
||||
const isSyncing = syncAllMutation.isPending || syncPrinterMutation.isPending;
|
||||
const syncResult = selectedPrinterId === 'all' ? syncAllMutation.data : syncPrinterMutation.data;
|
||||
@@ -236,18 +253,34 @@ export function SpoolmanSettings() {
|
||||
|
||||
{/* Built-in Inventory details */}
|
||||
{!localEnabled && (
|
||||
<div className="p-3 bg-bambu-green/5 border border-bambu-green/20 rounded-lg">
|
||||
<div className="flex gap-2">
|
||||
<Info className="w-4 h-4 text-bambu-green flex-shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-bambu-gray">
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
<li>{t('settings.builtInFeatureRfid')}</li>
|
||||
<li>{t('settings.builtInFeatureUsage')}</li>
|
||||
<li>{t('settings.builtInFeatureCatalog')}</li>
|
||||
<li>{t('settings.builtInFeatureThirdParty')}</li>
|
||||
</ul>
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 bg-bambu-green/5 border border-bambu-green/20 rounded-lg">
|
||||
<div className="flex gap-2">
|
||||
<Info className="w-4 h-4 text-bambu-green flex-shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-bambu-gray">
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
<li>{t('settings.builtInFeatureRfid')}</li>
|
||||
<li>{t('settings.builtInFeatureUsage')}</li>
|
||||
<li>{t('settings.builtInFeatureCatalog')}</li>
|
||||
<li>{t('settings.builtInFeatureThirdParty')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowAmsSyncConfirm(true)}
|
||||
disabled={amsSyncMutation.isPending}
|
||||
>
|
||||
{amsSyncMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{t('settings.amsSyncButton')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -528,6 +561,19 @@ export function SpoolmanSettings() {
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{showAmsSyncConfirm && (
|
||||
<ConfirmModal
|
||||
title={t('settings.amsSyncTitle')}
|
||||
message={t('settings.amsSyncMessage')}
|
||||
confirmText={t('settings.amsSyncButton')}
|
||||
variant="warning"
|
||||
isLoading={amsSyncMutation.isPending}
|
||||
loadingText={t('settings.amsSyncing')}
|
||||
onConfirm={() => amsSyncMutation.mutate()}
|
||||
onCancel={() => setShowAmsSyncConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1204,6 +1204,12 @@ export default {
|
||||
builtInFeatureUsage: 'Erfasst den Filamentverbrauch pro Druck',
|
||||
builtInFeatureCatalog: 'Spulen, Farben und K-Faktor-Profile verwalten',
|
||||
builtInFeatureThirdParty: 'Drittanbieter-Spulen können Inventarspulen zugewiesen werden',
|
||||
amsSyncButton: 'Gewichte vom AMS synchronisieren',
|
||||
amsSyncTitle: 'Spulengewichte vom AMS synchronisieren',
|
||||
amsSyncMessage: 'Alle Inventar-Spulengewichte werden mit den aktuellen AMS-Restwerten der verbundenen Drucker überschrieben. Verwenden Sie dies zur Wiederherstellung beschädigter Gewichtsdaten. Drucker müssen online sein.',
|
||||
amsSyncing: 'Synchronisiere...',
|
||||
amsSyncSuccess: '{{synced}} Spule(n) synchronisiert, {{skipped}} übersprungen',
|
||||
amsSyncError: 'Synchronisierung der Gewichte vom AMS fehlgeschlagen',
|
||||
// Spoolman settings
|
||||
spoolmanUrl: 'Spoolman URL',
|
||||
spoolmanUrlHint: 'URL Ihres Spoolman-Servers (z.B. http://localhost:7912)',
|
||||
|
||||
@@ -1204,6 +1204,12 @@ export default {
|
||||
builtInFeatureUsage: 'Tracks filament consumption per print',
|
||||
builtInFeatureCatalog: 'Manage spools, colors, and K-factor profiles',
|
||||
builtInFeatureThirdParty: 'Third-party spools can be assigned to inventory spools',
|
||||
amsSyncButton: 'Sync Weights from AMS',
|
||||
amsSyncTitle: 'Sync Spool Weights from AMS',
|
||||
amsSyncMessage: 'This will overwrite all inventory spool weights with the current AMS remain% values from connected printers. Use this to recover from corrupted weight data. Printers must be online.',
|
||||
amsSyncing: 'Syncing...',
|
||||
amsSyncSuccess: '{{synced}} spool(s) synced, {{skipped}} skipped',
|
||||
amsSyncError: 'Failed to sync weights from AMS',
|
||||
// Spoolman settings
|
||||
spoolmanUrl: 'Spoolman URL',
|
||||
spoolmanUrlHint: 'URL of your Spoolman server (e.g., http://localhost:7912)',
|
||||
|
||||
@@ -1200,6 +1200,12 @@ export default {
|
||||
builtInFeatureUsage: 'Suit la consommation par impression',
|
||||
builtInFeatureCatalog: 'Gère bobines, couleurs et profils facteur K',
|
||||
builtInFeatureThirdParty: 'Les bobines tierces peuvent être assignées aux bobines d\'inventaire',
|
||||
amsSyncButton: 'Synchroniser les poids depuis l\'AMS',
|
||||
amsSyncTitle: 'Synchroniser les poids des bobines depuis l\'AMS',
|
||||
amsSyncMessage: 'Tous les poids des bobines de l\'inventaire seront écrasés par les valeurs actuelles de l\'AMS des imprimantes connectées. Utilisez ceci pour récupérer des données de poids corrompues. Les imprimantes doivent être en ligne.',
|
||||
amsSyncing: 'Synchronisation...',
|
||||
amsSyncSuccess: '{{synced}} bobine(s) synchronisée(s), {{skipped}} ignorée(s)',
|
||||
amsSyncError: 'Échec de la synchronisation des poids depuis l\'AMS',
|
||||
// Spoolman settings
|
||||
spoolmanUrl: 'URL Spoolman',
|
||||
spoolmanUrlHint: 'URL de votre serveur Spoolman (ex: http://localhost:7912)',
|
||||
|
||||
@@ -1452,6 +1452,12 @@ export default {
|
||||
builtInFeatureUsage: 'プリントごとのフィラメント消費量を追跡',
|
||||
builtInFeatureCatalog: 'スプール、カラー、K値プロファイルを管理',
|
||||
builtInFeatureThirdParty: 'サードパーティ製スプールをインベントリスプールに割り当て可能',
|
||||
amsSyncButton: 'AMSから重量を同期',
|
||||
amsSyncTitle: 'AMSからスプール重量を同期',
|
||||
amsSyncMessage: '接続されたプリンターの現在のAMS残量値で、すべてのインベントリスプール重量を上書きします。破損した重量データの復旧に使用してください。プリンターがオンラインである必要があります。',
|
||||
amsSyncing: '同期中...',
|
||||
amsSyncSuccess: '{{synced}}個のスプールを同期、{{skipped}}個をスキップ',
|
||||
amsSyncError: 'AMSからの重量同期に失敗しました',
|
||||
// Spoolman設定
|
||||
spoolmanUrl: 'Spoolman URL',
|
||||
spoolmanUrlHint: 'Spoolmanサーバーのurl(例:http://localhost:7912)',
|
||||
|
||||
Reference in New Issue
Block a user