FilamentMappingArchivePick.test.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. /**
  2. * Tests for the FilamentMapping "Mapping" toggle (#2700).
  3. *
  4. * When the archive carries the slicer's own saved AMS-slot pick, a toggle next
  5. * to "Re-read" selects every slot straight from it, bypassing the type/color
  6. * auto-match. Turning it back off has to undo exactly what it did and leave
  7. * hand-made picks alone — that bookkeeping is what these tests pin.
  8. */
  9. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  10. import { useState } from 'react';
  11. import { screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
  12. import { http, HttpResponse } from 'msw';
  13. import { render } from '../utils';
  14. import { server } from '../mocks/server';
  15. import { FilamentMapping } from '../../components/PrintModal/FilamentMapping';
  16. import type { PrinterStatus } from '../../api/client';
  17. // Two-slot print. Slot 1 wants red PLA, slot 2 wants green PETG.
  18. const TWO_SLOT_REQS = {
  19. filaments: [
  20. { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, used_meters: 3 },
  21. { slot_id: 2, type: 'PETG', color: '#00FF00', used_grams: 10, used_meters: 3 },
  22. ],
  23. };
  24. // One AMS, four trays -> global tray IDs 0..3. Trays 0 and 2 both hold red PLA,
  25. // which is exactly the ambiguity the saved slicer pick exists to resolve: the
  26. // auto-match has no way to tell which red spool the user meant.
  27. function createStatus(): PrinterStatus {
  28. return {
  29. id: 1,
  30. name: 'X1C',
  31. connected: true,
  32. state: 'IDLE',
  33. ams: [
  34. {
  35. id: 0,
  36. tray: [
  37. { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'Red A' },
  38. { id: 1, tray_type: 'PETG', tray_color: '00FF00', tray_info_idx: 'GFG00', tray_sub_brands: 'Green' },
  39. { id: 2, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', tray_sub_brands: 'Red B' },
  40. { id: 3, tray_type: 'PLA', tray_color: '0000FF', tray_info_idx: 'GFA00', tray_sub_brands: 'Blue' },
  41. ],
  42. },
  43. ],
  44. vt_tray: [],
  45. ams_extruder_map: {},
  46. fila_switch: null,
  47. } as unknown as PrinterStatus;
  48. }
  49. /** Holds `manualMappings` the way PrintModal does, so an OFF click sees the
  50. * state the ON click produced rather than the initial prop. */
  51. function Harness({
  52. archiveAmsMapping,
  53. initialManualMappings = {},
  54. onChange,
  55. }: {
  56. archiveAmsMapping?: number[];
  57. initialManualMappings?: Record<number, number>;
  58. onChange?: (m: Record<number, number>) => void;
  59. }) {
  60. const [manualMappings, setManualMappings] = useState<Record<number, number>>(initialManualMappings);
  61. return (
  62. <FilamentMapping
  63. printerId={1}
  64. filamentReqs={TWO_SLOT_REQS}
  65. manualMappings={manualMappings}
  66. onManualMappingChange={(m) => {
  67. setManualMappings(m);
  68. onChange?.(m);
  69. }}
  70. currencySymbol="$"
  71. defaultCostPerKg={0}
  72. defaultExpanded
  73. archiveAmsMapping={archiveAmsMapping}
  74. />
  75. );
  76. }
  77. /** The panel only finishes mounting once printer status has loaded. */
  78. async function waitForPanel() {
  79. await waitFor(() => {
  80. expect(screen.getByText(/Re-read/i)).toBeInTheDocument();
  81. });
  82. }
  83. beforeEach(() => {
  84. server.use(
  85. http.get('/api/v1/printers/:id/status', () => HttpResponse.json(createStatus())),
  86. http.get('/api/v1/printers/:id/spool-assignments', () => HttpResponse.json([])),
  87. );
  88. });
  89. afterEach(() => {
  90. cleanup();
  91. vi.clearAllMocks();
  92. });
  93. describe('FilamentMapping — saved slicer AMS pick', () => {
  94. it('hides the toggle when the archive has no saved mapping', async () => {
  95. // Every archive predating the feature, every library file, and every
  96. // reprint aimed at a printer other than the one the mapping came from.
  97. render(<Harness />);
  98. await waitForPanel();
  99. expect(screen.queryByRole('button', { name: 'Mapping' })).not.toBeInTheDocument();
  100. });
  101. it('selects every slot from the saved mapping when switched on', async () => {
  102. // Saved pick says slot 1 -> tray 2 (the *second* red spool) and slot 2 ->
  103. // tray 1. Auto-match would have taken tray 0 for slot 1, so this is a
  104. // visible, load-bearing difference.
  105. const onChange = vi.fn();
  106. render(<Harness archiveAmsMapping={[2, 1]} onChange={onChange} />);
  107. await waitForPanel();
  108. fireEvent.click(screen.getByRole('button', { name: 'Mapping' }));
  109. expect(onChange).toHaveBeenCalledTimes(1);
  110. expect(onChange).toHaveBeenCalledWith({ 1: 2, 2: 1 });
  111. });
  112. it('skips slots the slicer left unresolved', async () => {
  113. // -1 is the slicer saying "no AMS tray for this filament" (external spool,
  114. // or it simply didn't resolve). Overriding that slot with -1 would be
  115. // worse than leaving it to the auto-match.
  116. const onChange = vi.fn();
  117. render(<Harness archiveAmsMapping={[-1, 1]} onChange={onChange} />);
  118. await waitForPanel();
  119. fireEvent.click(screen.getByRole('button', { name: 'Mapping' }));
  120. expect(onChange).toHaveBeenCalledWith({ 2: 1 });
  121. });
  122. it('skips slots the saved mapping is too short to address', async () => {
  123. // A mapping with fewer entries than the plate has slots can't say anything
  124. // about the missing ones; reading past the end would write `undefined`.
  125. const onChange = vi.fn();
  126. render(<Harness archiveAmsMapping={[2]} onChange={onChange} />);
  127. await waitForPanel();
  128. fireEvent.click(screen.getByRole('button', { name: 'Mapping' }));
  129. expect(onChange).toHaveBeenCalledWith({ 1: 2 });
  130. });
  131. it('undoes exactly its own picks when switched off', async () => {
  132. const onChange = vi.fn();
  133. render(<Harness archiveAmsMapping={[2, 1]} onChange={onChange} />);
  134. await waitForPanel();
  135. const toggle = screen.getByRole('button', { name: 'Mapping' });
  136. fireEvent.click(toggle);
  137. expect(onChange).toHaveBeenLastCalledWith({ 1: 2, 2: 1 });
  138. fireEvent.click(toggle);
  139. expect(onChange).toHaveBeenLastCalledWith({});
  140. });
  141. it('leaves a hand-made pick untouched when switched off', async () => {
  142. // The user picked slot 2 by hand first, then switched the toggle on, which
  143. // overwrote it as part of applying the whole saved mapping. Switching off
  144. // removes both, since both are now the toggle's own picks — the earlier
  145. // hand pick is not restored, and slot 2 falls back to the auto-match. That
  146. // is the documented behaviour, not an accident; the next test covers the
  147. // case where the hand pick does survive.
  148. const onChange = vi.fn();
  149. render(<Harness archiveAmsMapping={[2, 1]} initialManualMappings={{ 2: 3 }} onChange={onChange} />);
  150. await waitForPanel();
  151. const toggle = screen.getByRole('button', { name: 'Mapping' });
  152. fireEvent.click(toggle);
  153. expect(onChange).toHaveBeenLastCalledWith({ 1: 2, 2: 1 });
  154. fireEvent.click(toggle);
  155. expect(onChange).toHaveBeenLastCalledWith({});
  156. });
  157. it('keeps hand-made picks for slots the saved mapping never touched', async () => {
  158. // Saved mapping only resolves slot 1, so slot 2's hand-made pick was never
  159. // one of "its own" and has to survive the round trip.
  160. const onChange = vi.fn();
  161. render(<Harness archiveAmsMapping={[2, -1]} initialManualMappings={{ 2: 3 }} onChange={onChange} />);
  162. await waitForPanel();
  163. const toggle = screen.getByRole('button', { name: 'Mapping' });
  164. fireEvent.click(toggle);
  165. expect(onChange).toHaveBeenLastCalledWith({ 1: 2, 2: 3 });
  166. fireEvent.click(toggle);
  167. expect(onChange).toHaveBeenLastCalledWith({ 2: 3 });
  168. });
  169. });