| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447 |
- /**
- * 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<number, number>([[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<number, number>([[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');
- });
- });
|