mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 05:31:31 +02:00
Show resolved filament colour and AMS mapping on queue cards (#3184)
This commit is contained in:
@@ -11,7 +11,9 @@ import React from 'react';
|
||||
import { AmsUnitCard } from '../../../components/spoolbuddy/AmsUnitCard';
|
||||
import type { AMSUnit, AMSTray } from '../../../api/client';
|
||||
|
||||
vi.mock('../../../utils/amsHelpers', () => ({
|
||||
vi.mock('../../../utils/amsHelpers', async (importOriginal) => ({
|
||||
// Keep the real getEmptySlotKind: the "Empty" vs "?" cases below test it.
|
||||
...(await importOriginal<typeof import('../../../utils/amsHelpers')>()),
|
||||
getFillBarColor: (fill: number) => {
|
||||
if (fill > 50) return '#00ae42';
|
||||
if (fill >= 15) return '#f59e0b';
|
||||
|
||||
@@ -197,6 +197,691 @@ describe('QueuePage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the queue override colour instead of the original 3MF colour (#3132)', async () => {
|
||||
const item = {
|
||||
...mockQueueItems[0],
|
||||
id: 82,
|
||||
printer_id: null,
|
||||
target_model: 'P2S',
|
||||
archive_id: null,
|
||||
library_file_id: 13,
|
||||
archive_name: null,
|
||||
library_file_name: 'Override colour test',
|
||||
plate_id: 1,
|
||||
filament_used_grams: 23.66,
|
||||
filament_type: 'PLA',
|
||||
filament_color: '#7C4B00',
|
||||
filament_overrides: [
|
||||
{
|
||||
slot_id: 1,
|
||||
type: 'PLA',
|
||||
color: '#C2BAA7FF',
|
||||
color_name: 'Bone White',
|
||||
force_color_match: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
server.use(
|
||||
http.get('/api/v1/queue/', () => HttpResponse.json([item])),
|
||||
http.get('/api/v1/library/files/13/plates', () =>
|
||||
HttpResponse.json({
|
||||
file_id: 13,
|
||||
filename: 'override-colour.3mf',
|
||||
plates: [
|
||||
{
|
||||
index: 1,
|
||||
name: 'Plate 1',
|
||||
objects: ['Part'],
|
||||
has_thumbnail: false,
|
||||
thumbnail_url: null,
|
||||
print_time_seconds: 3095,
|
||||
filament_used_grams: 23.66,
|
||||
filaments: [
|
||||
{
|
||||
slot_id: 1,
|
||||
type: 'PLA',
|
||||
color: '#7C4B00',
|
||||
used_grams: 23.7,
|
||||
used_meters: 7.93,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
is_multi_plate: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<QueuePage />);
|
||||
|
||||
const colourName = await screen.findByText('Bone White');
|
||||
const row = colourName.closest('.group');
|
||||
|
||||
expect(row).not.toBeNull();
|
||||
expect(within(row as HTMLElement).getByTestId('filament-swatch')).toHaveAttribute(
|
||||
'title',
|
||||
'#C2BAA7',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the resolved AMS slot and bound spool when a physical mapping is stored (#3132)', async () => {
|
||||
const item = {
|
||||
...mockQueueItems[0],
|
||||
id: 85,
|
||||
printer_id: 1,
|
||||
printer_name: 'Test Printer',
|
||||
archive_id: null,
|
||||
library_file_id: 16,
|
||||
archive_name: null,
|
||||
library_file_name: 'Resolved AMS mapping test',
|
||||
plate_id: 1,
|
||||
ams_mapping: [1],
|
||||
filament_overrides: [
|
||||
{
|
||||
slot_id: 1,
|
||||
type: 'PLA',
|
||||
color: '#8E351BFF',
|
||||
color_name: 'Caramel',
|
||||
force_color_match: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
server.use(
|
||||
http.get('/api/v1/queue/', () => HttpResponse.json([item])),
|
||||
http.get('/api/v1/library/files/16/plates', () =>
|
||||
HttpResponse.json({
|
||||
file_id: 16,
|
||||
filename: 'resolved-mapping.3mf',
|
||||
plates: [
|
||||
{
|
||||
index: 1,
|
||||
name: 'Plate 1',
|
||||
objects: ['Part'],
|
||||
has_thumbnail: false,
|
||||
thumbnail_url: null,
|
||||
print_time_seconds: 3600,
|
||||
filament_used_grams: 20,
|
||||
filaments: [
|
||||
{
|
||||
slot_id: 1,
|
||||
type: 'PLA',
|
||||
color: '#7C4B00',
|
||||
used_grams: 20,
|
||||
used_meters: 6.7,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
is_multi_plate: false,
|
||||
}),
|
||||
),
|
||||
http.get('/api/v1/printers/:id/status', () =>
|
||||
HttpResponse.json({
|
||||
id: 1,
|
||||
name: 'Test Printer',
|
||||
connected: true,
|
||||
state: 'IDLE',
|
||||
ams: [
|
||||
{
|
||||
id: 0,
|
||||
// Regular AMS units expose four tray records. Keeping all four
|
||||
// here matters because buildLoadedFilaments distinguishes a
|
||||
// regular AMS from a single-slot AMS-HT by tray count.
|
||||
tray: [
|
||||
{ id: 0, tray_type: null },
|
||||
{
|
||||
id: 1,
|
||||
tray_type: 'PLA',
|
||||
tray_color: 'C2BAA7FF',
|
||||
tray_sub_brands: 'PLA Basic',
|
||||
tray_info_idx: 'GFA00',
|
||||
remain: 80,
|
||||
},
|
||||
{ id: 2, tray_type: null },
|
||||
{ id: 3, tray_type: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
vt_tray: [],
|
||||
nozzles: [],
|
||||
ams_extruder_map: {},
|
||||
}),
|
||||
),
|
||||
http.get('/api/v1/printers/:id/inventory-remain', () =>
|
||||
HttpResponse.json({
|
||||
inventory_remain_g: { '1': 800 },
|
||||
slot_materials: [
|
||||
{
|
||||
ams_id: 0,
|
||||
tray_id: 1,
|
||||
global_tray_id: 1,
|
||||
material_key: 'PLA|basic|bone-white',
|
||||
remaining_g: 800,
|
||||
extruder: 0,
|
||||
spool: {
|
||||
brand: 'eSUN',
|
||||
material: 'PLA',
|
||||
subtype: 'Basic',
|
||||
color_name: 'Bone White',
|
||||
rgba: 'C2BAA7FF',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<QueuePage />);
|
||||
|
||||
const name = await screen.findByText('Resolved AMS mapping test');
|
||||
const row = name.closest('.group');
|
||||
expect(row).not.toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(row as HTMLElement).getByText('A2 · eSUN PLA Basic · Bone White'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(within(row as HTMLElement).queryByText('Caramel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resolves a mapped tray on a second regular AMS unit (#3132)', async () => {
|
||||
const item = {
|
||||
...mockQueueItems[0],
|
||||
id: 86,
|
||||
printer_id: 1,
|
||||
printer_name: 'Test Printer',
|
||||
archive_id: null,
|
||||
library_file_id: 17,
|
||||
archive_name: null,
|
||||
library_file_name: 'Second AMS mapping test',
|
||||
plate_id: 1,
|
||||
ams_mapping: [5],
|
||||
filament_overrides: null,
|
||||
};
|
||||
|
||||
server.use(
|
||||
http.get('/api/v1/queue/', () => HttpResponse.json([item])),
|
||||
http.get('/api/v1/library/files/17/plates', () =>
|
||||
HttpResponse.json({
|
||||
file_id: 17,
|
||||
filename: 'second-ams.3mf',
|
||||
plates: [
|
||||
{
|
||||
index: 1,
|
||||
name: 'Plate 1',
|
||||
objects: ['Part'],
|
||||
has_thumbnail: false,
|
||||
thumbnail_url: null,
|
||||
print_time_seconds: 3600,
|
||||
filament_used_grams: 20,
|
||||
filaments: [
|
||||
{
|
||||
slot_id: 1,
|
||||
type: 'PLA',
|
||||
color: '#111111',
|
||||
used_grams: 20,
|
||||
used_meters: 6.7,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
is_multi_plate: false,
|
||||
}),
|
||||
),
|
||||
http.get('/api/v1/printers/:id/status', () =>
|
||||
HttpResponse.json({
|
||||
id: 1,
|
||||
name: 'Test Printer',
|
||||
connected: true,
|
||||
state: 'IDLE',
|
||||
ams: [
|
||||
{
|
||||
id: 1,
|
||||
tray: [
|
||||
{ id: 0, tray_type: null },
|
||||
{
|
||||
id: 1,
|
||||
tray_type: 'PLA',
|
||||
tray_color: 'C2BAA7FF',
|
||||
tray_sub_brands: 'PLA Basic',
|
||||
tray_info_idx: 'GFA00',
|
||||
remain: 80,
|
||||
},
|
||||
{ id: 2, tray_type: null },
|
||||
{ id: 3, tray_type: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
vt_tray: [],
|
||||
nozzles: [],
|
||||
ams_extruder_map: {},
|
||||
}),
|
||||
),
|
||||
http.get('/api/v1/printers/:id/inventory-remain', () =>
|
||||
HttpResponse.json({
|
||||
inventory_remain_g: { '5': 800 },
|
||||
slot_materials: [
|
||||
{
|
||||
ams_id: 1,
|
||||
tray_id: 1,
|
||||
global_tray_id: 5,
|
||||
material_key: 'PLA|basic|bone-white',
|
||||
remaining_g: 800,
|
||||
extruder: 0,
|
||||
spool: {
|
||||
brand: 'eSUN',
|
||||
material: 'PLA',
|
||||
subtype: 'Basic',
|
||||
color_name: 'Bone White',
|
||||
rgba: 'C2BAA7FF',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<QueuePage />);
|
||||
|
||||
const name = await screen.findByText('Second AMS mapping test');
|
||||
const row = name.closest('.group');
|
||||
expect(row).not.toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(row as HTMLElement).getByText('B2 · eSUN PLA Basic · Bone White'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the selected plate 3MF colour when there is no override (#3132)', async () => {
|
||||
const item = {
|
||||
...mockQueueItems[0],
|
||||
id: 83,
|
||||
printer_id: null,
|
||||
target_model: 'P2S',
|
||||
archive_id: null,
|
||||
library_file_id: 14,
|
||||
archive_name: null,
|
||||
library_file_name: 'Original colour test',
|
||||
plate_id: 1,
|
||||
filament_color: '#FFFFFF',
|
||||
filament_overrides: null,
|
||||
};
|
||||
|
||||
server.use(
|
||||
http.get('/api/v1/queue/', () => HttpResponse.json([item])),
|
||||
http.get('/api/v1/library/files/14/plates', () =>
|
||||
HttpResponse.json({
|
||||
file_id: 14,
|
||||
filename: 'original-colour.3mf',
|
||||
plates: [
|
||||
{
|
||||
index: 1,
|
||||
name: 'Plate 1',
|
||||
objects: ['Part'],
|
||||
has_thumbnail: false,
|
||||
thumbnail_url: null,
|
||||
print_time_seconds: 3600,
|
||||
filament_used_grams: 20,
|
||||
filaments: [
|
||||
{
|
||||
slot_id: 1,
|
||||
type: 'PLA',
|
||||
color: '#7C4B00',
|
||||
used_grams: 20,
|
||||
used_meters: 6.7,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
is_multi_plate: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<QueuePage />);
|
||||
|
||||
await screen.findByText('Original colour test');
|
||||
const row = screen.getByText('Original colour test').closest('.group');
|
||||
|
||||
expect(row).not.toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(within(row as HTMLElement).getByTestId('filament-swatch')).toHaveAttribute(
|
||||
'title',
|
||||
'#7C4B00',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows all used plate colours and only overrides the matching slot (#3132)', async () => {
|
||||
const item = {
|
||||
...mockQueueItems[0],
|
||||
id: 84,
|
||||
printer_id: null,
|
||||
target_model: 'P2S',
|
||||
archive_id: null,
|
||||
library_file_id: 15,
|
||||
archive_name: null,
|
||||
library_file_name: 'Multi colour test',
|
||||
plate_id: 1,
|
||||
filament_overrides: [
|
||||
{
|
||||
slot_id: 2,
|
||||
type: 'PLA',
|
||||
color: '#C2BAA7FF',
|
||||
color_name: 'Bone White',
|
||||
force_color_match: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
server.use(
|
||||
http.get('/api/v1/queue/', () => HttpResponse.json([item])),
|
||||
http.get('/api/v1/library/files/15/plates', () =>
|
||||
HttpResponse.json({
|
||||
file_id: 15,
|
||||
filename: 'multi-colour.3mf',
|
||||
plates: [
|
||||
{
|
||||
index: 1,
|
||||
name: 'Plate 1',
|
||||
objects: ['Part'],
|
||||
has_thumbnail: false,
|
||||
thumbnail_url: null,
|
||||
print_time_seconds: 3600,
|
||||
filament_used_grams: 30,
|
||||
filaments: [
|
||||
{
|
||||
slot_id: 1,
|
||||
type: 'PLA',
|
||||
color: '#000000',
|
||||
used_grams: 20,
|
||||
used_meters: 6.7,
|
||||
},
|
||||
{
|
||||
slot_id: 2,
|
||||
type: 'PLA',
|
||||
color: '#7C4B00',
|
||||
used_grams: 10,
|
||||
used_meters: 3.35,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
is_multi_plate: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<QueuePage />);
|
||||
|
||||
const overrideName = await screen.findByText('Bone White');
|
||||
const row = overrideName.closest('.group');
|
||||
|
||||
expect(row).not.toBeNull();
|
||||
await waitFor(() => {
|
||||
const swatches = within(row as HTMLElement).getAllByTestId('filament-swatch');
|
||||
expect(swatches).toHaveLength(2);
|
||||
expect(swatches.map((swatch) => swatch.getAttribute('title'))).toEqual([
|
||||
'#000000',
|
||||
'#C2BAA7',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an eight-colour job compact and exposes details in the tooltip (#3132)', async () => {
|
||||
const colours = [
|
||||
'#FF0000',
|
||||
'#00FF00',
|
||||
'#0000FF',
|
||||
'#FFFF00',
|
||||
'#FF00FF',
|
||||
'#00FFFF',
|
||||
'#FFFFFF',
|
||||
'#000000',
|
||||
];
|
||||
const item = {
|
||||
...mockQueueItems[0],
|
||||
id: 87,
|
||||
printer_id: null,
|
||||
target_model: 'P2S',
|
||||
archive_id: null,
|
||||
library_file_id: 18,
|
||||
archive_name: null,
|
||||
library_file_name: 'Eight colour test',
|
||||
plate_id: 1,
|
||||
filament_overrides: colours.map((color, index) => ({
|
||||
slot_id: index + 1,
|
||||
type: 'PLA',
|
||||
color,
|
||||
color_name: `Colour ${index + 1}`,
|
||||
force_color_match: false,
|
||||
})),
|
||||
};
|
||||
|
||||
server.use(
|
||||
http.get('/api/v1/queue/', () => HttpResponse.json([item])),
|
||||
http.get('/api/v1/library/files/18/plates', () =>
|
||||
HttpResponse.json({
|
||||
file_id: 18,
|
||||
filename: 'eight-colour.3mf',
|
||||
plates: [
|
||||
{
|
||||
index: 1,
|
||||
name: 'Plate 1',
|
||||
objects: ['Part'],
|
||||
has_thumbnail: false,
|
||||
thumbnail_url: null,
|
||||
print_time_seconds: 3600,
|
||||
filament_used_grams: 40,
|
||||
filaments: colours.map((color, index) => ({
|
||||
slot_id: index + 1,
|
||||
type: 'PLA',
|
||||
color,
|
||||
used_grams: 5,
|
||||
used_meters: 1.7,
|
||||
})),
|
||||
},
|
||||
],
|
||||
is_multi_plate: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<QueuePage />);
|
||||
|
||||
const name = await screen.findByText('Eight colour test');
|
||||
const row = name.closest('.group');
|
||||
expect(row).not.toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
const compact = within(row as HTMLElement).getByTestId('queue-filament-compact');
|
||||
expect(compact).toHaveAttribute(
|
||||
'title',
|
||||
colours.map((_, index) => `Colour ${index + 1}`).join('\n'),
|
||||
);
|
||||
expect(within(compact).getAllByTestId('filament-swatch')).toHaveLength(8);
|
||||
});
|
||||
// Touch screens never show the tooltip; the same lines are the group's
|
||||
// accessible name.
|
||||
expect(screen.getByRole('img', { name: /^Colour 1, Colour 2, .*Colour 8$/ })).toBe(
|
||||
within(row as HTMLElement).getByTestId('queue-filament-compact'),
|
||||
);
|
||||
|
||||
// Compact mode keeps the long labels out of the metadata row itself.
|
||||
expect(within(row as HTMLElement).queryByText('Colour 1')).not.toBeInTheDocument();
|
||||
expect(within(row as HTMLElement).queryByText('Colour 8')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('stored mapping on the live printer (#3132)', () => {
|
||||
const mappedItem = (amsMapping: number[]) => ({
|
||||
...mockQueueItems[0],
|
||||
id: 88,
|
||||
printer_id: 1,
|
||||
printer_name: 'Test Printer',
|
||||
archive_id: null,
|
||||
library_file_id: 19,
|
||||
archive_name: null,
|
||||
library_file_name: 'Mapped slot test',
|
||||
plate_id: 1,
|
||||
ams_mapping: amsMapping,
|
||||
filament_overrides: [
|
||||
{
|
||||
slot_id: 1,
|
||||
type: 'PLA',
|
||||
color: '#8E351BFF',
|
||||
color_name: 'Caramel',
|
||||
force_color_match: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const useMappedPrinter = (amsMapping: number[], status: Record<string, unknown>) => {
|
||||
server.use(
|
||||
http.get('/api/v1/queue/', () => HttpResponse.json([mappedItem(amsMapping)])),
|
||||
http.get('/api/v1/library/files/19/plates', () =>
|
||||
HttpResponse.json({
|
||||
file_id: 19,
|
||||
filename: 'mapped-slot.3mf',
|
||||
plates: [
|
||||
{
|
||||
index: 1,
|
||||
name: 'Plate 1',
|
||||
objects: ['Part'],
|
||||
has_thumbnail: false,
|
||||
thumbnail_url: null,
|
||||
print_time_seconds: 3600,
|
||||
filament_used_grams: 20,
|
||||
filaments: [
|
||||
{ slot_id: 1, type: 'PLA', color: '#7C4B00', used_grams: 20, used_meters: 6.7 },
|
||||
],
|
||||
},
|
||||
],
|
||||
is_multi_plate: false,
|
||||
}),
|
||||
),
|
||||
http.get('/api/v1/printers/:id/status', () =>
|
||||
HttpResponse.json({
|
||||
id: 1,
|
||||
name: 'Test Printer',
|
||||
state: 'IDLE',
|
||||
nozzles: [],
|
||||
ams_extruder_map: {},
|
||||
...status,
|
||||
}),
|
||||
),
|
||||
http.get('/api/v1/printers/:id/inventory-remain', () =>
|
||||
HttpResponse.json({ inventory_remain_g: {}, slot_materials: [] }),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
// Regular AMS units expose four tray records; slot A3 (global 2) is empty.
|
||||
const amsWithEmptyA3 = [
|
||||
{
|
||||
id: 0,
|
||||
tray: [
|
||||
{ id: 0, tray_type: 'PLA', tray_color: 'FFFFFFFF', remain: 50 },
|
||||
{ id: 1, tray_type: 'PLA', tray_color: 'C2BAA7FF', remain: 80 },
|
||||
// Firmware confirms A3 has no spool (tray_exist_bits bit clear).
|
||||
{ id: 2, tray_type: null, exists: false, state: 9 },
|
||||
{ id: 3, tray_type: null },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const mappedRow = async () => {
|
||||
render(<QueuePage />);
|
||||
const name = await screen.findByText('Mapped slot test');
|
||||
const row = name.closest('.group');
|
||||
expect(row).not.toBeNull();
|
||||
return row as HTMLElement;
|
||||
};
|
||||
|
||||
it('resolves a mapping to the external spool', async () => {
|
||||
useMappedPrinter([254], {
|
||||
connected: true,
|
||||
ams: [],
|
||||
vt_tray: [{ id: 254, tray_type: 'PETG', tray_color: 'C2BAA7FF', remain: 60 }],
|
||||
});
|
||||
const row = await mappedRow();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(row).getByText(/^External · PETG · /)).toBeInTheDocument();
|
||||
});
|
||||
expect(within(row).queryByText('Caramel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says the mapped slot is empty, keeping the intended colour', async () => {
|
||||
useMappedPrinter([2], { connected: true, ams: amsWithEmptyA3, vt_tray: [] });
|
||||
const row = await mappedRow();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(row).getByText('A3 · Empty · Caramel')).toBeInTheDocument();
|
||||
});
|
||||
expect(within(row).getByTestId('filament-swatch')).toHaveAttribute('title', '#8E351B');
|
||||
});
|
||||
|
||||
it('never calls the external spool empty, having no presence signal', async () => {
|
||||
// Shape the status route really sends: an external tray carries no
|
||||
// state / exists, so an unconfigured spool and no spool look the same.
|
||||
let statusServed = false;
|
||||
useMappedPrinter([254], { connected: true, ams: [], vt_tray: [] });
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id/status', () => {
|
||||
statusServed = true;
|
||||
return HttpResponse.json({ id: 1, name: 'Test Printer', state: 'IDLE', connected: true, ams: [], vt_tray: [{ id: 254, tray_type: '' }], nozzles: [], ams_extruder_map: {} });
|
||||
}),
|
||||
);
|
||||
const row = await mappedRow();
|
||||
|
||||
await waitFor(() => expect(statusServed).toBe(true));
|
||||
expect(await within(row).findByText('Caramel')).toBeInTheDocument();
|
||||
expect(within(row).queryByText(/Empty/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not call a loaded but unconfigured spool empty (#2527)', async () => {
|
||||
// A non-RFID spool the firmware can't identify has no tray_type but is
|
||||
// physically present; the Printers page draws it as "?", not "Empty".
|
||||
const ams = [
|
||||
{
|
||||
id: 0,
|
||||
tray: [
|
||||
{ id: 0, tray_type: 'PLA', tray_color: 'FFFFFFFF', remain: 50 },
|
||||
{ id: 1, tray_type: 'PLA', tray_color: 'C2BAA7FF', remain: 80 },
|
||||
{ id: 2, tray_type: '', exists: true, state: 3 },
|
||||
{ id: 3, tray_type: null },
|
||||
],
|
||||
},
|
||||
];
|
||||
let statusServed = false;
|
||||
useMappedPrinter([2], { connected: true, ams, vt_tray: [] });
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id/status', () => {
|
||||
statusServed = true;
|
||||
return HttpResponse.json({ id: 1, name: 'Test Printer', state: 'IDLE', connected: true, ams, vt_tray: [], nozzles: [], ams_extruder_map: {} });
|
||||
}),
|
||||
);
|
||||
const row = await mappedRow();
|
||||
|
||||
await waitFor(() => expect(statusServed).toBe(true));
|
||||
expect(await within(row).findByText('Caramel')).toBeInTheDocument();
|
||||
expect(within(row).queryByText(/Empty/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not call a slot empty while the printer is offline', async () => {
|
||||
useMappedPrinter([2], { connected: false, ams: amsWithEmptyA3, vt_tray: [] });
|
||||
const row = await mappedRow();
|
||||
|
||||
expect(await within(row).findByText('Caramel')).toBeInTheDocument();
|
||||
expect(within(row).queryByText(/Empty/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows one if-started-now ETA for an eligible pending item', async () => {
|
||||
// Printer 1 is free: nothing is printing on it and nothing is queued ahead.
|
||||
server.use(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AMSUnit, AMSTray } from '../../api/client';
|
||||
import { getFillBarColor } from '../../utils/amsHelpers';
|
||||
import { getEmptySlotKind, getFillBarColor } from '../../utils/amsHelpers';
|
||||
|
||||
function trayColorToCSS(color: string | null): string {
|
||||
if (!color) return '#808080';
|
||||
@@ -11,19 +11,6 @@ function isTrayEmpty(tray: AMSTray): boolean {
|
||||
return !tray.tray_type || tray.tray_type === '';
|
||||
}
|
||||
|
||||
// Mirror of PrintersPage.getEmptySlotKind (#1694, #2527): 'physical' when
|
||||
// firmware confirms no spool, 'reset' when a spool is present but has no
|
||||
// tray_type (= loaded, slot just unconfigured — e.g. a non-RFID spool).
|
||||
// tray_exist_bits (exists) is authoritative when present; otherwise fall back
|
||||
// to the state=9/10 heuristic.
|
||||
function getEmptySlotKind(tray: AMSTray): 'physical' | 'reset' | null {
|
||||
if (tray.tray_type) return null;
|
||||
if (tray.exists === true) return 'reset';
|
||||
if (tray.exists === false) return 'physical';
|
||||
const state = tray.state ?? null;
|
||||
return state === 9 || state === 10 ? 'physical' : 'reset';
|
||||
}
|
||||
|
||||
function getAmsName(id: number): string {
|
||||
if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
|
||||
if (id >= 128 && id <= 135) return `AMS HT ${String.fromCharCode(65 + id - 128)}`;
|
||||
|
||||
@@ -195,7 +195,7 @@ import { FileUploadModal } from '../components/FileUploadModal';
|
||||
import { PrintModal } from '../components/PrintModal';
|
||||
import { PrinterInfoModal } from '../components/PrinterInfoModal';
|
||||
import { FeedDirectionModal } from '../components/FeedDirectionModal';
|
||||
import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, installedNozzleDiameters, isBambuLabSpool, resolveSlotNozzleDiameter, resolveSlotExtruder, formatSlotLabel, slotPresetDescribesTray, FTS_INLET_SIDE } from '../utils/amsHelpers';
|
||||
import { getAmsLabel, getEmptySlotKind, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, installedNozzleDiameters, isBambuLabSpool, resolveSlotNozzleDiameter, resolveSlotExtruder, formatSlotLabel, slotPresetDescribesTray, FTS_INLET_SIDE } from '../utils/amsHelpers';
|
||||
import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems, isPrinterCurrentlyDispatchable } from '../utils/printer';
|
||||
import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
|
||||
import { Collapsible } from '../components/Collapsible';
|
||||
@@ -1074,32 +1074,6 @@ function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35, on
|
||||
|
||||
|
||||
|
||||
/** Classify an empty AMS slot for UI rendering (#1322 follow-up).
|
||||
*
|
||||
* "physical" — firmware positively confirmed no spool (state 9 or 10). The
|
||||
* bambu_mqtt handler now promotes tray_exist_bits=0 slots to state=9, so
|
||||
* every empty-by-bitmask slot lands here regardless of firmware payload
|
||||
* shape.
|
||||
*
|
||||
* "reset" — tray_type is missing/empty but firmware hasn't confirmed
|
||||
* emptiness (state is null, 3, or any non-9/10 value). Typically a slot
|
||||
* the user cleared with "Reset Slot" where a physical spool may still be
|
||||
* loaded but unassigned.
|
||||
*
|
||||
* Returns null when the slot is loaded (tray_type is present).
|
||||
*/
|
||||
function getEmptySlotKind(tray: { tray_type?: string | null; state?: number | null; exists?: boolean | null } | null | undefined): 'physical' | 'reset' | null {
|
||||
if (tray?.tray_type) return null;
|
||||
// tray_exist_bits is firmware's authoritative presence signal: a non-RFID
|
||||
// spool the firmware can't identify is physically present (exists === true)
|
||||
// but carries no tray_type, so it must read as "?" (loaded, unconfigured),
|
||||
// never "Empty" (#2527). BambuStudio draws it the same way. Only fall back to
|
||||
// the state=9/10 heuristic when the bitmask was unavailable (exists == null).
|
||||
if (tray?.exists === true) return 'reset';
|
||||
if (tray?.exists === false) return 'physical';
|
||||
return (tray?.state === 9 || tray?.state === 10) ? 'physical' : 'reset';
|
||||
}
|
||||
|
||||
// How long to wait for an AMS to report a live drying cycle after the printer
|
||||
// acked the start command (#2533). Firmware moves to DryStatus 1 (Checking)
|
||||
// within a couple of seconds; 30s covers the slowest observed push cadence
|
||||
|
||||
@@ -68,8 +68,12 @@ import { api, ApiError } from '../api/client';
|
||||
import { PipelineRunsView } from './PipelineRunsPage';
|
||||
import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
|
||||
import { getBedTypeInfo } from '../utils/bedType';
|
||||
import type { PrintQueueItem, PrintQueueBulkUpdate, Permission, CalibrationMode } from '../api/client';
|
||||
import { getColorName } from '../utils/colors';
|
||||
import type { PrintQueueItem, PrintQueueBulkUpdate, Permission, CalibrationMode, PrinterStatus, SlotSpoolIdentity } from '../api/client';
|
||||
import { formatSlotLabel, getEmptySlotKind } from '../utils/amsHelpers';
|
||||
import type { PlateMetadata } from '../types/plates';
|
||||
import { Card } from '../components/Card';
|
||||
import { FilamentSwatch } from '../components/FilamentSwatch';
|
||||
import { Button } from '../components/Button';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { PrintModal } from '../components/PrintModal';
|
||||
@@ -80,6 +84,154 @@ import { CompactHistoryRow } from '../components/CompactHistoryRow';
|
||||
import { QueueTimelineView } from '../components/QueueTimelineView';
|
||||
import { compareQueueOrder, compareQueueOrderAcrossLanes } from '../utils/queueOrder';
|
||||
import { BatchOrdersView } from '../components/BatchOrdersView';
|
||||
import { buildLoadedFilaments, type LoadedFilament } from '../hooks/useFilamentMapping';
|
||||
|
||||
type QueueFilamentDisplay = {
|
||||
slotId: number;
|
||||
type: string;
|
||||
color: string;
|
||||
colorName: string;
|
||||
slotLabel?: string;
|
||||
spoolName?: string;
|
||||
extraColors?: string;
|
||||
effectType?: string;
|
||||
subtype?: string;
|
||||
/** The stored mapping names a slot the printer reports as empty. */
|
||||
emptySlot?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Slot label for a mapped tray the printer positively reports as empty.
|
||||
*
|
||||
* The scheduler dispatches a stored mapping as-is, so a spool unloaded after
|
||||
* queueing leaves the job pointed at an empty slot. Returns undefined when the
|
||||
* status doesn't describe that slot (offline, still loading, unit removed) or
|
||||
* when the slot may still hold an unconfigured spool (a non-RFID spool has no
|
||||
* tray_type either, #2527), so the card only warns on a positive finding.
|
||||
*/
|
||||
function emptyMappedSlotLabel(status: PrinterStatus | undefined, trayId: number): string | undefined {
|
||||
// The external holder reports no presence signal (the status route sends
|
||||
// vt_tray without state / exists), so its emptiness can't be confirmed.
|
||||
if (!status?.connected || trayId >= 254) return undefined;
|
||||
const isHt = trayId >= 128;
|
||||
const amsId = isHt ? trayId : Math.floor(trayId / 4);
|
||||
const slot = isHt ? 0 : trayId % 4;
|
||||
const unit = status.ams?.find((ams) => ams.id === amsId);
|
||||
const tray = isHt ? unit?.tray[0] : unit?.tray.find((candidate) => candidate.id === slot);
|
||||
if (getEmptySlotKind(tray) !== 'physical') return undefined;
|
||||
return formatSlotLabel(amsId, slot, isHt, false);
|
||||
}
|
||||
|
||||
function queueFilamentLabel(filament: QueueFilamentDisplay): string {
|
||||
return filament.slotLabel
|
||||
? [
|
||||
filament.slotLabel,
|
||||
filament.spoolName || filament.type,
|
||||
filament.colorName,
|
||||
].filter(Boolean).join(' · ')
|
||||
: filament.colorName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the filament colours a queued job is actually configured to use.
|
||||
*
|
||||
* Plate metadata is the source of truth for which 3MF slots the selected plate
|
||||
* consumes. A queue-level override replaces only its matching slot, so a
|
||||
* multi-colour job can mix original 3MF colours and user-selected overrides
|
||||
* without losing either (#3132).
|
||||
*/
|
||||
function resolveQueueFilaments(
|
||||
item: PrintQueueItem,
|
||||
plates: PlateMetadata[],
|
||||
loadedFilaments: LoadedFilament[] = [],
|
||||
status?: PrinterStatus,
|
||||
emptyLabel = 'Empty',
|
||||
): QueueFilamentDisplay[] {
|
||||
// A stored AMS mapping names the physical tray that will actually feed a 3MF
|
||||
// slot. Once its live tray data is available, that is more specific than
|
||||
// either the queue override or the original slice. When the printer reports
|
||||
// that tray as empty, keep the intended colour but say the slot is empty.
|
||||
const resolveSlot = (planned: QueueFilamentDisplay): QueueFilamentDisplay => {
|
||||
const slotId = planned.slotId;
|
||||
const mappedTrayId = slotId > 0 ? item.ams_mapping?.[slotId - 1] : undefined;
|
||||
if (mappedTrayId == null || mappedTrayId < 0) return planned;
|
||||
const loaded = loadedFilaments.find((filament) => filament.globalTrayId === mappedTrayId);
|
||||
if (loaded) {
|
||||
return {
|
||||
slotId,
|
||||
type: loaded.type,
|
||||
color: loaded.color,
|
||||
colorName: loaded.colorName,
|
||||
slotLabel: loaded.label,
|
||||
spoolName: loaded.spoolName,
|
||||
extraColors: loaded.extraColors,
|
||||
effectType: loaded.effectType,
|
||||
subtype: loaded.spoolSubtype,
|
||||
};
|
||||
}
|
||||
const emptySlotLabel = emptyMappedSlotLabel(status, mappedTrayId);
|
||||
if (emptySlotLabel) {
|
||||
return { ...planned, slotLabel: emptySlotLabel, spoolName: emptyLabel, emptySlot: true };
|
||||
}
|
||||
return planned;
|
||||
};
|
||||
|
||||
const selectedPlate =
|
||||
item.plate_id != null
|
||||
? plates.find((plate) => plate.index === item.plate_id)
|
||||
: plates[0];
|
||||
|
||||
if (selectedPlate) {
|
||||
const overrides = new Map(
|
||||
(item.filament_overrides ?? []).map((override) => [override.slot_id, override]),
|
||||
);
|
||||
|
||||
return selectedPlate.filaments
|
||||
.filter((filament) => filament.used_in_plate !== false && filament.used_grams > 0)
|
||||
.map((filament) => {
|
||||
const override = overrides.get(filament.slot_id);
|
||||
const color = override?.color ?? filament.color;
|
||||
const type = override?.type ?? filament.type;
|
||||
|
||||
return resolveSlot({
|
||||
slotId: filament.slot_id,
|
||||
type,
|
||||
color,
|
||||
colorName: override?.color_name?.trim() || getColorName(color, type),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// The queue row can render before plate metadata arrives (or an old source
|
||||
// may no longer expose it). Prefer a resolved physical tray when possible,
|
||||
// then explicit queue overrides because they describe the user's intention.
|
||||
if (item.filament_overrides?.length) {
|
||||
return item.filament_overrides.map((override) =>
|
||||
resolveSlot({
|
||||
slotId: override.slot_id,
|
||||
type: override.type,
|
||||
color: override.color,
|
||||
colorName:
|
||||
override.color_name?.trim() ||
|
||||
getColorName(override.color, override.type),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Last-resort compatibility fallback for older/simpler queue responses.
|
||||
if (item.filament_color) {
|
||||
return [
|
||||
resolveSlot({
|
||||
slotId: 1,
|
||||
type: item.filament_type ?? '',
|
||||
color: item.filament_color,
|
||||
colorName: getColorName(item.filament_color, item.filament_type),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function formatWeight(g: number, useKg = false): string {
|
||||
if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;
|
||||
@@ -403,14 +555,46 @@ function SortableQueueItem({
|
||||
etaNow?: number;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
// Fetch printer status every 30 seconds while printing to monitor progress
|
||||
const hasPhysicalAmsMapping =
|
||||
item.printer_id != null && (item.ams_mapping?.some((trayId) => trayId >= 0) ?? false);
|
||||
|
||||
// Printing rows already need live status for progress. A queued item with a
|
||||
// stored AMS mapping also needs it so the card can turn global tray ids into
|
||||
// the actual slot / colour that will feed the print (#3132). React Query
|
||||
// deduplicates rows sharing a printer.
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['printerStatus', item.printer_id],
|
||||
queryFn: () => api.getPrinterStatus(item.printer_id!),
|
||||
refetchInterval: 30000,
|
||||
enabled: item.printer_id != null && printerState === 'printing',
|
||||
refetchInterval: printerState === 'printing' ? 30000 : false,
|
||||
enabled: item.printer_id != null && (printerState === 'printing' || hasPhysicalAmsMapping),
|
||||
});
|
||||
|
||||
// Inventory identity is display-only: printer telemetry knows the tray colour
|
||||
// and material but not that a third-party spool is e.g. "eSUN PLA Basic".
|
||||
// Reuse the same payload / key as PrintModal so a mapped queue row names the
|
||||
// physical spool consistently with the mapping picker.
|
||||
const { data: inventoryRemain } = useQuery({
|
||||
queryKey: ['printer-inventory-remain', item.printer_id],
|
||||
queryFn: () => api.getInventoryRemain(item.printer_id!),
|
||||
enabled: hasPhysicalAmsMapping,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
const slotSpools = useMemo(() => {
|
||||
const slots = inventoryRemain?.slot_materials;
|
||||
if (!slots?.length) return undefined;
|
||||
const map = new Map<number, SlotSpoolIdentity>();
|
||||
slots.forEach((slot) => {
|
||||
if (slot.spool) map.set(slot.global_tray_id, slot.spool);
|
||||
});
|
||||
return map.size > 0 ? map : undefined;
|
||||
}, [inventoryRemain]);
|
||||
|
||||
const loadedFilaments = useMemo(
|
||||
() => buildLoadedFilaments(status, slotSpools),
|
||||
[status, slotSpools],
|
||||
);
|
||||
|
||||
// Determine if we're printing a library file
|
||||
const isLibraryFile = !!item.library_file_id && !item.archive_id;
|
||||
// Fetch archive plate details. Skip when the linked archive has been
|
||||
@@ -432,6 +616,7 @@ function SortableQueueItem({
|
||||
// Combine plates data from either source
|
||||
const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData;
|
||||
const plates = platesData?.plates ?? [];
|
||||
const queueFilaments = resolveQueueFilaments(item, plates, loadedFilaments, status, t('ams.empty'));
|
||||
|
||||
const canReorder = hasPermission('queue:reorder');
|
||||
const {
|
||||
@@ -657,6 +842,62 @@ function SortableQueueItem({
|
||||
{formatWeight(item.filament_used_grams)}
|
||||
</span>
|
||||
)}
|
||||
{queueFilaments.length > 2 ? (
|
||||
<span
|
||||
data-testid="queue-filament-compact"
|
||||
className="flex items-center gap-0.5 flex-shrink-0"
|
||||
title={queueFilaments.map(queueFilamentLabel).join('\n')}
|
||||
// The tooltip needs a hover; touch screens and screen readers
|
||||
// get the same lines through the accessible name instead.
|
||||
role="img"
|
||||
aria-label={queueFilaments.map(queueFilamentLabel).join(', ')}
|
||||
>
|
||||
{queueFilaments.map((filament) => (
|
||||
<FilamentSwatch
|
||||
key={`filament-${filament.slotId}`}
|
||||
rgba={filament.color}
|
||||
extraColors={filament.extraColors}
|
||||
effectType={filament.effectType}
|
||||
subtype={filament.subtype}
|
||||
className="w-2.5 h-2.5 sm:w-3 sm:h-3 pointer-events-none"
|
||||
effectSize="table"
|
||||
/>
|
||||
))}
|
||||
{queueFilaments.some((filament) => filament.emptySlot) && (
|
||||
<AlertCircle
|
||||
className="w-3 h-3 ml-0.5 text-yellow-700 dark:text-yellow-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
queueFilaments.map((filament) => {
|
||||
const mappedLabel = queueFilamentLabel(filament);
|
||||
return (
|
||||
<span
|
||||
key={`filament-${filament.slotId}`}
|
||||
className="flex items-center gap-1 sm:gap-1.5 min-w-0"
|
||||
title={mappedLabel}
|
||||
>
|
||||
<FilamentSwatch
|
||||
rgba={filament.color}
|
||||
extraColors={filament.extraColors}
|
||||
effectType={filament.effectType}
|
||||
subtype={filament.subtype}
|
||||
className="w-3 h-3 sm:w-3.5 sm:h-3.5"
|
||||
effectSize="table"
|
||||
/>
|
||||
<span
|
||||
className={`truncate max-w-[110px] sm:max-w-[260px]${
|
||||
filament.emptySlot ? ' text-yellow-700 dark:text-yellow-400' : ''
|
||||
}`}
|
||||
>
|
||||
{mappedLabel}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{(() => {
|
||||
// Build plate badge so the user knows which plate to mount before
|
||||
// walking to the printer (#1281). Hidden when the 3MF doesn't
|
||||
|
||||
@@ -326,6 +326,32 @@ export function findNearestSimilar<T>(
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Classify an empty AMS slot for UI rendering (#1322 follow-up).
|
||||
*
|
||||
* "physical" — firmware positively confirmed no spool (state 9 or 10). The
|
||||
* bambu_mqtt handler now promotes tray_exist_bits=0 slots to state=9, so
|
||||
* every empty-by-bitmask slot lands here regardless of firmware payload
|
||||
* shape.
|
||||
*
|
||||
* "reset" — tray_type is missing/empty but firmware hasn't confirmed
|
||||
* emptiness (state is null, 3, or any non-9/10 value). Typically a slot
|
||||
* the user cleared with "Reset Slot" where a physical spool may still be
|
||||
* loaded but unassigned.
|
||||
*
|
||||
* Returns null when the slot is loaded (tray_type is present).
|
||||
*/
|
||||
export function getEmptySlotKind(tray: { tray_type?: string | null; state?: number | null; exists?: boolean | null } | null | undefined): 'physical' | 'reset' | null {
|
||||
if (tray?.tray_type) return null;
|
||||
// tray_exist_bits is firmware's authoritative presence signal: a non-RFID
|
||||
// spool the firmware can't identify is physically present (exists === true)
|
||||
// but carries no tray_type, so it must read as "?" (loaded, unconfigured),
|
||||
// never "Empty" (#2527). BambuStudio draws it the same way. Only fall back to
|
||||
// the state=9/10 heuristic when the bitmask was unavailable (exists == null).
|
||||
if (tray?.exists === true) return 'reset';
|
||||
if (tray?.exists === false) return 'physical';
|
||||
return (tray?.state === 9 || tray?.state === 10) ? 'physical' : 'reset';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format slot label for display in the UI.
|
||||
* @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)
|
||||
|
||||
Reference in New Issue
Block a user