PrintersPageCardScale.test.tsx 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. /**
  2. * Printer-card body scale (#1848, reporter @misterff1).
  3. *
  4. * S/M/L/XL already scaled the card's width, thumbnail and printer name, but
  5. * every label in the body was pinned at 8-11px, so an XL card carried the same
  6. * tiny text as an S one. The body now scales too, driven by custom properties
  7. * on the card root.
  8. *
  9. * S and M stay at 1.0 on purpose: an existing install must look identical
  10. * until the user reaches for a size that is already asking for more space.
  11. */
  12. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  13. import { screen, waitFor } from '@testing-library/react';
  14. import { render } from '../utils';
  15. import { PrintersPage } from '../../pages/PrintersPage';
  16. import { http, HttpResponse } from 'msw';
  17. import { server } from '../mocks/server';
  18. const mockPrinter = {
  19. id: 1,
  20. name: 'X1C',
  21. ip_address: '192.168.1.100',
  22. serial_number: '01P00A000000001',
  23. access_code: '12345678',
  24. model: 'X1C',
  25. enabled: true,
  26. nozzle_diameter: 0.4,
  27. nozzle_type: 'stainless_steel',
  28. location: 'Workshop',
  29. auto_archive: true,
  30. created_at: '2024-01-01T00:00:00Z',
  31. updated_at: '2024-01-01T00:00:00Z',
  32. };
  33. const baseTray = {
  34. tray_color: 'FF0000FF',
  35. tray_type: 'PLA',
  36. tray_sub_brands: 'PLA Basic',
  37. tray_info_idx: 'GFA00',
  38. remain: 80,
  39. k: 0.02,
  40. cali_idx: null,
  41. tag_uid: null,
  42. tray_uuid: null,
  43. nozzle_temp_min: 190,
  44. nozzle_temp_max: 230,
  45. drying_temp: null,
  46. drying_time: null,
  47. state: 3,
  48. };
  49. const STATUS = {
  50. connected: true,
  51. state: 'IDLE',
  52. progress: 0,
  53. layer_num: 0,
  54. total_layers: 0,
  55. temperatures: { nozzle: 25, bed: 25, chamber: 25 },
  56. remaining_time: 0,
  57. filename: null,
  58. wifi_signal: -29,
  59. speed_level: 2,
  60. ams: [
  61. {
  62. id: 0,
  63. humidity: 30,
  64. temp: 28.2,
  65. is_ams_ht: false,
  66. serial_number: 'AMS00',
  67. sw_ver: '03.00.21.29',
  68. dry_time: 0,
  69. dry_status: 0,
  70. dry_sub_status: 0,
  71. dry_sf_reason: [],
  72. module_type: 'n3f',
  73. tray: [0, 1, 2, 3].map((id) => ({ id, ...baseTray })),
  74. },
  75. {
  76. // AMS-HT: a single tray, with its temperature and humidity readings
  77. // beside the slot rather than under it.
  78. id: 128,
  79. humidity: 52,
  80. temp: 28.6,
  81. is_ams_ht: true,
  82. serial_number: 'HT00',
  83. sw_ver: '03.00.21.29',
  84. dry_time: 0,
  85. dry_status: 0,
  86. dry_sub_status: 0,
  87. dry_sf_reason: [],
  88. module_type: 'n3s',
  89. tray: [{ id: 0, ...baseTray }],
  90. },
  91. ],
  92. vt_tray: [],
  93. };
  94. let store: Record<string, string>;
  95. /** Render at a given card size and hand back the card root's inline style. */
  96. async function cardStyleAt(cardSize: string) {
  97. store['printerCardSize'] = cardSize;
  98. render(<PrintersPage />);
  99. const card = await waitFor(() => {
  100. const el = document.getElementById('printer-card-1');
  101. if (!el) throw new Error('card not rendered');
  102. return el as HTMLElement;
  103. });
  104. return card.style;
  105. }
  106. /** The AMS slot grid's track sizing, once the status has populated the card. */
  107. async function slotGridColumns(): Promise<string> {
  108. return waitFor(() => {
  109. const grid = document.querySelector<HTMLElement>('#printer-card-1 [style*="minmax"]');
  110. if (!grid) throw new Error('AMS slot grid not rendered');
  111. return grid.style.gridTemplateColumns;
  112. });
  113. }
  114. /** The AMS-HT card's own sizing — it is the unit whose readings sit beside the slot. */
  115. async function htCardStyle(): Promise<CSSStyleDeclaration> {
  116. return waitFor(() => {
  117. const el = [...document.querySelectorAll<HTMLElement>('#printer-card-1 [class*="rounded-[10px]"]')]
  118. .find((d) => /^HT-/.test((d.textContent || '').trim()));
  119. if (!el) throw new Error('AMS-HT card not rendered');
  120. return el.style;
  121. });
  122. }
  123. /**
  124. * The single filament slot inside the AMS-HT card. Scoped through the card
  125. * itself, since the card carries a max-width of its own.
  126. */
  127. async function htSlotStyle(): Promise<CSSStyleDeclaration> {
  128. return waitFor(() => {
  129. const card = [...document.querySelectorAll<HTMLElement>('#printer-card-1 [class*="rounded-[10px]"]')]
  130. .find((d) => /^HT-/.test((d.textContent || '').trim()));
  131. const el = card?.querySelector<HTMLElement>('[style*="max-width"]');
  132. if (!el) throw new Error('AMS-HT slot not rendered');
  133. return el.style;
  134. });
  135. }
  136. describe('PrintersPage — printer card body scale (#1848)', () => {
  137. beforeEach(() => {
  138. store = {};
  139. vi.mocked(localStorage.getItem).mockImplementation((key: string) => store[key] ?? null);
  140. vi.mocked(localStorage.setItem).mockImplementation((key: string, value: string) => {
  141. store[key] = String(value);
  142. });
  143. server.use(
  144. http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
  145. http.get('/api/v1/printers/:id/status', () => HttpResponse.json(STATUS)),
  146. http.get('/api/v1/queue/', () => HttpResponse.json([])),
  147. );
  148. });
  149. afterEach(() => {
  150. vi.mocked(localStorage.getItem).mockReset();
  151. vi.mocked(localStorage.setItem).mockReset();
  152. });
  153. it('leaves M — the default — at the sizes shipped before this change', async () => {
  154. const style = await cardStyleAt('2');
  155. expect(style.getPropertyValue('--pc-t10')).toBe('10px');
  156. expect(style.getPropertyValue('--pc-t8')).toBe('8px');
  157. expect(style.getPropertyValue('--pc-i3')).toBe('12px');
  158. expect(style.getPropertyValue('--pc-i4')).toBe('16px');
  159. });
  160. it('leaves S at the same sizes — the dense fleet view wants density', async () => {
  161. const style = await cardStyleAt('1');
  162. expect(style.getPropertyValue('--pc-t10')).toBe('10px');
  163. expect(style.getPropertyValue('--pc-i3')).toBe('12px');
  164. });
  165. it('scales the body type and icons at L', async () => {
  166. const style = await cardStyleAt('3');
  167. expect(style.getPropertyValue('--pc-t10')).toBe('12px');
  168. expect(style.getPropertyValue('--pc-t8')).toBe('9.6px');
  169. expect(style.getPropertyValue('--pc-t11')).toBe('13.2px');
  170. expect(style.getPropertyValue('--pc-i3')).toBe('14.4px');
  171. expect(style.getPropertyValue('--pc-i4')).toBe('19.2px');
  172. // The H2C rack chips. They were a hard-coded 28px, so the rack read as a
  173. // shrunken strip beside neighbours that had grown 20%.
  174. expect(style.getPropertyValue('--pc-i7')).toBe('33.6px');
  175. });
  176. it('scales further at XL, where the card is full width', async () => {
  177. const style = await cardStyleAt('4');
  178. expect(style.getPropertyValue('--pc-t10')).toBe('14px');
  179. expect(style.getPropertyValue('--pc-i4')).toBe('22.4px');
  180. // Every property is set at every size, so a converted class can never
  181. // fall through to its fallback while sitting inside a card.
  182. for (const name of ['--pc-t8', '--pc-t9', '--pc-t10', '--pc-t11',
  183. '--pc-i2', '--pc-i25', '--pc-i3', '--pc-i35', '--pc-i4', '--pc-i5',
  184. '--pc-i7']) {
  185. expect(style.getPropertyValue(name)).not.toBe('');
  186. }
  187. });
  188. // Scaling these along with the type was tried and reverted. The AMS cards
  189. // already grow to fill their row, so 3.5rem is a floor they sit well above;
  190. // raising it only costs a unit its place on the row, and a wrapped AMS-HT is
  191. // then alone on its line where flex-grow stretches its single slot across the
  192. // whole card.
  193. it('leaves the AMS slot columns at their fixed floor, at every size', async () => {
  194. await cardStyleAt('2');
  195. expect(await slotGridColumns()).toContain('3.5rem');
  196. });
  197. it('still leaves them alone at XL, where the type is largest', async () => {
  198. await cardStyleAt('4');
  199. expect(await slotGridColumns()).toContain('3.5rem');
  200. expect(await slotGridColumns()).not.toContain('4.9rem');
  201. });
  202. // The AMS-HT does need its width scaled: its readings sit beside the slot,
  203. // so bigger type eats the room they occupy.
  204. it('widens the AMS-HT card with the type, and caps how wide it can get', async () => {
  205. await cardStyleAt('3');
  206. const ht = await htCardStyle();
  207. expect(ht.minWidth).toBe('13.2rem'); // 11rem * 1.2
  208. expect(ht.flex).toContain('13.2rem');
  209. // Without a ceiling, an AMS-HT that wraps onto a line of its own is the
  210. // only flex item there and grow stretches its single slot across the
  211. // entire card, stranding the readings at the far edge.
  212. expect(ht.maxWidth).toBe('calc(4 * 3.5rem + 3 * 0.25rem + 1rem)');
  213. });
  214. it('leaves the AMS-HT card as it was at M', async () => {
  215. await cardStyleAt('2');
  216. expect((await htCardStyle()).minWidth).toBe('11rem');
  217. });
  218. // The AMS-HT's single slot is the only growable item on its row, so it took
  219. // every spare pixel and pushed the readings beside it hard against the card
  220. // edge. Capping it is what keeps them clear.
  221. it('caps the AMS-HT slot so it cannot swallow the row', async () => {
  222. await cardStyleAt('2');
  223. expect((await htSlotStyle()).maxWidth).toBe('7.25rem');
  224. });
  225. it('scales that cap with the type, since the slot label scales too', async () => {
  226. await cardStyleAt('4');
  227. expect((await htSlotStyle()).maxWidth).toBe('10.15rem'); // 7.25rem * 1.4
  228. });
  229. it('drives real elements, not just the root variables', async () => {
  230. await cardStyleAt('3');
  231. // The printer name already scaled before this change and still does.
  232. const heading = await screen.findByRole('heading', { name: 'X1C' });
  233. expect(heading.className).toContain('text-xl');
  234. // Body labels now reference the scaled property rather than a fixed px.
  235. const scaled = document.querySelectorAll('#printer-card-1 [class*="--pc-t"]');
  236. expect(scaled.length).toBeGreaterThan(0);
  237. });
  238. });