Merge pull request #414 from Keybored02/feature/filament-fix

Fix: Spool Empty Weight Catalog Matching by ID
This commit is contained in:
MartinNYHC
2026-02-18 12:28:23 +01:00
committed by GitHub
8 changed files with 206 additions and 9 deletions
+6
View File
@@ -1185,6 +1185,12 @@ async def run_migrations(conn):
except OperationalError:
pass # Already applied
# Migration: Add core_weight_catalog_id to track which catalog entry was used for empty spool weight
try:
await conn.execute(text("ALTER TABLE spool ADD COLUMN core_weight_catalog_id INTEGER"))
except OperationalError:
pass # Already applied
# Migration: Create spool_usage_history table for filament consumption tracking
try:
await conn.execute(
+3
View File
@@ -19,6 +19,9 @@ class Spool(Base):
brand: Mapped[str | None] = mapped_column(String(100)) # "Polymaker"
label_weight: Mapped[int] = mapped_column(Integer, default=1000) # Advertised net weight (g)
core_weight: Mapped[int] = mapped_column(Integer, default=250) # Empty spool weight (g)
core_weight_catalog_id: Mapped[int | None] = mapped_column(
Integer
) # Reference to spool_catalog entry for core weight
weight_used: Mapped[float] = mapped_column(Float, default=0) # Consumed grams
slicer_filament: Mapped[str | None] = mapped_column(String(50)) # Preset ID (e.g. "GFL99")
slicer_filament_name: Mapped[str | None] = mapped_column(String(100)) # Preset name for slicer
+2
View File
@@ -11,6 +11,7 @@ class SpoolBase(BaseModel):
brand: str | None = None
label_weight: int = 1000
core_weight: int = 250
core_weight_catalog_id: int | None = None
weight_used: float = 0
slicer_filament: str | None = None
slicer_filament_name: str | None = None
@@ -35,6 +36,7 @@ class SpoolUpdate(BaseModel):
brand: str | None = None
label_weight: int | None = None
core_weight: int | None = None
core_weight_catalog_id: int | None = None
weight_used: float | None = None
slicer_filament: str | None = None
slicer_filament_name: str | None = None
@@ -62,6 +62,7 @@ const existingSpool: InventorySpool = {
rgba: 'FF0000FF',
label_weight: 1000,
core_weight: 250,
core_weight_catalog_id: null,
weight_used: 300,
slicer_filament: 'GFL99',
slicer_filament_name: 'Generic PLA',
@@ -183,4 +184,138 @@ describe('SpoolFormModal weightTouched', () => {
// weight_used MUST be included for new spools (default value 0)
expect(payload).toHaveProperty('weight_used', 0);
});
it('preserves core_weight_catalog_id when editing other fields', async () => {
const spoolWithCatalogId: InventorySpool = {
...existingSpool,
core_weight_catalog_id: 5,
};
render(
<SpoolFormModal
isOpen={true}
onClose={vi.fn()}
spool={spoolWithCatalogId}
/>
);
await waitFor(() => {
expect(screen.getByText('Edit Spool')).toBeInTheDocument();
});
// Change the note field (unrelated to catalog ID)
const noteInputs = screen.getAllByPlaceholderText(/note/i);
expect(noteInputs.length).toBeGreaterThan(0);
fireEvent.change(noteInputs[0], { target: { value: 'Updated note' } });
// 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);
// core_weight_catalog_id MUST be preserved when editing other fields
expect(payload).toHaveProperty('core_weight_catalog_id', 5);
// Other changes should also be present
expect(payload).toHaveProperty('note', 'Updated note');
});
it('includes core_weight_catalog_id when selecting from catalog', async () => {
const mockCatalog = [
{ id: 1, name: 'Generic 250g', weight: 250 },
{ id: 2, name: 'Bambu Lab 250g', weight: 250 },
{ id: 3, name: 'Standard 300g', weight: 300 },
];
vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
render(
<SpoolFormModal
isOpen={true}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
});
// Wait for catalog to load
await waitFor(() => {
expect(api.getSpoolCatalog).toHaveBeenCalled();
});
// Click on the empty spool weight field to open dropdown
const weightInputs = screen.getAllByPlaceholderText(/search/i);
const weightPicker = weightInputs.find(input =>
input.getAttribute('placeholder')?.toLowerCase().includes('spool')
);
expect(weightPicker).toBeTruthy();
fireEvent.focus(weightPicker!);
// Click on "Bambu Lab 250g" option
const bambuOption = await screen.findByText('Bambu Lab 250g');
fireEvent.click(bambuOption);
// Click the add spool 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];
// Both weight AND catalog ID should be sent
expect(payload).toHaveProperty('core_weight', 250);
expect(payload).toHaveProperty('core_weight_catalog_id', 2); // ID of "Bambu Lab 250g"
});
it('displays correct catalog name when duplicates exist', async () => {
const spoolWithCatalogId: InventorySpool = {
...existingSpool,
core_weight: 250,
core_weight_catalog_id: 2, // "Bambu Lab 250g", not the first match
};
const mockCatalog = [
{ id: 1, name: 'Generic 250g', weight: 250 },
{ id: 2, name: 'Bambu Lab 250g', weight: 250 },
{ id: 3, name: 'Standard 300g', weight: 300 },
];
vi.mocked(api.getSpoolCatalog).mockResolvedValue(mockCatalog);
render(
<SpoolFormModal
isOpen={true}
onClose={vi.fn()}
spool={spoolWithCatalogId}
/>
);
await waitFor(() => {
expect(screen.getByText('Edit Spool')).toBeInTheDocument();
});
// Wait for catalog to load
await waitFor(() => {
expect(api.getSpoolCatalog).toHaveBeenCalled();
});
// Should display "Bambu Lab 250g" (by ID), not "Generic 250g" (first match by weight)
await waitFor(() => {
const weightInputs = screen.getAllByDisplayValue(/250|Bambu/i);
const bambuFound = weightInputs.some(input =>
input.value === 'Bambu Lab 250g' || input.getAttribute('value') === 'Bambu Lab 250g'
);
expect(bambuFound).toBeTruthy();
});
});
});
+1
View File
@@ -1771,6 +1771,7 @@ export interface InventorySpool {
brand: string | null;
label_weight: number;
core_weight: number;
core_weight_catalog_id: number | null;
weight_used: number;
slicer_filament: string | null;
slicer_filament_name: string | null;
@@ -171,6 +171,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
rgba: spool.rgba || '808080FF',
label_weight: spool.label_weight || 1000,
core_weight: spool.core_weight || 250,
core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
weight_used: spool.weight_used || 0,
slicer_filament: spool.slicer_filament || '',
note: spool.note || '',
@@ -336,6 +337,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
rgba: formData.rgba || null,
label_weight: formData.label_weight,
core_weight: formData.core_weight,
core_weight_catalog_id: formData.core_weight_catalog_id,
slicer_filament: formData.slicer_filament || null,
slicer_filament_name: presetName,
nozzle_temp_min: null,
@@ -8,15 +8,18 @@ function SpoolWeightPicker({
catalog,
value,
onChange,
catalogId,
onCatalogIdChange,
}: {
catalog: { id: number; name: string; weight: number }[];
value: number;
onChange: (weight: number) => void;
catalogId: number | null;
onCatalogIdChange: (id: number | null) => void;
}) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<number | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@@ -30,6 +33,35 @@ function SpoolWeightPicker({
return () => document.removeEventListener('mousedown', handleClick);
}, []);
// When value changes, auto-select if there's only one matching entry or keep selection if it still matches
useEffect(() => {
// If no catalog loaded yet, skip matching logic
if (catalog.length === 0) {
return;
}
const matches = catalog.filter(e => e.weight === value);
// If currently selected entry still matches the weight, keep it selected
if (catalogId) {
const selected = catalog.find(e => e.id === catalogId);
if (selected && selected.weight === value) {
return; // Keep current selection
}
}
// If exactly one match, auto-select it
if (matches.length === 1) {
onCatalogIdChange(matches[0].id);
} else if (matches.length === 0) {
// No matches, clear selection to prevent stale catalog ID
if (catalogId !== null) {
onCatalogIdChange(null);
}
}
// If multiple matches, don't auto-select - let user choose
}, [value, catalog, catalogId, onCatalogIdChange]);
const filtered = useMemo(() => {
if (!search) return catalog;
const s = search.toLowerCase();
@@ -39,17 +71,29 @@ function SpoolWeightPicker({
);
}, [catalog, search]);
// Display value: show catalog name if selected, or the weight
// Find all entries matching the current weight
const matchingEntries = useMemo(() => {
return catalog.filter(e => e.weight === value);
}, [catalog, value]);
// Display value: show catalog name if selected by ID, otherwise show first match
const displayValue = useMemo(() => {
if (isOpen) return search;
if (selectedId) {
const entry = catalog.find(e => e.id === selectedId);
// If a catalog ID is explicitly selected, use that
if (catalogId) {
const entry = catalog.find(e => e.id === catalogId);
if (entry) return entry.name;
}
const match = catalog.find(e => e.weight === value);
if (match) return match.name;
// Otherwise, show the first matching entry as a suggestion
if (matchingEntries.length > 0) {
return matchingEntries[0].name;
}
// Leave empty if there are no matches
return '';
}, [isOpen, search, selectedId, catalog, value]);
}, [isOpen, search, catalogId, catalog, matchingEntries]);
return (
<div>
@@ -86,12 +130,12 @@ function SpoolWeightPicker({
key={entry.id}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
(selectedId ? entry.id === selectedId : entry.weight === value)
(catalogId ? entry.id === catalogId : entry.weight === value)
? 'bg-bambu-green/10 text-bambu-green'
: 'text-white'
}`}
onClick={() => {
setSelectedId(entry.id);
onCatalogIdChange(entry.id);
onChange(entry.weight);
setIsOpen(false);
setSearch('');
@@ -150,6 +194,8 @@ export function AdditionalSection({
catalog={spoolCatalog}
value={formData.core_weight}
onChange={(weight) => updateField('core_weight', weight)}
catalogId={formData.core_weight_catalog_id}
onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)}
/>
{/* Current Weight (remaining filament) */}
@@ -9,6 +9,7 @@ export interface SpoolFormData {
rgba: string;
label_weight: number;
core_weight: number;
core_weight_catalog_id: number | null;
weight_used: number;
slicer_filament: string;
note: string;
@@ -22,6 +23,7 @@ export const defaultFormData: SpoolFormData = {
rgba: '808080FF',
label_weight: 1000,
core_weight: 250,
core_weight_catalog_id: null,
weight_used: 0,
slicer_filament: '',
note: '',