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