textRenderer.spec.tsx 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. import {mat3} from 'gl-matrix';
  2. import {Flamegraph} from 'sentry/utils/profiling/flamegraph';
  3. import {LightFlamegraphTheme as Theme} from 'sentry/utils/profiling/flamegraph/flamegraphTheme';
  4. import {Rect, trimTextCenter} from 'sentry/utils/profiling/gl/utils';
  5. import {EventedProfile} from 'sentry/utils/profiling/profile/eventedProfile';
  6. import {createFrameIndex} from 'sentry/utils/profiling/profile/utils';
  7. import {TextRenderer} from 'sentry/utils/profiling/renderers/textRenderer';
  8. const makeBaseFlamegraph = (): Flamegraph => {
  9. const profile = EventedProfile.FromProfile(
  10. {
  11. name: 'profile',
  12. startValue: 0,
  13. threadID: 0,
  14. endValue: 1000,
  15. unit: 'milliseconds',
  16. type: 'evented',
  17. events: [
  18. {type: 'O', at: 0, frame: 0},
  19. {type: 'O', at: 1, frame: 1},
  20. {type: 'C', at: 2, frame: 1},
  21. {type: 'C', at: 3, frame: 0},
  22. ],
  23. },
  24. createFrameIndex([{name: 'f0'}, {name: 'f1'}])
  25. );
  26. return new Flamegraph(profile, 0, {inverted: false, leftHeavy: false});
  27. };
  28. describe('TextRenderer', () => {
  29. it('invalidates cache if cached measurements do not match new measurements', () => {
  30. const context: Partial<CanvasRenderingContext2D> = {
  31. measureText: jest
  32. .fn()
  33. .mockReturnValueOnce({width: 1}) // first call for test
  34. .mockReturnValueOnce({width: 10})
  35. .mockReturnValueOnce({width: 20}),
  36. };
  37. const canvas: Partial<HTMLCanvasElement> = {
  38. getContext: jest.fn().mockReturnValue(context),
  39. };
  40. const textRenderer = new TextRenderer(
  41. canvas as HTMLCanvasElement,
  42. makeBaseFlamegraph(),
  43. Theme
  44. );
  45. textRenderer.measureAndCacheText('test');
  46. textRenderer.maybeInvalidateCache();
  47. textRenderer.maybeInvalidateCache();
  48. expect(textRenderer.textCache.test).toBe(undefined);
  49. expect(textRenderer.textCache).toEqual({
  50. 'Who knows if this changed, font-display: swap wont tell me': 20,
  51. });
  52. });
  53. it('caches measure text', () => {
  54. const context: Partial<CanvasRenderingContext2D> = {
  55. measureText: jest.fn().mockReturnValue({width: 10}),
  56. };
  57. const canvas: Partial<HTMLCanvasElement> = {
  58. getContext: jest.fn().mockReturnValue(context),
  59. };
  60. const textRenderer = new TextRenderer(
  61. canvas as HTMLCanvasElement,
  62. makeBaseFlamegraph(),
  63. Theme
  64. );
  65. textRenderer.measureAndCacheText('text');
  66. textRenderer.measureAndCacheText('text');
  67. expect(context.measureText).toHaveBeenCalledTimes(1);
  68. });
  69. it('skips rendering node if it is not visible', () => {
  70. // Flamegraph looks like this
  71. // f0----f0 f2
  72. // f1
  73. const profile = EventedProfile.FromProfile(
  74. {
  75. name: 'profile',
  76. startValue: 0,
  77. endValue: 1000,
  78. unit: 'milliseconds',
  79. type: 'evented',
  80. threadID: 0,
  81. events: [
  82. {type: 'O', at: 0, frame: 0},
  83. {type: 'O', at: 100, frame: 1},
  84. {type: 'C', at: 200, frame: 1},
  85. {type: 'C', at: 300, frame: 0},
  86. {type: 'O', at: 300, frame: 2},
  87. {type: 'C', at: 400, frame: 2},
  88. ],
  89. },
  90. createFrameIndex([{name: 'f0'}, {name: 'f1'}, {name: 'f2'}])
  91. );
  92. const flamegraph = new Flamegraph(profile, 0, {inverted: false, leftHeavy: false});
  93. const context: Partial<CanvasRenderingContext2D> = {
  94. measureText: jest.fn().mockReturnValue({width: 10}),
  95. fillText: jest.fn(),
  96. };
  97. const canvas: Partial<HTMLCanvasElement> = {
  98. getContext: jest.fn().mockReturnValue(context),
  99. };
  100. const textRenderer = new TextRenderer(canvas as HTMLCanvasElement, flamegraph, Theme);
  101. textRenderer.draw(new Rect(0, 0, 200, 2), mat3.identity(mat3.create()));
  102. expect(context.fillText).toHaveBeenCalledTimes(2);
  103. });
  104. it("trims output text if it doesn't fit", () => {
  105. const longFrameName =
  106. 'very long frame name that needs to be truncated to fit the rect';
  107. const profile = EventedProfile.FromProfile(
  108. {
  109. name: 'profile',
  110. startValue: 0,
  111. endValue: 1000,
  112. unit: 'milliseconds',
  113. type: 'evented',
  114. threadID: 0,
  115. events: [
  116. {type: 'O', at: 0, frame: 0},
  117. {type: 'C', at: longFrameName.length, frame: 0},
  118. ],
  119. },
  120. createFrameIndex([{name: longFrameName}])
  121. );
  122. const flamegraph = new Flamegraph(profile, 0, {inverted: false, leftHeavy: false});
  123. const context: Partial<CanvasRenderingContext2D> = {
  124. measureText: jest.fn().mockImplementation(n => {
  125. return {width: n.length - 1};
  126. }),
  127. fillText: jest.fn(),
  128. };
  129. const canvas: Partial<HTMLCanvasElement> = {
  130. getContext: jest.fn().mockReturnValue(context),
  131. };
  132. const textRenderer = new TextRenderer(canvas as HTMLCanvasElement, flamegraph, Theme);
  133. textRenderer.draw(
  134. new Rect(0, 0, Math.floor(longFrameName.length / 2), 10),
  135. mat3.identity(mat3.create())
  136. );
  137. expect(context.fillText).toHaveBeenCalledTimes(1);
  138. expect(context.fillText).toHaveBeenCalledWith(
  139. trimTextCenter(
  140. longFrameName,
  141. Math.floor(longFrameName.length / 2) - Theme.SIZES.BAR_PADDING * 2
  142. ),
  143. Theme.SIZES.BAR_PADDING,
  144. Theme.SIZES.BAR_HEIGHT - Theme.SIZES.BAR_FONT_SIZE / 2 // center text vertically inside the rect
  145. );
  146. });
  147. it('pins text to left and respects right boundary', () => {
  148. const longFrameName =
  149. 'very long frame name that needs to be truncated to fit the rect';
  150. const profile = EventedProfile.FromProfile(
  151. {
  152. name: 'profile',
  153. startValue: 0,
  154. endValue: 1000,
  155. unit: 'milliseconds',
  156. type: 'evented',
  157. threadID: 0,
  158. events: [
  159. {type: 'O', at: 0, frame: 0},
  160. {type: 'C', at: longFrameName.length, frame: 0},
  161. ],
  162. },
  163. createFrameIndex([{name: longFrameName}])
  164. );
  165. const flamegraph = new Flamegraph(profile, 0, {inverted: false, leftHeavy: false});
  166. const context: Partial<CanvasRenderingContext2D> = {
  167. measureText: jest.fn().mockImplementation(n => {
  168. return {width: n.length - 1};
  169. }),
  170. fillText: jest.fn(),
  171. };
  172. const canvas: Partial<HTMLCanvasElement> = {
  173. getContext: jest.fn().mockReturnValue(context),
  174. };
  175. const textRenderer = new TextRenderer(canvas as HTMLCanvasElement, flamegraph, Theme);
  176. textRenderer.draw(
  177. new Rect(
  178. Math.floor(longFrameName.length / 2),
  179. 0,
  180. Math.floor(longFrameName.length / 2 / 2),
  181. 10
  182. ),
  183. mat3.identity(mat3.create())
  184. );
  185. expect(context.fillText).toHaveBeenCalledTimes(1);
  186. expect(context.fillText).toHaveBeenCalledWith(
  187. trimTextCenter(
  188. longFrameName,
  189. Math.floor(longFrameName.length / 2 / 2) - Theme.SIZES.BAR_PADDING * 2
  190. ),
  191. Math.floor(longFrameName.length / 2) + Theme.SIZES.BAR_PADDING,
  192. Theme.SIZES.BAR_HEIGHT - Theme.SIZES.BAR_FONT_SIZE / 2 // center text vertically inside the rect
  193. );
  194. });
  195. });