textRenderer.spec.tsx 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  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': {
  51. width: 20,
  52. },
  53. });
  54. });
  55. it('caches measure text', () => {
  56. const context: Partial<CanvasRenderingContext2D> = {
  57. measureText: jest.fn().mockReturnValue({width: 10}),
  58. };
  59. const canvas: Partial<HTMLCanvasElement> = {
  60. getContext: jest.fn().mockReturnValue(context),
  61. };
  62. const textRenderer = new TextRenderer(
  63. canvas as HTMLCanvasElement,
  64. makeBaseFlamegraph(),
  65. Theme
  66. );
  67. textRenderer.measureAndCacheText('text');
  68. textRenderer.measureAndCacheText('text');
  69. expect(context.measureText).toHaveBeenCalledTimes(1);
  70. });
  71. it('skips rendering node if it is not visible', () => {
  72. // Flamegraph looks like this
  73. // f0----f0 f2
  74. // f1
  75. const profile = EventedProfile.FromProfile(
  76. {
  77. name: 'profile',
  78. startValue: 0,
  79. endValue: 1000,
  80. unit: 'milliseconds',
  81. type: 'evented',
  82. threadID: 0,
  83. events: [
  84. {type: 'O', at: 0, frame: 0},
  85. {type: 'O', at: 100, frame: 1},
  86. {type: 'C', at: 200, frame: 1},
  87. {type: 'C', at: 300, frame: 0},
  88. {type: 'O', at: 300, frame: 2},
  89. {type: 'C', at: 400, frame: 2},
  90. ],
  91. },
  92. createFrameIndex([{name: 'f0'}, {name: 'f1'}, {name: 'f2'}])
  93. );
  94. const flamegraph = new Flamegraph(profile, 0, {inverted: false, leftHeavy: false});
  95. const context: Partial<CanvasRenderingContext2D> = {
  96. measureText: jest.fn().mockReturnValue({width: 10}),
  97. fillText: jest.fn(),
  98. };
  99. const canvas: Partial<HTMLCanvasElement> = {
  100. getContext: jest.fn().mockReturnValue(context),
  101. };
  102. const textRenderer = new TextRenderer(canvas as HTMLCanvasElement, flamegraph, Theme);
  103. textRenderer.draw(new Rect(0, 0, 200, 2), mat3.identity(mat3.create()), new Map());
  104. expect(context.fillText).toHaveBeenCalledTimes(2);
  105. });
  106. it("trims output text if it doesn't fit", () => {
  107. const longFrameName =
  108. 'very long frame name that needs to be truncated to fit the rect';
  109. const profile = EventedProfile.FromProfile(
  110. {
  111. name: 'profile',
  112. startValue: 0,
  113. endValue: 1000,
  114. unit: 'milliseconds',
  115. type: 'evented',
  116. threadID: 0,
  117. events: [
  118. {type: 'O', at: 0, frame: 0},
  119. {type: 'C', at: longFrameName.length, frame: 0},
  120. ],
  121. },
  122. createFrameIndex([{name: longFrameName}])
  123. );
  124. const flamegraph = new Flamegraph(profile, 0, {inverted: false, leftHeavy: false});
  125. const context: Partial<CanvasRenderingContext2D> = {
  126. measureText: jest.fn().mockImplementation(n => {
  127. return {width: n.length - 1};
  128. }),
  129. fillText: jest.fn(),
  130. };
  131. const canvas: Partial<HTMLCanvasElement> = {
  132. getContext: jest.fn().mockReturnValue(context),
  133. };
  134. const textRenderer = new TextRenderer(canvas as HTMLCanvasElement, flamegraph, Theme);
  135. textRenderer.draw(
  136. new Rect(0, 0, Math.floor(longFrameName.length / 2), 10),
  137. mat3.identity(mat3.create()),
  138. new Map()
  139. );
  140. expect(context.fillText).toHaveBeenCalledTimes(1);
  141. expect(context.fillText).toHaveBeenCalledWith(
  142. trimTextCenter(
  143. longFrameName,
  144. Math.floor(longFrameName.length / 2) - Theme.SIZES.BAR_PADDING * 2
  145. ).text,
  146. Theme.SIZES.BAR_PADDING,
  147. Theme.SIZES.BAR_HEIGHT - Theme.SIZES.BAR_FONT_SIZE / 2 // center text vertically inside the rect
  148. );
  149. });
  150. it('pins text to left and respects right boundary', () => {
  151. const longFrameName =
  152. 'very long frame name that needs to be truncated to fit the rect';
  153. const profile = EventedProfile.FromProfile(
  154. {
  155. name: 'profile',
  156. startValue: 0,
  157. endValue: 1000,
  158. unit: 'milliseconds',
  159. type: 'evented',
  160. threadID: 0,
  161. events: [
  162. {type: 'O', at: 0, frame: 0},
  163. {type: 'C', at: longFrameName.length, frame: 0},
  164. ],
  165. },
  166. createFrameIndex([{name: longFrameName}])
  167. );
  168. const flamegraph = new Flamegraph(profile, 0, {inverted: false, leftHeavy: false});
  169. const context: Partial<CanvasRenderingContext2D> = {
  170. measureText: jest.fn().mockImplementation(n => {
  171. return {width: n.length - 1};
  172. }),
  173. fillText: jest.fn(),
  174. };
  175. const canvas: Partial<HTMLCanvasElement> = {
  176. getContext: jest.fn().mockReturnValue(context),
  177. };
  178. const textRenderer = new TextRenderer(canvas as HTMLCanvasElement, flamegraph, Theme);
  179. textRenderer.draw(
  180. new Rect(
  181. Math.floor(longFrameName.length / 2),
  182. 0,
  183. Math.floor(longFrameName.length / 2 / 2),
  184. 10
  185. ),
  186. mat3.identity(mat3.create()),
  187. new Map()
  188. );
  189. expect(context.fillText).toHaveBeenCalledTimes(1);
  190. expect(context.fillText).toHaveBeenCalledWith(
  191. trimTextCenter(
  192. longFrameName,
  193. Math.floor(longFrameName.length / 2 / 2) - Theme.SIZES.BAR_PADDING * 2
  194. ).text,
  195. Math.floor(longFrameName.length / 2) + Theme.SIZES.BAR_PADDING,
  196. Theme.SIZES.BAR_HEIGHT - Theme.SIZES.BAR_FONT_SIZE / 2 // center text vertically inside the rect
  197. );
  198. });
  199. });