From fe17a6905fa7254beb8fd237d4e311fea535f15e Mon Sep 17 00:00:00 2001 From: maziggy Date: Wed, 4 Feb 2026 08:18:05 +0100 Subject: [PATCH] Fix AMS auto-matching to use tray_info_idx from 3MF files When multiple AMS trays have the same filament type and color, Bambuddy now uses the tray_info_idx attribute from the 3MF file to identify the exact spool selected during slicing. This ensures the correct tray is used rather than just picking the first match. Matching priority: tray_info_idx > exact color > similar color > type-only Closes #245 --- .../tests/unit/test_scheduler_ams_mapping.py | 163 ++++++++ .../hooks/useFilamentMapping.test.ts | 349 ++++++++++++++++++ 2 files changed, 512 insertions(+) create mode 100644 frontend/src/__tests__/hooks/useFilamentMapping.test.ts diff --git a/backend/tests/unit/test_scheduler_ams_mapping.py b/backend/tests/unit/test_scheduler_ams_mapping.py index 82db6cfec..3cea29484 100644 --- a/backend/tests/unit/test_scheduler_ams_mapping.py +++ b/backend/tests/unit/test_scheduler_ams_mapping.py @@ -265,3 +265,166 @@ class TestMatchFilamentsToSlots: result = scheduler._match_filaments_to_slots(required, loaded) assert result == [254] + + def test_match_by_tray_info_idx_priority(self, scheduler): + """tray_info_idx match should have highest priority over color match.""" + required = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00"}] + loaded = [ + { + "type": "PLA", + "color": "#000000", + "global_tray_id": 0, + "tray_info_idx": "GFB00", + }, # Same color, different spool + { + "type": "PLA", + "color": "#000000", + "global_tray_id": 1, + "tray_info_idx": "GFA00", + }, # Same color, exact spool + { + "type": "PLA", + "color": "#000000", + "global_tray_id": 2, + "tray_info_idx": "GFC00", + }, # Same color, different spool + ] + + result = scheduler._match_filaments_to_slots(required, loaded) + assert result == [1] # Should pick tray 1 (exact tray_info_idx match) + + def test_match_by_tray_info_idx_with_different_colors(self, scheduler): + """tray_info_idx match should work even if colors differ slightly.""" + required = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "P4d64437"}] + loaded = [ + {"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": ""}, # No idx + { + "type": "PLA", + "color": "#000010", + "global_tray_id": 3, + "tray_info_idx": "P4d64437", + }, # Exact spool (slightly different color reported) + ] + + result = scheduler._match_filaments_to_slots(required, loaded) + assert result == [3] # Should pick tray 3 (exact tray_info_idx match) + + def test_match_fallback_to_color_when_no_tray_info_idx(self, scheduler): + """Should fall back to color matching when tray_info_idx is empty.""" + required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": ""}] + loaded = [ + {"type": "PLA", "color": "#00FF00", "global_tray_id": 0, "tray_info_idx": "GFA00"}, + {"type": "PLA", "color": "#FF0000", "global_tray_id": 1, "tray_info_idx": "GFB00"}, # Color match + ] + + result = scheduler._match_filaments_to_slots(required, loaded) + assert result == [1] # Should pick tray 1 (color match) + + def test_match_fallback_to_color_when_no_matching_tray_info_idx(self, scheduler): + """Should fall back to color when tray_info_idx doesn't match any loaded spool.""" + required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "tray_info_idx": "OLD_SPOOL"}] + loaded = [ + { + "type": "PLA", + "color": "#FF0000", + "global_tray_id": 0, + "tray_info_idx": "NEW_SPOOL", + }, # Different idx but same color + ] + + result = scheduler._match_filaments_to_slots(required, loaded) + assert result == [0] # Should fall back to color match + + def test_match_multiple_same_color_with_tray_info_idx(self, scheduler): + """Multiple identical filaments should be matched by tray_info_idx (H2D Pro scenario).""" + # This is the exact scenario from issue #245 - 3 black PLA spools + required = [ + {"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA03"}, # Wants tray 3 + ] + loaded = [ + {"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": "GFA00"}, # Tray 0 + {"type": "PLA", "color": "#000000", "global_tray_id": 1, "tray_info_idx": "GFA01"}, # Tray 1 + {"type": "PLA", "color": "#000000", "global_tray_id": 2, "tray_info_idx": "GFA02"}, # Tray 2 + { + "type": "PLA", + "color": "#000000", + "global_tray_id": 3, + "tray_info_idx": "GFA03", + }, # Tray 3 - the one we want + ] + + result = scheduler._match_filaments_to_slots(required, loaded) + assert result == [3] # Should pick tray 3, not tray 0 + + def test_match_tray_info_idx_not_reused(self, scheduler): + """tray_info_idx matched trays should not be reused for other slots.""" + required = [ + {"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00"}, + {"slot_id": 2, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA01"}, + ] + loaded = [ + {"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": "GFA00"}, + {"type": "PLA", "color": "#000000", "global_tray_id": 1, "tray_info_idx": "GFA01"}, + ] + + result = scheduler._match_filaments_to_slots(required, loaded) + assert result == [0, 1] # Each slot gets its specific tray + + +class TestBuildLoadedFilamentsTrayInfoIdx: + """Test tray_info_idx extraction in _build_loaded_filaments.""" + + @pytest.fixture + def scheduler(self): + return PrintScheduler() + + def test_build_loaded_filaments_includes_tray_info_idx(self, scheduler): + """Should extract tray_info_idx from AMS trays.""" + + class MockStatus: + raw_data = { + "ams": [ + { + "id": 0, + "tray": [ + {"id": 0, "tray_type": "PLA", "tray_color": "000000", "tray_info_idx": "GFA00"}, + {"id": 1, "tray_type": "PLA", "tray_color": "000000", "tray_info_idx": "GFA01"}, + ], + } + ] + } + + result = scheduler._build_loaded_filaments(MockStatus()) + assert len(result) == 2 + assert result[0]["tray_info_idx"] == "GFA00" + assert result[1]["tray_info_idx"] == "GFA01" + + def test_build_loaded_filaments_empty_tray_info_idx(self, scheduler): + """Missing tray_info_idx should default to empty string.""" + + class MockStatus: + raw_data = { + "ams": [ + { + "id": 0, + "tray": [ + {"id": 0, "tray_type": "PLA", "tray_color": "FF0000"}, # No tray_info_idx + ], + } + ] + } + + result = scheduler._build_loaded_filaments(MockStatus()) + assert len(result) == 1 + assert result[0]["tray_info_idx"] == "" + + def test_build_loaded_filaments_external_spool_tray_info_idx(self, scheduler): + """Should extract tray_info_idx from external spool.""" + + class MockStatus: + raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF", "tray_info_idx": "P4d64437"}} + + result = scheduler._build_loaded_filaments(MockStatus()) + assert len(result) == 1 + assert result[0]["tray_info_idx"] == "P4d64437" + assert result[0]["is_external"] is True diff --git a/frontend/src/__tests__/hooks/useFilamentMapping.test.ts b/frontend/src/__tests__/hooks/useFilamentMapping.test.ts new file mode 100644 index 000000000..39a5f7e3b --- /dev/null +++ b/frontend/src/__tests__/hooks/useFilamentMapping.test.ts @@ -0,0 +1,349 @@ +/** + * Tests for the useFilamentMapping hook and helper functions. + * + * Tests the tray_info_idx matching logic that ensures the exact spool + * selected during slicing is used when multiple trays have identical filament. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildLoadedFilaments, + computeAmsMapping, +} from '../../hooks/useFilamentMapping'; +import type { PrinterStatus } from '../../api/client'; + +// Helper to create a minimal printer status with AMS data +function createPrinterStatus(ams: PrinterStatus['ams'], vt_tray?: PrinterStatus['vt_tray']): PrinterStatus { + return { + ams, + vt_tray, + } as PrinterStatus; +} + +describe('buildLoadedFilaments', () => { + it('returns empty array for undefined status', () => { + const result = buildLoadedFilaments(undefined); + expect(result).toEqual([]); + }); + + it('extracts filaments from AMS units', () => { + const status = createPrinterStatus([ + { + id: 0, + tray: [ + { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' }, + { id: 1, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFA01' }, + ], + }, + ]); + + const result = buildLoadedFilaments(status); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + type: 'PLA', + color: '#FF0000', + amsId: 0, + trayId: 0, + globalTrayId: 0, + trayInfoIdx: 'GFA00', + }); + expect(result[1]).toMatchObject({ + type: 'PETG', + color: '#00FF00', + globalTrayId: 1, + trayInfoIdx: 'GFA01', + }); + }); + + it('includes tray_info_idx from AMS trays', () => { + const status = createPrinterStatus([ + { + id: 0, + tray: [ + { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'P4d64437' }, + ], + }, + ]); + + const result = buildLoadedFilaments(status); + + expect(result[0].trayInfoIdx).toBe('P4d64437'); + }); + + it('handles missing tray_info_idx', () => { + const status = createPrinterStatus([ + { + id: 0, + tray: [ + { id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // No tray_info_idx + ], + }, + ]); + + const result = buildLoadedFilaments(status); + + expect(result[0].trayInfoIdx).toBe(''); + }); + + it('extracts external spool with tray_info_idx', () => { + const status = createPrinterStatus( + [], + { tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' } + ); + + const result = buildLoadedFilaments(status); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'TPU', + isExternal: true, + globalTrayId: 254, + trayInfoIdx: 'EXT001', + }); + }); + + it('skips empty trays', () => { + const status = createPrinterStatus([ + { + id: 0, + tray: [ + { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' }, + { id: 1, tray_type: '', tray_color: '' }, // Empty tray + { id: 2 }, // No tray_type + ], + }, + ]); + + const result = buildLoadedFilaments(status); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('PLA'); + }); + + it('marks AMS-HT units correctly', () => { + const status = createPrinterStatus([ + { + id: 128, // AMS-HT typically has high ID + tray: [ + { id: 0, tray_type: 'PLA-CF', tray_color: '000000', tray_info_idx: 'HT001' }, + ], // Single tray = AMS-HT + }, + ]); + + const result = buildLoadedFilaments(status); + + expect(result[0].isHt).toBe(true); + expect(result[0].globalTrayId).toBe(512); // 128 * 4 + 0 + }); +}); + +describe('computeAmsMapping', () => { + it('returns undefined for empty filament requirements', () => { + const status = createPrinterStatus([ + { + id: 0, + tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }], + }, + ]); + + expect(computeAmsMapping(undefined, status)).toBeUndefined(); + expect(computeAmsMapping({ filaments: [] }, status)).toBeUndefined(); + }); + + it('returns undefined when no filaments loaded', () => { + const reqs = { + filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }], + }; + + expect(computeAmsMapping(reqs, undefined)).toBeUndefined(); + expect(computeAmsMapping(reqs, createPrinterStatus([]))).toBeUndefined(); + }); + + it('matches by tray_info_idx with highest priority', () => { + const reqs = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA01' }, + ], + }; + const status = createPrinterStatus([ + { + id: 0, + tray: [ + { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' }, // Same color, wrong idx + { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' }, // Exact idx match + { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' }, // Same color, wrong idx + ], + }, + ]); + + const result = computeAmsMapping(reqs, status); + + expect(result).toEqual([1]); // Should pick tray 1, not tray 0 + }); + + it('matches multiple identical filaments by tray_info_idx (H2D Pro scenario)', () => { + // This is the exact scenario from issue #245 - multiple black PLA spools + const reqs = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 50, tray_info_idx: 'GFA03' }, + ], + }; + const status = createPrinterStatus([ + { + id: 0, + tray: [ + { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' }, + { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' }, + { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' }, + { id: 3, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA03' }, // This one + ], + }, + ]); + + const result = computeAmsMapping(reqs, status); + + expect(result).toEqual([3]); // Should pick tray 3, not tray 0 + }); + + it('falls back to color match when tray_info_idx is empty', () => { + const reqs = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: '' }, + ], + }; + const status = createPrinterStatus([ + { + id: 0, + tray: [ + { id: 0, tray_type: 'PLA', tray_color: '00FF00', tray_info_idx: 'GFA00' }, // Wrong color + { id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA01' }, // Color match + ], + }, + ]); + + const result = computeAmsMapping(reqs, status); + + expect(result).toEqual([1]); + }); + + it('falls back to color match when tray_info_idx does not match', () => { + const reqs = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'OLD_SPOOL' }, + ], + }; + const status = createPrinterStatus([ + { + id: 0, + tray: [ + { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'NEW_SPOOL' }, // Different idx, same color + ], + }, + ]); + + const result = computeAmsMapping(reqs, status); + + expect(result).toEqual([0]); // Falls back to color match + }); + + it('matches by type only when color differs', () => { + const reqs = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }, + ], + }; + const status = createPrinterStatus([ + { + id: 0, + tray: [ + { id: 0, tray_type: 'PLA', tray_color: '0000FF' }, // Same type, different color + ], + }, + ]); + + const result = computeAmsMapping(reqs, status); + + expect(result).toEqual([0]); // Type-only match + }); + + it('returns -1 for unmatched slots', () => { + const reqs = { + filaments: [ + { slot_id: 1, type: 'TPU', color: '#FF0000', used_grams: 10 }, // No TPU loaded + ], + }; + const status = createPrinterStatus([ + { + id: 0, + tray: [ + { id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, + ], + }, + ]); + + const result = computeAmsMapping(reqs, status); + + expect(result).toEqual([-1]); + }); + + it('avoids duplicate tray assignment', () => { + const reqs = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }, + { slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 }, // Same requirements + ], + }; + const status = createPrinterStatus([ + { + id: 0, + tray: [ + { id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // Only one PLA + ], + }, + ]); + + const result = computeAmsMapping(reqs, status); + + expect(result).toEqual([0, -1]); // First slot gets the match, second is unmatched + }); + + it('handles multi-slot mapping with tray_info_idx', () => { + const reqs = { + filaments: [ + { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA00' }, + { slot_id: 2, type: 'PLA', color: '#000000', used_grams: 10, tray_info_idx: 'GFA02' }, + ], + }; + const status = createPrinterStatus([ + { + id: 0, + tray: [ + { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' }, + { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA01' }, + { id: 2, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA02' }, + ], + }, + ]); + + const result = computeAmsMapping(reqs, status); + + expect(result).toEqual([0, 2]); // Each slot gets its specific tray + }); + + it('handles external spool matching', () => { + const reqs = { + filaments: [ + { slot_id: 1, type: 'TPU', color: '#0000FF', used_grams: 10, tray_info_idx: 'EXT001' }, + ], + }; + const status = createPrinterStatus( + [], + { tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' } + ); + + const result = computeAmsMapping(reqs, status); + + expect(result).toEqual([254]); // External spool global ID + }); +});