QueueTimelineView.test.tsx 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. /**
  2. * The timeline draws each lane's pending jobs chained end to end, so the order
  3. * it chains them in is a claim about what the scheduler will dispatch next.
  4. *
  5. * It used to chain by queue position alone, which meant turning
  6. * Shortest-Job-First on changed the scheduler and the pending list but left
  7. * the timeline drawing the pre-SJF queue forever (#3043).
  8. */
  9. import { describe, it, expect } from 'vitest';
  10. import { render, screen } from '@testing-library/react';
  11. import { QueueTimelineView } from '../../components/QueueTimelineView';
  12. import type { PrintQueueItem, Printer } from '../../api/client';
  13. const HOUR = 3600;
  14. const printers = [{ id: 1, name: 'Workshop X1C', model: 'X1C' }] as unknown as Printer[];
  15. // Something has to be running for a lane to forecast at all: an idle printer's
  16. // ASAP queue has no committed anchor to chain from, so the view drops it.
  17. const running = {
  18. id: 100,
  19. printer_id: 1,
  20. status: 'printing',
  21. position: 0,
  22. archive_name: 'Running now',
  23. print_time_seconds: HOUR,
  24. started_at: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
  25. } as unknown as PrintQueueItem;
  26. const pending = (
  27. id: number,
  28. name: string,
  29. position: number,
  30. printTimeSeconds: number,
  31. over: Partial<PrintQueueItem> = {},
  32. ) =>
  33. ({
  34. id,
  35. printer_id: 1,
  36. status: 'pending',
  37. position,
  38. archive_name: name,
  39. print_time_seconds: printTimeSeconds,
  40. scheduled_time: null,
  41. manual_start: false,
  42. waiting_reason: null,
  43. ...over,
  44. }) as unknown as PrintQueueItem;
  45. const queueItems = [
  46. running,
  47. pending(1, 'Long job', 1, 2 * HOUR),
  48. pending(2, 'Short job', 2, HOUR / 2),
  49. pending(3, 'Medium job', 3, HOUR),
  50. ];
  51. function renderTimeline(sjfEnabled: boolean) {
  52. render(
  53. <QueueTimelineView
  54. queueItems={queueItems}
  55. printers={printers}
  56. printerStatuses={{ 1: { progress: 50, remaining_time: 30, state: 'RUNNING' } }}
  57. sjfEnabled={sjfEnabled}
  58. onItemClick={() => {}}
  59. t={(key: string) => key}
  60. />,
  61. );
  62. }
  63. /** Bar names, left to right. Bars are absolutely positioned, so the rendered
  64. * offset is what the user reads -- not DOM order. The tooltip leads with the
  65. * display name, which is cleaner to match on than the bar's own text (name
  66. * and duration sit in adjacent divs with no separator between them). */
  67. function barsLeftToRight(): string[] {
  68. return screen
  69. .getAllByRole('button')
  70. .filter(el => el.style.left.endsWith('%'))
  71. .sort((a, b) => parseFloat(a.style.left) - parseFloat(b.style.left))
  72. .map(el => (el.getAttribute('title') ?? '').split(' \u00b7 ')[0]);
  73. }
  74. describe('QueueTimelineView job ordering', () => {
  75. it('chains by queue position when SJF is off', () => {
  76. renderTimeline(false);
  77. expect(barsLeftToRight()).toEqual(['Running now', 'Long job', 'Short job', 'Medium job']);
  78. });
  79. it('chains shortest-first when SJF is on', () => {
  80. renderTimeline(true);
  81. expect(barsLeftToRight()).toEqual(['Running now', 'Short job', 'Medium job', 'Long job']);
  82. });
  83. it('keeps a jumped item ahead of a shorter one', () => {
  84. // The starvation guard is part of the scheduler's order too, so the
  85. // timeline has to draw it or it lies about the long job that is finally
  86. // about to run.
  87. render(
  88. <QueueTimelineView
  89. queueItems={[
  90. running,
  91. pending(1, 'Short job', 1, HOUR / 2),
  92. pending(2, 'Long job', 2, 2 * HOUR, { been_jumped: true }),
  93. ]}
  94. printers={printers}
  95. printerStatuses={{ 1: { progress: 50, remaining_time: 30, state: 'RUNNING' } }}
  96. sjfEnabled
  97. onItemClick={() => {}}
  98. t={(key: string) => key}
  99. />,
  100. );
  101. // Position order would put the short job first, so this only passes if
  102. // been_jumped is actually consulted.
  103. expect(barsLeftToRight()).toEqual(['Running now', 'Long job', 'Short job']);
  104. });
  105. });
  106. describe('QueueTimelineView and the scheduler waiting reasons (#3074)', () => {
  107. /** The scheduler now puts a reason on a pinned item too, and the commonest
  108. * one by far -- "Busy: <printer>" -- describes the very chain this view
  109. * forecasts. Dropping every item that has a reason would empty the timeline
  110. * for anyone whose queue is pinned to specific printers. */
  111. function renderWith(items: PrintQueueItem[]) {
  112. render(
  113. <QueueTimelineView
  114. queueItems={items}
  115. printers={printers}
  116. printerStatuses={{ 1: { progress: 50, remaining_time: 30, state: 'RUNNING' } }}
  117. sjfEnabled={false}
  118. onItemClick={() => {}}
  119. t={(key: string) => key}
  120. />,
  121. );
  122. }
  123. it('still forecasts an item that is only waiting its turn', () => {
  124. renderWith([
  125. running,
  126. pending(1, 'Long job', 1, 2 * HOUR, { waiting_reason: 'Busy: X1C-01' }),
  127. pending(2, 'Medium job', 2, HOUR, { waiting_reason: 'Busy: X1C-01' }),
  128. ]);
  129. expect(barsLeftToRight()).toEqual(['Running now', 'Long job', 'Medium job']);
  130. });
  131. it('still forecasts one held behind a drying cycle', () => {
  132. renderWith([running, pending(1, 'Long job', 1, 2 * HOUR, { waiting_reason: 'Busy: X1C-01 (drying)' })]);
  133. expect(barsLeftToRight()).toEqual(['Running now', 'Long job']);
  134. });
  135. it('drops one that is waiting for the user', () => {
  136. // These do not start on their own, so a bar would be a promise the queue
  137. // cannot keep.
  138. for (const reason of [
  139. 'Waiting for plate confirmation: X1C-01',
  140. 'Offline, no Auto On smart plug: X1C-01',
  141. 'Waiting on Enclosure Door',
  142. 'Waiting for filament: X1C-01 (needs PETG)',
  143. ]) {
  144. const { unmount } = render(
  145. <QueueTimelineView
  146. queueItems={[running, pending(1, 'Long job', 1, 2 * HOUR, { waiting_reason: reason })]}
  147. printers={printers}
  148. printerStatuses={{ 1: { progress: 50, remaining_time: 30, state: 'RUNNING' } }}
  149. sjfEnabled={false}
  150. onItemClick={() => {}}
  151. t={(key: string) => key}
  152. />,
  153. );
  154. expect(barsLeftToRight()).toEqual(['Running now']);
  155. unmount();
  156. }
  157. });
  158. });