/** * 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('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 }); });