printer.test.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. /**
  2. * Tests for getPrinterImage — model → printer card image resolver.
  3. *
  4. * X2D support (#988): both the display name "X2D" and the internal SSDP
  5. * code "N6" must resolve to /img/printers/x2d.png so the Printers page
  6. * and PrinterInfoModal show the correct artwork instead of falling back
  7. * to default.png.
  8. */
  9. import { describe, it, expect } from 'vitest';
  10. import { getPrinterImage, isGcodeCompatible, filterCompatibleQueueItems } from '../../utils/printer';
  11. import type { PrintQueueItem } from '../../api/client';
  12. describe('getPrinterImage', () => {
  13. describe('X2D (#988)', () => {
  14. it('resolves display name "X2D" to x2d.png', () => {
  15. expect(getPrinterImage('X2D')).toBe('/img/printers/x2d.png');
  16. });
  17. it('resolves case-insensitive variants', () => {
  18. expect(getPrinterImage('x2d')).toBe('/img/printers/x2d.png');
  19. expect(getPrinterImage(' X2D ')).toBe('/img/printers/x2d.png');
  20. });
  21. it('resolves the internal SSDP code "N6" to x2d.png', () => {
  22. expect(getPrinterImage('N6')).toBe('/img/printers/x2d.png');
  23. });
  24. it('does not match X2D on unrelated model strings', () => {
  25. // Regression guard: a hypothetical future "X2" model must not
  26. // silently pick up x2d.png until it's explicitly mapped.
  27. expect(getPrinterImage('X2E')).toBe('/img/printers/default.png');
  28. });
  29. });
  30. describe('A2L (#1684)', () => {
  31. it('resolves display name "A2L" to a2l.png', () => {
  32. expect(getPrinterImage('A2L')).toBe('/img/printers/a2l.png');
  33. });
  34. it('resolves case-insensitive variants', () => {
  35. expect(getPrinterImage('a2l')).toBe('/img/printers/a2l.png');
  36. expect(getPrinterImage(' A2L ')).toBe('/img/printers/a2l.png');
  37. });
  38. it('resolves the internal SSDP code "N9" to a2l.png', () => {
  39. expect(getPrinterImage('N9')).toBe('/img/printers/a2l.png');
  40. });
  41. it('does not match A2L on unrelated A-series strings', () => {
  42. // Regression guard: a hypothetical future "A2M" or similar must not
  43. // silently pick up a2l.png until it's explicitly mapped, and "A1" /
  44. // "A1 Mini" must still resolve to their own artwork.
  45. expect(getPrinterImage('A2M')).toBe('/img/printers/default.png');
  46. expect(getPrinterImage('A1')).toBe('/img/printers/a1.png');
  47. expect(getPrinterImage('A1 Mini')).toBe('/img/printers/a1mini.png');
  48. });
  49. });
  50. describe('regression: existing families unchanged', () => {
  51. it('X1C → x1c.png', () => {
  52. expect(getPrinterImage('X1C')).toBe('/img/printers/x1c.png');
  53. });
  54. it('X1E → x1e.png', () => {
  55. expect(getPrinterImage('X1E')).toBe('/img/printers/x1e.png');
  56. });
  57. it('H2D → h2d.png', () => {
  58. expect(getPrinterImage('H2D')).toBe('/img/printers/h2d.png');
  59. });
  60. it('H2D Pro → h2dpro.png', () => {
  61. expect(getPrinterImage('H2D Pro')).toBe('/img/printers/h2dpro.png');
  62. });
  63. it('P2S → p2s.png', () => {
  64. expect(getPrinterImage('P2S')).toBe('/img/printers/p2s.png');
  65. });
  66. it('P1S → p1s.png', () => {
  67. // Ensure that P1S still resolves correctly
  68. expect(getPrinterImage('P1S')).toBe('/img/printers/p1s.png');
  69. });
  70. it('A1 Mini → a1mini.png (not a1.png)', () => {
  71. // The "a1mini" branch must run before the generic "a1" branch —
  72. // the X2D branch was inserted above both and must not break order.
  73. expect(getPrinterImage('A1 Mini')).toBe('/img/printers/a1mini.png');
  74. });
  75. it('null / undefined → default.png', () => {
  76. expect(getPrinterImage(null)).toBe('/img/printers/default.png');
  77. expect(getPrinterImage(undefined)).toBe('/img/printers/default.png');
  78. });
  79. it('unknown model → default.png', () => {
  80. expect(getPrinterImage('SomeFuturePrinter')).toBe(
  81. '/img/printers/default.png',
  82. );
  83. });
  84. });
  85. });
  86. // Mirrors backend/tests/unit/test_scheduler_model_mismatch.py — the frontend
  87. // table must stay in sync with backend GCODE_COMPAT_FAMILIES (#2578).
  88. describe('isGcodeCompatible', () => {
  89. it('accepts same model', () => {
  90. expect(isGcodeCompatible('X1C', 'X1C')).toBe(true);
  91. expect(isGcodeCompatible('H2D', 'H2D')).toBe(true);
  92. });
  93. it('accepts the X1/P1 interchange family', () => {
  94. expect(isGcodeCompatible('X1C', 'P1S')).toBe(true);
  95. expect(isGcodeCompatible('X1C', 'P1P')).toBe(true);
  96. expect(isGcodeCompatible('P1S', 'X1E')).toBe(true);
  97. expect(isGcodeCompatible('X1', 'P1P')).toBe(true);
  98. });
  99. it('rejects cross-family targets', () => {
  100. // The reporter's case: X1C-sliced G-code targeted at an H2D (#2578)
  101. expect(isGcodeCompatible('X1C', 'H2D')).toBe(false);
  102. expect(isGcodeCompatible('P1S', 'H2D')).toBe(false);
  103. expect(isGcodeCompatible('X1C', 'A1')).toBe(false);
  104. expect(isGcodeCompatible('A1', 'A1 Mini')).toBe(false);
  105. expect(isGcodeCompatible('H2D', 'H2S')).toBe(false);
  106. expect(isGcodeCompatible('X1C', 'P2S')).toBe(false);
  107. });
  108. it('is fail-safe on unknown metadata', () => {
  109. expect(isGcodeCompatible(null, 'H2D')).toBe(true);
  110. expect(isGcodeCompatible('X1C', null)).toBe(true);
  111. expect(isGcodeCompatible(undefined, undefined)).toBe(true);
  112. expect(isGcodeCompatible('', 'H2D')).toBe(true);
  113. });
  114. it('normalizes case, spaces and dashes', () => {
  115. expect(isGcodeCompatible('x1c', 'X1C')).toBe(true);
  116. expect(isGcodeCompatible('A1 Mini', 'A1-MINI')).toBe(true);
  117. expect(isGcodeCompatible('H2D Pro', 'H2DPRO')).toBe(true);
  118. });
  119. });
  120. describe('filterCompatibleQueueItems — force-color PLA variant (#2650)', () => {
  121. const makeItem = (
  122. overrides: Array<{ slot_id: number; type: string; color: string; tray_info_idx?: string; force_color_match?: boolean }>,
  123. ): PrintQueueItem => ({ id: 1, filament_overrides: overrides } as unknown as PrintQueueItem);
  124. // A job sliced for White PLA Matte (GFA01).
  125. const matteJob = makeItem([
  126. { slot_id: 1, type: 'PLA', color: '#FFFFFF', tray_info_idx: 'GFA01', force_color_match: true },
  127. ]);
  128. const loadedTypes = new Set(['PLA']);
  129. const loaded = new Set(['PLA:ffffff']);
  130. it('rejects a printer loaded only with other white PLA variants (Basic/Silk)', () => {
  131. const variants = new Set(['PLA:ffffff:GFA00', 'PLA:ffffff:GFA06']);
  132. expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded, variants)).toHaveLength(0);
  133. });
  134. it('accepts a printer loaded with the matching variant (Matte GFA01)', () => {
  135. const variants = new Set(['PLA:ffffff:GFA00', 'PLA:ffffff:GFA01']);
  136. expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded, variants)).toHaveLength(1);
  137. });
  138. it('accepts a same-colour spool that reports no tray_info_idx (custom/third-party)', () => {
  139. const variants = new Set(['PLA:ffffff:']);
  140. expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded, variants)).toHaveLength(1);
  141. });
  142. it('falls back to type+colour when no variant data is supplied', () => {
  143. // loadedVariants omitted → the hint is never stricter than the data it has.
  144. expect(filterCompatibleQueueItems([matteJob], loadedTypes, loaded)).toHaveLength(1);
  145. });
  146. it('an override without a tray_info_idx keeps the old type+colour behaviour', () => {
  147. const noIdxJob = makeItem([{ slot_id: 1, type: 'PLA', color: '#FFFFFF', force_color_match: true }]);
  148. const variants = new Set(['PLA:ffffff:GFA06']);
  149. expect(filterCompatibleQueueItems([noIdxJob], loadedTypes, loaded, variants)).toHaveLength(1);
  150. });
  151. });
  152. describe('filterCompatibleQueueItems — printer-targeted jobs (#3133)', () => {
  153. // A job moved from "Any P2S" to one P2S keeps its override. The printer is
  154. // already chosen, so a colour it has not loaded must not hide the job from
  155. // that printer's card — the scheduler maps the trays at dispatch.
  156. const boneWhite = [{ slot_id: 1, type: 'PLA', color: '#F5F5DC', force_color_match: false }];
  157. const loadedTypes = new Set(['PLA']);
  158. const loadedBrown = new Set(['PLA:8b4513']);
  159. it('keeps a specific-printer job whose override colour is not loaded', () => {
  160. const item = { id: 1, printer_id: 3, filament_overrides: boneWhite } as unknown as PrintQueueItem;
  161. expect(filterCompatibleQueueItems([item], loadedTypes, loadedBrown)).toHaveLength(1);
  162. });
  163. it('still filters the same job while it targets any printer of a model', () => {
  164. const item = { id: 1, printer_id: null, target_model: 'P2S', filament_overrides: boneWhite } as unknown as PrintQueueItem;
  165. expect(filterCompatibleQueueItems([item], loadedTypes, loadedBrown)).toHaveLength(0);
  166. });
  167. });