mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
Two tightly-coupled deliverables in one drop -- a new AMS Filament Backup status/control surface, and the #1766 fix that depends on it. Added -- AMS Filament Backup status + control - Parse bit 18 of top-level print.cfg into PrinterState.ams_filament_backup on every push_status. Verified against OrcaSlicer source (DeviceManager.cpp:4961) and a live H2D ON/OFF capture. Tri-state (None = A1 family / pre-cfg push) preserves today's behaviour. - Hold-timer guard (3 s) prevents stale frames from flickering the badge back to the printer's old cfg after a user-initiated toggle. - POST /printers/{id}/ams-backup toggle, set_ams_filament_backup() client method calling _set_print_option("auto_switch_filament", enabled). - GET /printers/{id}/inventory-remain endpoint exposes the same map the dispatcher uses (internal and Spoolman modes both work uniformly). - Small icon badge in the printer card's "Filaments" section header (placement reads as printer-wide because the cfg bit is printer-wide, not per-AMS). Click to toggle, success toast. - 5 i18n keys x 11 locales for the badge UI. Fixed -- #1766: prefer_lowest didn't pick lowest, ignored backup state - Backend gate in _compute_ams_mapping_for_printer: coerce prefer_lowest to False when status.ams_filament_backup is False; log the skip. - New effectivePreferLowest(setting, backup) helper applied at every frontend sort entry point: single-printer PrintModal, multi-printer hook per-printer, PrinterSelector InlineMappingEditor, FilamentMapping standalone editor (the last had NO preferLowest awareness at all before this change). - New preferLowestSortKey(f, inventoryByTrayId) mirrors backend's two-tier key exactly, including the banding tie-break (regular AMS < AMS-HT < external) so the client-side pre-compute matches the dispatch-time pick. An earlier draft used a flat `amsId * 4 + trayId` priority which gave external slots (ams_id = -1) a NEGATIVE priority -- caught in code review before commit. - Settings -> Filament -> "Prefer lowest remaining filament" gets an explanatory note about the printer-side AMS Backup dependency, with i18n key in all 11 locales.
1194 lines
41 KiB
TypeScript
1194 lines
41 KiB
TypeScript
/**
|
|
* 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 { 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.
|
|
});
|
|
});
|