/** * 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 { renderHook } from '@testing-library/react'; import { buildAmsMapping, buildFilamentComparison, buildLoadedFilaments, computeAmsMapping, useFilamentMapping, } from '../../hooks/useFilamentMapping'; import { effectivePreferLowest } from '../../utils/amsHelpers'; 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('includes tray_sub_brands from AMS trays', () => { const status = createPrinterStatus([ { id: 0, tray: [ { id: 0, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFL99', tray_sub_brands: 'PLA Basic' }, { id: 1, tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFL05', tray_sub_brands: 'PLA Matte' }, ], }, ]); const result = buildLoadedFilaments(status); expect(result[0].traySubBrands).toBe('PLA Basic'); expect(result[1].traySubBrands).toBe('PLA Matte'); }); it('handles missing tray_sub_brands', () => { const status = createPrinterStatus([ { id: 0, tray: [ { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' }, ], }, ]); const result = buildLoadedFilaments(status); expect(result[0].traySubBrands).toBe(''); }); it('includes tray_sub_brands from external spool', () => { const status = createPrinterStatus( [], [{ tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFG00', tray_sub_brands: 'PETG HF' }] ); const result = buildLoadedFilaments(status); expect(result[0].traySubBrands).toBe('PETG HF'); }); 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(128); // AMS-HT uses ams_id directly }); }); 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 }); }); describe('buildLoadedFilaments - nozzle awareness', () => { it('sets extruderId from ams_extruder_map', () => { const status = createPrinterStatus([ { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }], }, { id: 1, tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }], }, ]); (status as any).ams_extruder_map = { '0': 1, '1': 0 }; const result = buildLoadedFilaments(status); expect(result[0].extruderId).toBe(1); // AMS 0 → left nozzle expect(result[1].extruderId).toBe(0); // AMS 1 → right nozzle }); it('leaves extruderId undefined when no ams_extruder_map', () => { const status = createPrinterStatus([ { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }], }, ]); const result = buildLoadedFilaments(status); expect(result[0].extruderId).toBeUndefined(); }); }); describe('computeAmsMapping - nozzle filtering', () => { it('filters candidates by nozzle_id when set', () => { // Filament requires left nozzle (extruder 1), only AMS 0 is on left const reqs = { filaments: [ { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 }, ], }; const status = createPrinterStatus([ { id: 0, // Left nozzle tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }], }, { id: 1, // Right nozzle tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }], }, ]); (status as any).ams_extruder_map = { '0': 1, '1': 0 }; const result = computeAmsMapping(reqs, status); expect(result).toEqual([0]); // AMS 0, tray 0 (on left nozzle) }); it('filters to right nozzle when nozzle_id=0', () => { const reqs = { filaments: [ { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 0 }, ], }; const status = createPrinterStatus([ { id: 0, // Left nozzle tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }], }, { id: 1, // Right nozzle tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }], }, ]); (status as any).ams_extruder_map = { '0': 1, '1': 0 }; const result = computeAmsMapping(reqs, status); expect(result).toEqual([4]); // AMS 1, tray 0 (global ID = 1*4+0 = 4, on right nozzle) }); it('returns -1 when target nozzle has no trays (hard filter)', () => { // Requires nozzle_id=1 (left), but no AMS units are on left nozzle // Hard filter: cross-nozzle assignment causes "position of left hotend is abnormal" const reqs = { filaments: [ { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 }, ], }; const status = createPrinterStatus([ { id: 0, // Right nozzle only tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }], }, ]); (status as any).ams_extruder_map = { '0': 0 }; // AMS 0 → right nozzle, none on left const result = computeAmsMapping(reqs, status); expect(result).toEqual([-1]); // Hard filter: no fallback to wrong nozzle }); it('stays restricted when target nozzle has trays but wrong type', () => { // Left nozzle has PETG, right has PLA — but requires PLA on left const reqs = { filaments: [ { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 }, ], }; const status = createPrinterStatus([ { id: 0, // Left nozzle - only PETG tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }], }, { id: 1, // Right nozzle - has PLA tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }], }, ]); (status as any).ams_extruder_map = { '0': 1, '1': 0 }; const result = computeAmsMapping(reqs, status); expect(result).toEqual([-1]); // No PLA on left nozzle, stays restricted }); it('skips nozzle filtering when nozzle_id is undefined', () => { const reqs = { filaments: [ { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }, // No nozzle_id ], }; const status = createPrinterStatus([ { id: 0, tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }], }, { id: 1, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }], }, ]); (status as any).ams_extruder_map = { '0': 1, '1': 0 }; const result = computeAmsMapping(reqs, status); expect(result).toEqual([4]); // Picks best match regardless of nozzle }); it('handles dual-nozzle multi-slot mapping', () => { // Two filaments: one for left, one for right const reqs = { filaments: [ { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 }, // Left { slot_id: 2, type: 'PETG', color: '#00FF00', used_grams: 10, nozzle_id: 0 }, // Right ], }; const status = createPrinterStatus([ { id: 0, // Left nozzle tray: [ { id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, ], }, { id: 1, // Right nozzle tray: [ { id: 0, tray_type: 'PETG', tray_color: '00FF00' }, ], }, ]); (status as any).ams_extruder_map = { '0': 1, '1': 0 }; const result = computeAmsMapping(reqs, status); expect(result).toEqual([0, 4]); // Left gets AMS0-T0, Right gets AMS1-T0 }); // FTS (Filament Track Switch) — when present, AMS slots aren't tied to a // specific extruder. The track switch routes any slot to either extruder, so // the per-nozzle hard filter must NOT apply. See #1162. it('ignores nozzle_id when FTS is installed', () => { // Required filament asks for nozzle 1 (left). Without FTS this would force // AMS 0 (which is on the left nozzle). With FTS we accept any AMS slot // matching by type/color since the FTS routes it to whichever extruder. const reqs = { filaments: [ { slot_id: 1, type: 'PETG', color: '#00FF00', used_grams: 10, nozzle_id: 1 }, ], }; const status = createPrinterStatus([ { id: 0, // Without FTS, this AMS would be left/extruder 1; ams_extruder_map // is empty because the printer reports info bits 8-11 = 0xE. tray: [ { id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, { id: 1, tray_type: 'PETG', tray_color: '00FF00' }, ], }, ]); (status as any).ams_extruder_map = {}; (status as any).fila_switch = { installed: true, in_slots: [-1, 1], out_extruders: [0, 1], stat: 0, info: 2, }; const result = computeAmsMapping(reqs, status); expect(result).toEqual([1]); // Picks AMS 0 tray 1 (PETG green) regardless of nozzle }); // X2D / H2D / X2 Pro with no AMS but two external spools (one feeding each // extruder). Pre-fix, dual-nozzle was inferred from `ams_extruder_map` being // non-empty, which fails when there are no AMS units — both vt_tray entries // got `extruderId=undefined`, the per-nozzle filter rejected everything, and // the UI surfaced "Required filament type not found in printer" even though // the matching filament was sitting in the external spool. (#1257) it('matches external spools per-extruder on dual-nozzle without AMS', () => { const reqs = { filaments: [ { slot_id: 1, type: 'PETG', color: '#FFFFFF', used_grams: 15, nozzle_id: 1 }, // Left ], }; const status = createPrinterStatus([], [ // Two external spools, both PETG. Ext-L (id=254) feeds left extruder (1), // Ext-R (id=255) feeds right (0). 255 - id formula in buildLoadedFilaments // routes them when hasDualNozzle is true. { id: 254, tray_type: 'PETG', tray_color: 'FFFFFF' } as PrinterStatus['vt_tray'][number], { id: 255, tray_type: 'PETG', tray_color: '000000' } as PrinterStatus['vt_tray'][number], ]); // Real X2D hardware: both nozzles report a populated diameter via the // MQTT right_nozzle_diameter / left_nozzle_diameter fields. ams_extruder_map // is empty because there are zero AMS units. (status as any).nozzles = [ { nozzle_type: 'stainless_steel', nozzle_diameter: '0.4' }, { nozzle_type: 'stainless_steel', nozzle_diameter: '0.4' }, ]; (status as any).ams_extruder_map = {}; // Loaded filaments must surface extruderId on each external entry, // otherwise computeAmsMapping's per-nozzle filter strips them out. const loaded = buildLoadedFilaments(status); expect(loaded).toHaveLength(2); expect(loaded.find((f) => f.globalTrayId === 254)?.extruderId).toBe(1); // Ext-L → left expect(loaded.find((f) => f.globalTrayId === 255)?.extruderId).toBe(0); // Ext-R → right // Mapping must succeed and pick Ext-L (left extruder, white PETG). const result = computeAmsMapping(reqs, status); expect(result).toEqual([254]); }); // Sibling regression: the bambu_mqtt state defaults `nozzles` to a 2-entry // list with empty NozzleInfo() stubs even on single-nozzle printers, and the // route emits both entries on the wire. The dual-nozzle inference must NOT // be tripped by a stub second entry — only by populated hardware info, // populated ams_extruder_map, or >1 external trays. Pin: single-nozzle // printer (P1S/A1/X1C) with one external spool gets extruderId=undefined, // matching pre-fix behaviour. (#1257) it('does not fabricate extruderId for single-nozzle with stub nozzles[1]', () => { const status = createPrinterStatus([], [ { id: 254, tray_type: 'PLA', tray_color: 'FF0000' } as PrinterStatus['vt_tray'][number], ]); // Single-nozzle: nozzles[1] is the default stub (empty fields). (status as any).nozzles = [ { nozzle_type: 'stainless_steel', nozzle_diameter: '0.4' }, { nozzle_type: '', nozzle_diameter: '' }, ]; (status as any).ams_extruder_map = {}; const loaded = buildLoadedFilaments(status); expect(loaded).toHaveLength(1); expect(loaded[0].extruderId).toBeUndefined(); }); it('still applies nozzle filter when FTS object is null', () => { // Sanity check: explicit null fila_switch behaves like no FTS — nozzle // filter still applies on real dual-nozzle printers. const reqs = { filaments: [ { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 }, ], }; const status = createPrinterStatus([ { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }] }, { id: 1, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }] }, ]); (status as any).ams_extruder_map = { '0': 1, '1': 0 }; (status as any).fila_switch = null; const result = computeAmsMapping(reqs, status); expect(result).toEqual([0]); // AMS 0 (left/extruder 1) }); }); // ============================================================================ // MODEL-SPECIFIC TESTS: Real data from actual printers // ============================================================================ /** * H2D real data fixture (from live API response 2026-02-18). * * Configuration: * LEFT nozzle (extruder 1): AMS 0 (4-slot), AMS 2 (4-slot) * RIGHT nozzle (extruder 0): AMS 1 (4-slot), AMS-HT 128 (1-slot, empty) * External: 254 (Ext-L, LEFT nozzle), 255 (Ext-R, RIGHT nozzle) * * ams_extruder_map: {"0": 1, "1": 0, "2": 1, "128": 0} */ function createH2DStatus(): PrinterStatus { const status = createPrinterStatus( [ { id: 0, // LEFT nozzle (extruder 1) humidity: 24, temp: 21.4, tray: [ { id: 0, tray_type: 'PETG', tray_color: 'FFFFFFFF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' }, { id: 1, tray_type: 'PLA', tray_color: 'C8C8C8FF', tray_info_idx: 'GFA06', tray_sub_brands: 'PLA Silk+' }, { id: 2, tray_type: 'PETG', tray_color: '875718FF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' }, { id: 3, tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' }, ], }, { id: 1, // RIGHT nozzle (extruder 0) humidity: 25, temp: 21.7, tray: [ { id: 0, tray_type: 'PLA', tray_color: 'FFFFFFFF', tray_info_idx: 'GFA00', tray_sub_brands: 'PLA Basic' }, { id: 1, tray_type: 'PETG', tray_color: '000000FF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' }, { id: 2, tray_type: 'PLA', tray_color: '5F6367FF', tray_info_idx: 'GFA06', tray_sub_brands: 'PLA Silk+' }, { id: 3, tray_type: 'PLA', tray_color: 'B39B84FF', tray_info_idx: 'GFA02', tray_sub_brands: 'PLA Metal' }, ], }, { id: 128, // AMS-HT, RIGHT nozzle (extruder 0) — empty humidity: 48, temp: 21.4, tray: [ { id: 0 }, // empty tray ], }, { id: 2, // LEFT nozzle (extruder 1) humidity: 18, temp: 24.0, tray: [ { id: 0, tray_type: 'PLA-S', tray_color: 'FFFFFFFF', tray_info_idx: 'P8aa1726' }, { id: 1, tray_type: 'PLA', tray_color: '56B7E6FF', tray_info_idx: 'PFUS9924' }, { id: 2, tray_type: 'PETG', tray_color: '6EE53CFF', tray_info_idx: 'GFG02', tray_sub_brands: 'PETG HF' }, { id: 3, tray_type: 'PLA', tray_color: 'FF0000FF', tray_info_idx: 'PFUS9ac9' }, ], }, ], [ { id: 254, tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'P4d64437' }, // Ext-L (loaded) { id: 255, tray_type: '', tray_color: '00000000' }, // Ext-R (empty) ] ); (status as any).ams_extruder_map = { '0': 1, '1': 0, '2': 1, '128': 0 }; return status; } /** * X1C real data fixture (from live API response 2026-02-18). * * Configuration: * Single nozzle (extruder 0): AMS 0 (4-slot), AMS 1 (4-slot) * External: 254 (single) * * ams_extruder_map: {"0": 0, "1": 0} ← NOT empty, all on extruder 0 */ function createX1CStatus(): PrinterStatus { const status = createPrinterStatus( [ { id: 0, humidity: 23, temp: 26.1, tray: [ { id: 0 }, // empty (has tray_color but no tray_type) { id: 1 }, // empty { id: 2 }, // empty (has tray_color FFFFFFFF but no tray_type) { id: 3 }, // empty ], }, { id: 1, humidity: 20, temp: 25.9, tray: [ { id: 0 }, // empty { id: 1, tray_type: 'PLA', tray_color: 'EBCFA6FF', tray_info_idx: 'PFUS22b2' }, { id: 2, tray_type: 'PLA', tray_color: 'FCECD6FF', tray_info_idx: 'P4d64437' }, { id: 3, tray_type: 'PLA', tray_color: '0066FFFF', tray_info_idx: 'P4d64437' }, ], }, ], [ { id: 254, tray_type: '', tray_color: '00000000' }, // empty ] ); (status as any).ams_extruder_map = { '0': 0, '1': 0 }; return status; } describe('H2D model tests (dual nozzle, real data)', () => { describe('buildLoadedFilaments', () => { it('assigns correct extruderId to all AMS units', () => { const result = buildLoadedFilaments(createH2DStatus()); // AMS 0 trays → extruder 1 (LEFT) const ams0 = result.filter((f) => f.amsId === 0); expect(ams0).toHaveLength(4); ams0.forEach((f) => expect(f.extruderId).toBe(1)); // AMS 1 trays → extruder 0 (RIGHT) const ams1 = result.filter((f) => f.amsId === 1); expect(ams1).toHaveLength(4); ams1.forEach((f) => expect(f.extruderId).toBe(0)); // AMS 2 trays → extruder 1 (LEFT) const ams2 = result.filter((f) => f.amsId === 2); expect(ams2).toHaveLength(4); ams2.forEach((f) => expect(f.extruderId).toBe(1)); }); it('computes correct globalTrayId for all AMS types', () => { const result = buildLoadedFilaments(createH2DStatus()); // Regular AMS: amsId * 4 + trayId expect(result.find((f) => f.amsId === 0 && f.trayId === 0)?.globalTrayId).toBe(0); expect(result.find((f) => f.amsId === 0 && f.trayId === 3)?.globalTrayId).toBe(3); expect(result.find((f) => f.amsId === 1 && f.trayId === 0)?.globalTrayId).toBe(4); expect(result.find((f) => f.amsId === 1 && f.trayId === 3)?.globalTrayId).toBe(7); expect(result.find((f) => f.amsId === 2 && f.trayId === 0)?.globalTrayId).toBe(8); expect(result.find((f) => f.amsId === 2 && f.trayId === 3)?.globalTrayId).toBe(11); }); it('skips empty AMS-HT tray (no tray_type)', () => { const result = buildLoadedFilaments(createH2DStatus()); // AMS-HT 128 is empty in real data — should be skipped const ht = result.filter((f) => f.amsId === 128); expect(ht).toHaveLength(0); }); it('includes loaded external spool with correct extruder', () => { const result = buildLoadedFilaments(createH2DStatus()); const ext = result.filter((f) => f.isExternal); // Only Ext-L (254) has filament, Ext-R (255) is empty expect(ext).toHaveLength(1); expect(ext[0].globalTrayId).toBe(254); expect(ext[0].type).toBe('PLA'); // Ext-L (254) should be LEFT nozzle (extruder 1) expect(ext[0].extruderId).toBe(1); }); it('returns 13 loaded filaments total (12 AMS + 1 external)', () => { const result = buildLoadedFilaments(createH2DStatus()); // AMS 0: 4, AMS 1: 4, AMS-HT 128: 0 (empty), AMS 2: 4, External: 1 expect(result).toHaveLength(13); }); }); describe('computeAmsMapping', () => { it('matches left-nozzle filament to left-nozzle AMS only', () => { const reqs = { filaments: [ { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 10, nozzle_id: 1 }, ], }; const result = computeAmsMapping(reqs, createH2DStatus()); // Black PLA on LEFT: AMS 0 T4 (globalTrayId 3) is PLA Basic black on left expect(result).toEqual([3]); }); it('matches right-nozzle filament to right-nozzle AMS only', () => { const reqs = { filaments: [ { slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 10, nozzle_id: 0 }, ], }; const result = computeAmsMapping(reqs, createH2DStatus()); // White PLA on RIGHT: AMS 1 T1 (globalTrayId 4) is PLA Basic white on right expect(result).toEqual([4]); }); it('rejects cross-nozzle assignment (right requires type only on left)', () => { const reqs = { filaments: [ // PLA-S only exists on AMS 2 T1 (left nozzle), but requires right nozzle { slot_id: 1, type: 'PLA-S', color: '#FFFFFF', used_grams: 10, nozzle_id: 0, tray_info_idx: 'P8aa1726' }, ], }; const result = computeAmsMapping(reqs, createH2DStatus()); expect(result).toEqual([-1]); // No fallback to wrong nozzle }); it('maps dual-nozzle multi-filament print correctly', () => { const reqs = { filaments: [ // Slot 1: PETG white on LEFT → AMS 0 T1 (globalTrayId 0) { slot_id: 1, type: 'PETG', color: '#FFFFFF', used_grams: 30, nozzle_id: 1, tray_info_idx: 'GFG02' }, // Slot 2: PLA white on RIGHT → AMS 1 T1 (globalTrayId 4) { slot_id: 2, type: 'PLA', color: '#FFFFFF', used_grams: 20, nozzle_id: 0, tray_info_idx: 'GFA00' }, ], }; const result = computeAmsMapping(reqs, createH2DStatus()); expect(result).toEqual([0, 4]); }); it('matches external spool on correct nozzle', () => { const reqs = { filaments: [ // Ext-L has black PLA loaded, on LEFT nozzle (extruder 1) { slot_id: 1, type: 'PLA', color: '#000000', used_grams: 5, nozzle_id: 1, tray_info_idx: 'P4d64437' }, ], }; const result = computeAmsMapping(reqs, createH2DStatus()); expect(result).toEqual([254]); // External spool on left nozzle }); }); }); describe('X1C model tests (single nozzle, real data)', () => { describe('buildLoadedFilaments', () => { it('assigns all filaments to extruder 0', () => { const result = buildLoadedFilaments(createX1CStatus()); result.forEach((f) => expect(f.extruderId).toBe(0)); }); it('computes correct globalTrayId for regular AMS', () => { const result = buildLoadedFilaments(createX1CStatus()); // AMS 1 T2 (tray id 1) → globalTrayId 5 expect(result.find((f) => f.amsId === 1 && f.trayId === 1)?.globalTrayId).toBe(5); // AMS 1 T3 (tray id 2) → globalTrayId 6 expect(result.find((f) => f.amsId === 1 && f.trayId === 2)?.globalTrayId).toBe(6); // AMS 1 T4 (tray id 3) → globalTrayId 7 expect(result.find((f) => f.amsId === 1 && f.trayId === 3)?.globalTrayId).toBe(7); }); it('returns only loaded trays (3 from AMS 1)', () => { const result = buildLoadedFilaments(createX1CStatus()); // AMS 0: all 4 slots empty, AMS 1: slots 1-3 loaded, External: empty expect(result).toHaveLength(3); }); }); describe('computeAmsMapping', () => { it('matches single-nozzle file without nozzle filtering', () => { const reqs = { filaments: [ { slot_id: 1, type: 'PLA', color: '#0066FF', used_grams: 15 }, ], }; const result = computeAmsMapping(reqs, createX1CStatus()); // Blue PLA → AMS 1 T4 (globalTrayId 7, color 0066FF) expect(result).toEqual([7]); }); it('matches by tray_info_idx across AMS units', () => { const reqs = { filaments: [ { slot_id: 1, type: 'PLA', color: '#EBCFA6', used_grams: 10, tray_info_idx: 'PFUS22b2' }, ], }; const result = computeAmsMapping(reqs, createX1CStatus()); // PFUS22b2 uniquely in AMS 1 T2 (globalTrayId 5) expect(result).toEqual([5]); }); it('handles non-unique tray_info_idx with color matching', () => { // P4d64437 appears in both AMS 1 T3 and T4 const reqs = { filaments: [ { slot_id: 1, type: 'PLA', color: '#FCECD6', used_grams: 10, tray_info_idx: 'P4d64437' }, ], }; const result = computeAmsMapping(reqs, createX1CStatus()); // Should pick AMS 1 T3 (globalTrayId 6, color FCECD6) over T4 (0066FF) expect(result).toEqual([6]); }); it('does not cross-nozzle filter for single-nozzle printer', () => { // Even if ams_extruder_map exists, single-nozzle 3MF has no nozzle_id const reqs = { filaments: [ { slot_id: 1, type: 'PLA', color: '#EBCFA6', used_grams: 10 }, { slot_id: 2, type: 'PLA', color: '#0066FF', used_grams: 10 }, ], }; const result = computeAmsMapping(reqs, createX1CStatus()); // Both should match freely across all AMS units expect(result).toEqual([5, 7]); }); }); }); describe('computeAmsMapping preferLowest', () => { it('picks spool with lowest remain when enabled', () => { 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: 'FF0000', remain: 80 }, { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 }, ], }, ]); const result = computeAmsMapping(reqs, status, true); expect(result).toEqual([1]); // Tray 1 has 25% remain }); it('picks first match when disabled', () => { 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: 'FF0000', remain: 80 }, { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 }, ], }, ]); const result = computeAmsMapping(reqs, status, false); expect(result).toEqual([0]); // First match (default) }); it('sorts unknown remain to end', () => { 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: 'FF0000' }, // No remain (defaults to -1) { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 60 }, ], }, ]); const result = computeAmsMapping(reqs, status, true); expect(result).toEqual([1]); // Known 60% over unknown }); }); // #1766: the user reported that "Prefer lowest remaining filament" picked the // wrong spool when two identical-material/color spools differed only in the // inventory-tracked grams (not the printer's `remain%`). The pre-fix sort // looked at `remain%` only and ignored Bambuddy's bound inventory entirely; // now we pass a globalTrayId -> grams map and the sort lifts inventory-bound // spools to tier 0 (matching backend _prefer_lowest_sort_key). describe('computeAmsMapping preferLowest with inventory map (#1766)', () => { it('picks spool with lower inventory grams when both spools report same remain%', () => { // Reporter's scenario: two identical Bambu-branded spools, both report // `remain=100` because they were freshly inserted, but inventory has them // at 950 g vs 50 g remaining. Pre-fix sort ties and picks the first. const reqs = { filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'GFA00' }], }; const status = createPrinterStatus([ { id: 0, tray: [ { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 100 }, { id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 100 }, ], }, ]); const inventory = new Map([[0, 950], [1, 50]]); const result = computeAmsMapping(reqs, status, true, inventory); expect(result).toEqual([1]); // Inventory says tray 1 is nearly empty — use it first. }); it('prefers inventory-tracked spool over non-tracked one even when remain% would order them differently', () => { // Two spools both match by type+color, both have the same tray_info_idx // (identical SKU). Tray 0 has no inventory binding but reports remain=20. // Tray 1 has an inventory binding with 100 g remaining. Tier 0 (bound) // always beats tier 1 (MQTT-only) regardless of value — matches backend. const reqs = { filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'GFA00' }], }; const status = createPrinterStatus([ { id: 0, tray: [ { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 20 }, { id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 80 }, ], }, ]); const inventory = new Map([[1, 100]]); // Only tray 1 bound const result = computeAmsMapping(reqs, status, true, inventory); expect(result).toEqual([1]); }); it('falls back to remain% sort when no inventory map provided (pre-#1766 behaviour)', () => { // Regression guard: callers that haven't yet wired the map must get the // same sort they always got. None of the existing tests in this file pass // a map; this asserts the default path is unchanged. 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: 'FF0000', remain: 80 }, { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 }, ], }, ]); const result = computeAmsMapping(reqs, status, true, undefined); expect(result).toEqual([1]); // Same as the existing no-inventory case. }); }); // #1766 safety gate: when the printer has AMS Filament Backup OFF, the sort // MUST NOT run, even with the user setting on. Otherwise the dispatch picks a // near-empty spool the printer can't switch off of when it runs out mid-print. // Mirrors backend `_compute_ams_mapping_for_printer` gate. describe('effectivePreferLowest gate (#1766)', () => { it('coerces to false when backup is OFF', () => { expect(effectivePreferLowest(true, false)).toBe(false); }); it('passes through when backup is ON', () => { expect(effectivePreferLowest(true, true)).toBe(true); }); it('passes through when backup is unknown (null/undefined — A1 family)', () => { expect(effectivePreferLowest(true, null)).toBe(true); expect(effectivePreferLowest(true, undefined)).toBe(true); }); it('stays false when the user setting is off, regardless of backup state', () => { expect(effectivePreferLowest(false, true)).toBe(false); expect(effectivePreferLowest(false, false)).toBe(false); expect(effectivePreferLowest(undefined, true)).toBe(false); }); it('slot-priority tie-break: external/VT spools sort AFTER regular AMS', () => { // Mirrors backend `_slot_priority` banding. When tier and value tie, slot // position decides — external (ams_id = -1) must clamp to 10_000 so it // can't beat AMS slot 0 (priority 0). 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: 'FF0000', remain: -1 }, // priority 0 ], }, ], [{ id: 254, tray_type: 'PLA', tray_color: 'FF0000', remain: -1 }], // priority 10_000 ); const result = computeAmsMapping(reqs, status, true); expect(result).toEqual([0]); // AMS slot wins the tie; VT does not. }); it('end-to-end: backup OFF prevents lowest-pick at dispatch (caller-coerced)', () => { // PrintModal computes the effective flag and passes it to computeAmsMapping. // This pins that flow: with backup=false the flag becomes false, the sort // doesn't run, and the first matching tray wins (today's behaviour). 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: 'FF0000', remain: 80 }, { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 5 }, // near-empty ], }, ]); const gated = effectivePreferLowest(true, false); const result = computeAmsMapping(reqs, status, gated); expect(result).toEqual([0]); // First match wins; the 5%-remain spool is NOT selected. }); }); describe('per-plate mapping vs. the whole-file union (#2551 follow-up)', () => { // The multi-plate case that made this necessary: two plates, each printing one // red object, but on different slots of the same file. The printer has exactly // one red spool. const PLATE_1 = { filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }] }; const PLATE_2 = { filaments: [{ slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 }] }; const WHOLE_FILE_UNION = { filaments: [ { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }, { slot_id: 2, type: 'PLA', color: '#FF0000', used_grams: 10 }, ], }; const printer = createPrinterStatus([ { id: 0, tray: [ { id: 0, tray_type: 'PLA', tray_color: 'FF0000' }, // the only red — global tray 0 { id: 1, tray_type: 'PLA', tray_color: '000000' }, // black — global tray 1 ], }, ]); const mapPlate = (reqs: { filaments: { slot_id: number; type: string; color: string; used_grams: number }[] }) => buildAmsMapping(buildFilamentComparison(reqs, buildLoadedFilaments(printer), {})); it('maps each plate to the red tray, because each plate is its own print', () => { expect(mapPlate(PLATE_1)).toEqual([0]); // Slot 1 is unused by this plate, so it stays -1; slot 2 takes the red tray. expect(mapPlate(PLATE_2)).toEqual([-1, 0]); }); it('the union starves the second slot — which is what a shared mapping sent', () => { // Tray assignment is stateful: slot 1 claims the red tray, so slot 2 falls // through to a type-only match on the BLACK tray. Sending this to plate 2 // prints it in black. This is the mapping the modal used to post for every // plate of a multi-plate submission. expect(mapPlate(WHOLE_FILE_UNION)).toEqual([0, 1]); }); it('a manual override on one plate does not leak into another', () => { const loaded = buildLoadedFilaments(printer); // The user pins plate 2's slot 2 to the black tray by hand. const plate2 = buildAmsMapping(buildFilamentComparison(PLATE_2, loaded, { 2: 1 })); expect(plate2).toEqual([-1, 1]); // Plate 1 is mapped from its own (empty) override set and still gets red. expect(buildAmsMapping(buildFilamentComparison(PLATE_1, loaded, {}))).toEqual([0]); }); }); describe('useFilamentMapping — no [-1] mapping during a status-load race (#2589)', () => { // The file requires one PETG slot; two compatible PETG spools are loaded once // the printer status arrives. const filamentReqs = { filaments: [{ slot_id: 1, type: 'PETG', color: '#161616', used_grams: 141.86 }], }; const statusWithPetg = createPrinterStatus([ { id: 0, tray: [ { id: 0, tray_type: 'PETG', tray_color: 'BCBCBC' }, { id: 1, tray_type: 'PETG', tray_color: 'FFFFFF' }, ], }, ]); it('returns undefined (not [-1]) while printerStatus is still loading', () => { // printerStatus undefined = query not resolved yet. Serializing [-1] here is // exactly what dispatched the P1S print to the empty external feed. const { result } = renderHook(() => useFilamentMapping(filamentReqs, undefined, {})); expect(result.current.amsMapping).toBeUndefined(); }); it('resolves to an AMS tray once status has loaded (type-only match, strict color off)', () => { // Black requested, only gray/white PETG loaded: a type-only match is valid. const { result } = renderHook(() => useFilamentMapping(filamentReqs, statusWithPetg, {})); expect(result.current.amsMapping).toEqual([0]); expect(result.current.hasTypeMismatch).toBe(false); }); it('still emits [-1] for a genuine mismatch when trays are present', () => { // Status loaded but nothing compatible (only PLA) -> the mapping legitimately // carries -1 and the mismatch is surfaced; this must NOT be suppressed. const statusWithPla = createPrinterStatus([ { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }] }, ]); const { result } = renderHook(() => useFilamentMapping(filamentReqs, statusWithPla, {})); expect(result.current.amsMapping).toEqual([-1]); expect(result.current.hasTypeMismatch).toBe(true); }); }); describe('colour verdict is independent of how the tray was found (#2687)', () => { // tray_info_idx names the filament *variant*, not an individual spool: // GFA00 = PLA Basic, GFA01 = PLA Matte, GFA17 = PLA Translucent. A user with // exactly one Matte spool loaded therefore idx-matches every Matte // requirement no matter what colour it is. const MATTE_DARK_GREEN = createPrinterStatus([ { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: '004225', tray_info_idx: 'GFA01' }] }, ]); const wantRedMatte = { filaments: [ { slot_id: 1, type: 'PLA', color: '#9D432C', used_grams: 31, tray_info_idx: 'GFA01' }, ], }; it('reports a unique-idx tray of the wrong colour as type_only, not match', () => { const [item] = buildFilamentComparison( wantRedMatte, buildLoadedFilaments(MATTE_DARK_GREEN), {}, ); // The tray is still selected — it is the right variant (#2650) ... expect(item.loaded?.globalTrayId).toBe(0); // ... but red-on-dark-green is not a colour match. expect(item.colorMatch).toBe(false); expect(item.status).toBe('type_only'); }); it('auto and manual agree on the same tray', () => { const loaded = buildLoadedFilaments(MATTE_DARK_GREEN); const auto = buildFilamentComparison(wantRedMatte, loaded, {})[0]; const manual = buildFilamentComparison(wantRedMatte, loaded, { 1: 0 })[0]; // The original report: auto said "match", manually picking that very tray // said "mismatch". Both paths must now reach the same verdict. expect(manual.isManual).toBe(true); expect(auto.status).toBe(manual.status); expect(auto.colorMatch).toBe(manual.colorMatch); }); it('surfaces the mismatch through the hook so the panel stops saying Ready', () => { const { result } = renderHook(() => useFilamentMapping(wantRedMatte, MATTE_DARK_GREEN, {})); // hasColorMismatch drives the yellow "(Color mismatch)" header; the tray is // still mapped, so this is not a type mismatch. expect(result.current.hasColorMismatch).toBe(true); expect(result.current.hasTypeMismatch).toBe(false); expect(result.current.amsMapping).toEqual([0]); }); it('still reports a match when the unique-idx tray does carry the right colour', () => { const [item] = buildFilamentComparison( { filaments: [{ slot_id: 1, type: 'PLA', color: '#004225', used_grams: 31, tray_info_idx: 'GFA01' }] }, buildLoadedFilaments(MATTE_DARK_GREEN), {}, ); expect(item.status).toBe('match'); expect(item.colorMatch).toBe(true); }); it('accepts a near-enough shade on the idx path', () => { // Within colorsAreSimilar's per-channel tolerance — the printer reporting a // spool a shade off must not become a mismatch. const [item] = buildFilamentComparison( { filaments: [{ slot_id: 1, type: 'PLA', color: '#0A4A2A', used_grams: 31, tray_info_idx: 'GFA01' }] }, buildLoadedFilaments(MATTE_DARK_GREEN), {}, ); expect(item.status).toBe('match'); }); it('treats a colourless requirement as satisfied by any colour', () => { // 3MFs that omit the colour parse to "" (filament_requirements.py); there is // nothing to disagree with, so this must not read as a colour mismatch. const [item] = buildFilamentComparison( { filaments: [{ slot_id: 1, type: 'PLA', color: '', used_grams: 31, tray_info_idx: 'GFA01' }] }, buildLoadedFilaments(MATTE_DARK_GREEN), {}, ); expect(item.status).toBe('match'); expect(item.colorMatch).toBe(true); }); it('keeps the multi-idx path intact — same idx, several colours picks the right one', () => { // Two Matte spools: the branch that already compared colours must be // unaffected, and the exact-colour tray still wins. const twoMatte = createPrinterStatus([ { id: 0, tray: [ { id: 0, tray_type: 'PLA', tray_color: '004225', tray_info_idx: 'GFA01' }, { id: 1, tray_type: 'PLA', tray_color: '9D432C', tray_info_idx: 'GFA01' }, ], }, ]); const [item] = buildFilamentComparison(wantRedMatte, buildLoadedFilaments(twoMatte), {}); expect(item.loaded?.globalTrayId).toBe(1); expect(item.status).toBe('match'); }); it('a type-only fallback with no idx candidate is still type_only', () => { // Regression guard: the pre-existing "type matches, colour does not" path. const basicOnly = createPrinterStatus([ { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: '004225', tray_info_idx: 'GFA00' }] }, ]); const [item] = buildFilamentComparison(wantRedMatte, buildLoadedFilaments(basicOnly), {}); expect(item.status).toBe('type_only'); expect(item.colorMatch).toBe(false); }); }); describe('filament type equivalence groups reach the matcher', () => { // The scheduler has always canonicalised types before comparing, so PA12-CF // satisfies a PA-CF requirement at dispatch. The interface compared the raw // strings, so it called the same pairing a type mismatch — the badge // contradicted what the printer would actually do, and the manual override // picker (which groups by canonical type) offered the very spool the badge // then rejected. Both sides now use `filamentTypesCompatible`. const wantPaCf = { filaments: [{ slot_id: 1, type: 'PA-CF', color: '#1A1A1A', used_grams: 20 }], }; const pa12Loaded = createPrinterStatus([ { id: 0, tray: [{ id: 0, tray_type: 'PA12-CF', tray_color: '1A1A1AFF' }] }, ]); it('matches PA12-CF against a PA-CF requirement', () => { const [item] = buildFilamentComparison(wantPaCf, buildLoadedFilaments(pa12Loaded), {}); expect(item.loaded?.globalTrayId).toBe(0); expect(item.status).toBe('match'); }); it('agrees with the mapping the request is built from', () => { expect( buildAmsMapping(buildFilamentComparison(wantPaCf, buildLoadedFilaments(pa12Loaded), {})), ).toEqual([0]); }); it('still refuses a genuinely different material', () => { const petg = createPrinterStatus([ { id: 0, tray: [{ id: 0, tray_type: 'PETG', tray_color: '1A1A1AFF' }] }, ]); const [item] = buildFilamentComparison(wantPaCf, buildLoadedFilaments(petg), {}); expect(item.status).toBe('mismatch'); }); it('does not treat a product variant as an alias for the base material', () => { // "PLA Silk" dries like PLA but does not print like it, so the matcher // must not substitute one for the other. Matches the backend's rule. const silk = createPrinterStatus([ { id: 0, tray: [{ id: 0, tray_type: 'PLA Silk', tray_color: 'FFFFFFFF' }] }, ]); const wantPla = { filaments: [{ slot_id: 1, type: 'PLA', color: '#FFFFFF', used_grams: 20 }], }; const [item] = buildFilamentComparison(wantPla, buildLoadedFilaments(silk), {}); expect(item.status).toBe('mismatch'); }); });