nozzleRack.test.ts 5.9 KB

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