GcodeToolpathViewerSizing.test.tsx 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. /**
  2. * How the toolpath viewer is sized (#2887).
  3. *
  4. * The viewer appends its canvas into the same element it measures with
  5. * `clientWidth`/`clientHeight` and watches with a ResizeObserver. If that
  6. * element can take its height from its contents, the canvas ends up sizing the
  7. * box that sizes the canvas: every resize grew the container, which fired the
  8. * observer, which resized again. On `/gcode-viewer` the page climbed about
  9. * 190px a second and never rendered a frame, because a fresh ~18-megapixel
  10. * buffer was allocated and cleared before the previous one could finish.
  11. *
  12. * jsdom does no layout, so the loop itself cannot be reproduced here. What can
  13. * be pinned down is the structure that makes it impossible: the canvas is out
  14. * of flow, so it contributes nothing to its parent's height, and the measured
  15. * element takes a definite height from the pane above it rather than a
  16. * percentage that resolves to `auto`.
  17. */
  18. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  19. import { render } from '@testing-library/react';
  20. // Everything three.js does here is arithmetic except creating a GL context,
  21. // which jsdom has no answer for -- so only WebGLRenderer is replaced. Keeping
  22. // Scene/PerspectiveCamera/GridHelper real means the component runs its actual
  23. // setup path rather than one written for the test.
  24. vi.mock('three', async (importOriginal) => {
  25. const actual = await importOriginal<typeof import('three')>();
  26. class FakeWebGLRenderer {
  27. domElement = document.createElement('canvas');
  28. setSize(width: number, height: number) {
  29. // Mirror three.js: the size is written onto the canvas as inline style,
  30. // which is what fed back into the container's height.
  31. this.domElement.style.width = `${width}px`;
  32. this.domElement.style.height = `${height}px`;
  33. }
  34. setPixelRatio() {}
  35. render() {}
  36. dispose() {}
  37. }
  38. return { ...actual, WebGLRenderer: FakeWebGLRenderer };
  39. });
  40. vi.mock('three/examples/jsm/controls/OrbitControls.js', () => ({
  41. OrbitControls: class {
  42. enableDamping = false;
  43. dampingFactor = 0;
  44. update() {}
  45. dispose() {}
  46. },
  47. }));
  48. import { GcodeToolpathViewer } from '../../components/GcodeToolpathViewer';
  49. const canvas = () => document.querySelector('canvas') as HTMLCanvasElement;
  50. describe('GcodeToolpathViewer sizing', () => {
  51. beforeEach(() => {
  52. // Hold the component in its loading state so the canvas stays mounted;
  53. // an error would swap the whole tree for a message.
  54. vi.stubGlobal(
  55. 'fetch',
  56. vi.fn(() => new Promise(() => {})),
  57. );
  58. vi.stubGlobal(
  59. 'ResizeObserver',
  60. class {
  61. observe() {}
  62. unobserve() {}
  63. disconnect() {}
  64. },
  65. );
  66. });
  67. afterEach(() => {
  68. vi.unstubAllGlobals();
  69. });
  70. it('puts the canvas out of flow so it cannot size its own container', () => {
  71. render(<GcodeToolpathViewer gcodeUrl="/api/v1/archives/224/gcode" />);
  72. const element = canvas();
  73. expect(element).toBeTruthy();
  74. expect(element.style.position).toBe('absolute');
  75. // An in-flow canvas is `display: inline` by default, and the line box then
  76. // adds descender space on top of the height just set -- the ~33px per round
  77. // that drove the growth.
  78. expect(element.style.display).toBe('block');
  79. expect(element.style.inset).toBe('0');
  80. });
  81. it('measures an element with a definite height, not a percentage one', () => {
  82. render(<GcodeToolpathViewer gcodeUrl="/api/v1/archives/224/gcode" />);
  83. const measured = canvas().parentElement as HTMLElement;
  84. // `h-full` is `height: 100%`, which resolves to `auto` unless every ancestor
  85. // has a definite height -- on the full-page route none does. `inset-0`
  86. // against the positioned pane is definite whatever the page does.
  87. expect(measured.className).toContain('absolute');
  88. expect(measured.className).toContain('inset-0');
  89. expect(measured.className).not.toContain('h-full');
  90. });
  91. it('keeps the measured element inside a positioned pane', () => {
  92. const { container } = render(
  93. <GcodeToolpathViewer gcodeUrl="/api/v1/archives/224/gcode" className="flex-1 min-h-0" />,
  94. );
  95. // `inset-0` only means anything against a positioned ancestor; without the
  96. // `relative` pane the canvas would escape to the viewport.
  97. const pane = container.firstElementChild as HTMLElement;
  98. expect(pane.className).toContain('relative');
  99. expect(pane.className).toContain('flex-1');
  100. expect(pane.contains(canvas())).toBe(true);
  101. });
  102. });