nozzleRack.test.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. import { describe, it, expect } from 'vitest';
  2. import type { NozzleRackSlot } from '../../api/client';
  3. import type { RackGroupInfo } from '../../components/PrintModal/types';
  4. import {
  5. autoAssignRackPositions,
  6. isRackSlotEligible,
  7. rackByPosition,
  8. rackOptionsForGroup,
  9. } from '../../utils/nozzleRack';
  10. /**
  11. * Mirrors backend/tests/unit/test_nozzle_rack_positions_1784.py. The two
  12. * implementations have to agree or the picker greys out an option the
  13. * dispatcher would have accepted, or worse offers one it will refuse.
  14. */
  15. const slot = (id: number, over: Partial<NozzleRackSlot> = {}): NozzleRackSlot => ({
  16. id,
  17. nozzle_type: 'HH01',
  18. nozzle_diameter: '0.4',
  19. wear: null,
  20. stat: null,
  21. max_temp: 300,
  22. serial_number: '',
  23. filament_color: '',
  24. filament_id: '',
  25. filament_type: '',
  26. ...over,
  27. });
  28. /** Live rack with a nozzle at each named 1-based position, plus the carriages. */
  29. const rack = (present: number[] = [1, 2, 3, 4, 5, 6], over: Record<number, Partial<NozzleRackSlot>> = {}) => [
  30. slot(1),
  31. ...present.map((p) => slot(15 + p, over[p] ?? {})),
  32. ];
  33. const group = (over: Partial<RackGroupInfo> = {}): RackGroupInfo => ({
  34. on_rack: true,
  35. nozzle_diameter: '0.40',
  36. volume_type: 'High Flow',
  37. filament_color: '',
  38. ...over,
  39. });
  40. const t = (key: string) => key;
  41. describe('reading the live rack', () => {
  42. it('recovers the mounted nozzle from the lone gap', () => {
  43. // Measured 2026-08-14 09:02: IDs [16, 1, 21, 19, 18, 0, 20] — id 17 absent
  44. // because that nozzle was picked up onto the carriage (#943).
  45. const slots = [...rack([1, 3, 4, 5, 6]), slot(0)];
  46. const byPosition = rackByPosition(slots);
  47. expect([...byPosition.keys()].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6]);
  48. expect(byPosition.get(2)!.id).toBe(0);
  49. });
  50. it('leaves two gaps absent, because which one is mounted is unknowable', () => {
  51. const byPosition = rackByPosition([...rack([1, 4, 5, 6]), slot(0)]);
  52. expect([...byPosition.keys()].sort((a, b) => a - b)).toEqual([1, 4, 5, 6]);
  53. });
  54. it('fills no gap from an empty carriage', () => {
  55. const empty = slot(0, { nozzle_diameter: '', nozzle_type: '' });
  56. expect(rackByPosition([...rack([1, 3, 4, 5, 6]), empty]).has(2)).toBe(false);
  57. });
  58. it('ignores the fixed carriage entirely', () => {
  59. expect(rackByPosition([slot(1)]).size).toBe(0);
  60. });
  61. });
  62. describe('eligibility', () => {
  63. it('matches a padded slice diameter against an unpadded printer one', () => {
  64. expect(isRackSlotEligible(slot(16, { nozzle_diameter: '0.4' }), group())).toBe(true);
  65. });
  66. it('rejects the wrong diameter', () => {
  67. expect(isRackSlotEligible(slot(16, { nozzle_diameter: '0.6' }), group())).toBe(false);
  68. });
  69. it('rejects the wrong flow type', () => {
  70. expect(isRackSlotEligible(slot(16, { nozzle_type: 'HS' }), group())).toBe(false);
  71. });
  72. it('does not rule out a printer that reports no flow type', () => {
  73. expect(isRackSlotEligible(slot(16, { nozzle_type: '' }), group())).toBe(true);
  74. });
  75. it('rejects an empty position', () => {
  76. expect(isRackSlotEligible(slot(16, { nozzle_diameter: '', nozzle_type: '' }), group())).toBe(false);
  77. });
  78. it('rejects a position that is not there at all', () => {
  79. expect(isRackSlotEligible(undefined, group())).toBe(false);
  80. });
  81. });
  82. describe('the options offered for a group', () => {
  83. it('always offers all six, so a position is greyed out rather than missing', () => {
  84. const options = rackOptionsForGroup(rack([1, 2]), group(), t);
  85. expect(options).toHaveLength(6);
  86. expect(options.filter((o) => o.eligible).map((o) => o.position)).toEqual([1, 2]);
  87. });
  88. it('says why an empty position cannot be used', () => {
  89. const options = rackOptionsForGroup(rack([1]), group(), t);
  90. expect(options[1].reason).toBe('printModal.rackEmptyPosition');
  91. });
  92. it('says why a wrong nozzle cannot be used', () => {
  93. const options = rackOptionsForGroup(rack([1, 2], { 2: { nozzle_diameter: '0.6' } }), group(), t);
  94. expect(options[1].reason).toBe('printModal.rackWrongNozzle');
  95. });
  96. });
  97. describe('auto-assignment', () => {
  98. const groups = new Map<number, RackGroupInfo>([
  99. [0, group({ on_rack: false, filament_color: '#F4EE2A' })],
  100. [1, group({ filament_color: '#0078BF' })],
  101. [2, group({ filament_color: '#DE4343' })],
  102. ]);
  103. it('prefers the position already loaded with the group colour', () => {
  104. // Reproduces BambuStudio's own pick for this plate: group 2 (red) to R1,
  105. // group 1 (blue) to R2 — dispatched as [16, 1, 17] on 2026-08-14.
  106. const loaded = rack([1, 2, 3, 4, 5, 6], {
  107. 1: { filament_color: 'DE4343FF' },
  108. 2: { filament_color: '0078BFFF' },
  109. });
  110. expect(autoAssignRackPositions(loaded, groups)).toEqual({ 1: 2, 2: 1 });
  111. });
  112. it('falls back to the lowest free position when no colour matches', () => {
  113. expect(autoAssignRackPositions(rack(), groups)).toEqual({ 1: 1, 2: 2 });
  114. });
  115. it('never reassigns a position the operator pinned', () => {
  116. expect(autoAssignRackPositions(rack(), groups, { 1: 4 })).toEqual({ 1: 4, 2: 1 });
  117. });
  118. it('gives up rather than half-assigning when a group cannot be placed', () => {
  119. expect(autoAssignRackPositions(rack([1]), groups)).toBeNull();
  120. });
  121. it('gives up when a pinned position is no longer eligible', () => {
  122. expect(autoAssignRackPositions(rack([1, 2]), groups, { 1: 5 })).toBeNull();
  123. });
  124. it('assigns nothing, successfully, when no group needs the rack', () => {
  125. const fixedOnly = new Map<number, RackGroupInfo>([[0, group({ on_rack: false })]]);
  126. expect(autoAssignRackPositions(rack([]), fixedOnly)).toEqual({});
  127. });
  128. });