1import {
2 getSharedHighlighter,
3 VirtualizedFile,
4 Virtualizer,
5 type FileContents,
6} from "@pierre/diffs";
7import { Editor, TextDocument } from "@pierre/diffs/edit";
8import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test";
9
10const renderingManagerUrl = new URL(
11 "./managers/UniversalRenderingManager.js",
12 import.meta.resolve("@pierre/diffs"),
13);
14const { clearRenderQueue } = (await import(/* @vite-ignore */ renderingManagerUrl.href)) as {
15 clearRenderQueue(): void;
16};
17
18// Layout measurements are controlled here. The real reconciler, document and
19// renderer calculate positions. This does not simulate native CSS wrapping.
20class MeasuredElement {
21 static geometryReads = 0;
22 children: MeasuredElement[] = [];
23 dataset: Record<string, string> = {};
24 nextElementSibling: MeasuredElement | null = null;
25 shadowRoot: MeasuredElement | null = null;
26 width = 283;
27
28 constructor(readonly height = 0) {}
29
30 attachShadow() {
31 this.shadowRoot ??= new MeasuredElement();
32 return this.shadowRoot;
33 }
34
35 appendChild(child: MeasuredElement) {
36 this.children.push(child);
37 return child;
38 }
39
40 getBoundingClientRect() {
41 MeasuredElement.geometryReads += 1;
42 return { top: 0, height: this.height, width: this.width };
43 }
44}
45
46class MeasuredCodeElement extends MeasuredElement {
47 readonly tagName = "CODE";
48
49 get firstElementChild() {
50 return this.children[0] ?? null;
51 }
52}
53
54const observers: RecordedResizeObserver[] = [];
55const animationFrames = new Map<number, FrameRequestCallback>();
56let nextFrameId = 0;
57
58class RecordedResizeObserver {
59 readonly targets = new Set<Element>();
60
61 constructor(readonly callback: ResizeObserverCallback) {
62 observers.push(this);
63 }
64
65 observe(target: Element) {
66 this.targets.add(target);
67 }
68
69 unobserve(target: Element) {
70 this.targets.delete(target);
71 }
72
73 disconnect() {
74 this.targets.clear();
75 }
76
77 deliver(target: HTMLElement) {
78 const rect = target.getBoundingClientRect();
79 const size = { inlineSize: rect.width, blockSize: rect.height };
80 this.callback(
81 [
82 {
83 target,
84 contentRect: rect,
85 contentBoxSize: [size],
86 borderBoxSize: [size],
87 devicePixelContentBoxSize: [size],
88 },
89 ],
90 this as unknown as ResizeObserver,
91 );
92 }
93}
94
95function drainRenderFrames() {
96 const errors = vi.spyOn(console, "error");
97 try {
98 for (let frame = 0; animationFrames.size > 0; frame += 1) {
99 if (frame === 10) throw new Error("The production render queue did not settle");
100 const callbacks = [...animationFrames.values()];
101 animationFrames.clear();
102 for (const callback of callbacks) callback(frame);
103 }
104 expect(errors).not.toHaveBeenCalled();
105 } finally {
106 errors.mockRestore();
107 }
108}
109
110function measuredElement(element: MeasuredElement): HTMLElement {
111 return element as unknown as HTMLElement;
112}
113
114class LayoutVirtualizer extends Virtualizer {
115 override getOffsetInScrollContainer(_element: HTMLElement) {
116 return 0;
117 }
118}
119
120class MeasuredFile extends VirtualizedFile {
121 override top = 0;
122 // Rows are measured by hand here; the virtualizer only needs to reconcile them.
123 override onRender = () => false;
124
125 override __attachEditor(editor: Parameters<VirtualizedFile["__attachEditor"]>[0]) {
126 this.editor = editor;
127 return () => {
128 this.editor = undefined;
129 };
130 }
131
132 async initialize(file: FileContents) {
133 this.updateCodeViewLayout(file, 0);
134 // Document changes require the session an attached editor installs.
135 (this as unknown as { installEditSession(file: FileContents): void }).installEditSession(file);
136 await this.fileRenderer.initializeHighlighter();
137 expect(
138 this.fileRenderer.renderFile(file, {
139 startingLine: 5950,
140 totalLines: 51,
141 bufferBefore: 0,
142 bufferAfter: 0,
143 }),
144 ).toBeDefined();
145 this.fileContainer = measuredElement(new MeasuredElement());
146 }
147
148 measure(
149 rows: ReadonlyArray<readonly [lineIndex: number, height: number]>,
150 contentWidth = 226.25,
151 ) {
152 const content = new MeasuredElement();
153 content.width = contentWidth;
154 content.children = rows.map(([lineIndex, height]) => {
155 const row = new MeasuredElement(height);
156 row.dataset.lineIndex = String(lineIndex);
157 return row;
158 });
159 const code = new MeasuredCodeElement();
160 code.width = contentWidth + 56.75;
161 code.children = [new MeasuredElement(), content];
162 this.code = measuredElement(code);
163 this.reconcileHeights();
164 }
165
166 resizeContent(contentWidth: number, codeWidth = contentWidth + 56.75) {
167 const code = this.code;
168 const content = code?.children[1];
169 if (!(code instanceof MeasuredElement) || !(content instanceof MeasuredElement)) {
170 throw new Error("Expected measured code and content");
171 }
172 code.width = codeWidth;
173 content.width = contentWidth;
174 }
175
176 observeLayout() {
177 const pre = new MeasuredElement();
178 if (!(this.code instanceof MeasuredElement)) throw new Error("Expected measured code");
179 pre.children = [this.code];
180 this.resizeManager.setup(measuredElement(pre) as HTMLPreElement, {
181 disableAnnotations: true,
182 columnVariables: "measure",
183 });
184 const code = this.code;
185 const observer = observers.find((candidate) => candidate.targets.has(code));
186 if (observer === undefined) throw new Error("The real resize manager did not observe code");
187 return () => observer.deliver(code);
188 }
189
190 dispose() {
191 this.fileContainer = undefined;
192 this.code = undefined;
193 this.cleanUp();
194 }
195}
196
197const instances: MeasuredFile[] = [];
198const editors: Editor<"file", undefined, undefined>[] = [];
199
200beforeAll(async () => {
201 await getSharedHighlighter({
202 themes: ["pierre-dark"],
203 langs: ["text"],
204 preferredHighlighter: "shiki-wasm",
205 });
206});
207
208beforeEach(() => {
209 observers.length = 0;
210 animationFrames.clear();
211 MeasuredElement.geometryReads = 0;
212 vi.stubGlobal("HTMLElement", MeasuredElement);
213 vi.stubGlobal("Document", MeasuredElement);
214 vi.stubGlobal("ResizeObserver", RecordedResizeObserver);
215 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
216 const id = ++nextFrameId;
217 animationFrames.set(id, callback);
218 return id;
219 });
220 vi.stubGlobal("cancelAnimationFrame", (id: number) => animationFrames.delete(id));
221});
222
223afterEach(() => {
224 for (const editor of editors.splice(0)) editor.cleanUp();
225 for (const instance of instances.splice(0)) instance.dispose();
226 clearRenderQueue();
227 animationFrames.clear();
228 vi.unstubAllGlobals();
229});
230
231async function makeFixture(
232 overflow: "wrap" | "scroll" = "wrap",
233 lineCount = 6001,
234 contentWidth = 226.25,
235) {
236 const contents = Array.from({ length: lineCount }, (_, index) => `line ${index}`).join("\n");
237 const file: FileContents = {
238 name: "wrapped.txt",
239 contents,
240 cacheKey: `wrapped:${overflow}`,
241 lang: "text",
242 };
243 const document = new TextDocument<"file", undefined>(file.name, contents, "text");
244 const instance = new MeasuredFile(
245 {
246 overflow,
247 disableFileHeader: true,
248 theme: "pierre-dark",
249 preferredHighlighter: "shiki-wasm",
250 useTokenTransformer: true,
251 controlledSelection: true,
252 },
253 new LayoutVirtualizer(),
254 );
255 instances.push(instance);
256 await instance.initialize(file);
257 instance.measure(
258 [
259 [0, 80],
260 [120, 60],
261 [4999, 100],
262 [5000, 80],
263 [5999, 100],
264 [6000, 60],
265 ],
266 contentWidth,
267 );
268 const apply = (change: { startLine: number } | undefined, passStartLine = true) => {
269 if (change === undefined) throw new Error("Expected a document change");
270 file.contents = document.getText();
271 instance.applyDocumentChange(
272 document,
273 undefined,
274 false,
275 passStartLine ? change.startLine : undefined,
276 );
277 };
278 const append = () => {
279 const position = document.positionAt(document.getText().length);
280 apply(document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }]));
281 };
282 return { instance, document, file, apply, append };
283}
284
285describe("wrapped editor document changes", () => {
286 it("preserves the position above an EOF insertion across layout checkpoints", async () => {
287 const { instance, document, append } = await makeFixture();
288 const previousLastLine = document.lineCount;
289 const before = instance.getLinePosition(previousLastLine);
290 expect(before).toEqual({ top: 120328, height: 60 });
291 const viewport = { top: before!.top - 100, bottom: before!.top + 80 };
292 expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2096 });
293
294 append();
295
296 expect(document.lineCount).toBe(previousLastLine + 1);
297 expect(instance.getLinePosition(previousLastLine)).toEqual({ top: before!.top, height: 20 });
298 expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120348, height: 20 });
299 expect(instance.getVirtualizedHeight()).toBe(120376);
300 expect(instance.getAdvancedStickySpecs(viewport)).toEqual({ topOffset: 118240, height: 2096 });
301 });
302
303 it("invalidates changed and shifted rows after an insertion in the middle", async () => {
304 const { instance, document, apply } = await makeFixture();
305 const before = instance.getLinePosition(5001);
306 const position = { line: 5000, character: 2 };
307 apply(document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }]));
308
309 expect(instance.getLinePosition(5001)).toEqual({ top: before!.top, height: 20 });
310 expect(instance.getLineHeight(4999)).toBe(100);
311 expect(instance.getLineHeight(5000)).toBe(20);
312 expect(instance.getLineHeight(5999)).toBe(20);
313 expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120208, height: 20 });
314 });
315
316 it("keeps preceding measurements when a deletion crosses a checkpoint", async () => {
317 const { instance, document, apply } = await makeFixture();
318 const before = instance.getLinePosition(5000);
319 apply(
320 document.applyEdits([
321 {
322 range: { start: { line: 4999, character: 2 }, end: { line: 5001, character: 2 } },
323 newText: "",
324 },
325 ]),
326 );
327
328 expect(document.lineCount).toBe(5999);
329 expect(instance.getLinePosition(5000)).toEqual({ top: before!.top, height: 20 });
330 expect(instance.getLineHeight(120)).toBe(60);
331 expect(instance.getLineHeight(4999)).toBe(20);
332 expect(instance.getLineHeight(6000)).toBe(20);
333 expect(instance.getLinePosition(document.lineCount)).toEqual({ top: 120068, height: 20 });
334 });
335
336 it("uses the earliest changed line for edits at multiple selections", async () => {
337 const { instance, document, apply } = await makeFixture();
338 const before = instance.getLinePosition(121);
339 apply(
340 document.applyEdits(
341 [120, 5000].map((line) => ({
342 range: { start: { line, character: 2 }, end: { line, character: 2 } },
343 newText: "\n",
344 })),
345 ),
346 );
347
348 expect(document.lineCount).toBe(6003);
349 expect(instance.getLinePosition(121)).toEqual({ top: before!.top, height: 20 });
350 expect(instance.getLineHeight(0)).toBe(80);
351 expect(instance.getLineHeight(120)).toBe(20);
352 expect(instance.getLineHeight(4999)).toBe(20);
353 });
354
355 it("retains the unchanged prefix through repeated Enter, undo and redo", async () => {
356 const { instance, document, apply, append } = await makeFixture();
357 const before = instance.getLinePosition(6001);
358 for (let count = 0; count < 60; count += 1) append();
359 expect(document.lineCount).toBe(6061);
360 expect(instance.getLinePosition(6001)?.top).toBe(before!.top);
361 apply(document.undo()?.[0]);
362 expect(instance.getLinePosition(6001)?.top).toBe(before!.top);
363 apply(document.redo()?.[0]);
364 expect(document.lineCount).toBe(6061);
365 expect(instance.getLinePosition(6001)?.top).toBe(before!.top);
366 });
367
368 it("keeps unwrapped positions unchanged", async () => {
369 const { instance, append } = await makeFixture("scroll");
370 const before = instance.getLinePosition(6001);
371 append();
372 expect(instance.getLinePosition(6001)).toEqual(before);
373 expect(instance.getLinePosition(6002)).toEqual({ top: 120028, height: 20 });
374 });
375
376 it("fully invalidates measurements when the first changed line is unknown", async () => {
377 const { instance, document, apply } = await makeFixture();
378 const position = document.positionAt(document.getText().length);
379 apply(
380 document.applyEdits([{ range: { start: position, end: position }, newText: "\n" }]),
381 false,
382 );
383 expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 });
384 expect(instance.getLineHeight(0)).toBe(20);
385 });
386
387 it("still discards all measured rows after a metric change", async () => {
388 const { instance, file, append } = await makeFixture();
389 append();
390 instance.setMetrics({ hunkLineCount: 50, lineHeight: 24, diffHeaderHeight: 44, spacing: 8 });
391 instance.updateCodeViewLayout(file, 0);
392 expect(instance.getLinePosition(6001)).toEqual({ top: 144008, height: 24 });
393 });
394
395 it("still discards all measured rows when annotations change", async () => {
396 const { instance, file, append } = await makeFixture();
397 append();
398 instance.setLineAnnotations([{ lineNumber: 10, metadata: undefined }]);
399 instance.updateCodeViewLayout(file, 0);
400 expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 });
401 });
402});
403
404describe("wrapped measurement widths", () => {
405 it.each([
406 [226.25, 482.25],
407 [482.25, 226.25],
408 ])(
409 "drops offscreen measurements when content changes from %spx to %spx",
410 async (before, after) => {
411 const { instance } = await makeFixture("wrap", 6001, before);
412 instance.measure([[6000, 60]], after);
413 expect(instance.getLineHeight(0)).toBe(20);
414 expect(instance.getLineHeight(120)).toBe(20);
415 expect(instance.getLineHeight(6000)).toBe(60);
416 expect(instance.getVirtualizedHeight()).toBe(120076);
417 },
418 );
419
420 it.each([
421 [226.25, 482.25],
422 [482.25, 226.25],
423 ])(
424 "does not retain old-width prefix heights after a %spx to %spx resize and edit",
425 async (before, after) => {
426 const { instance, append } = await makeFixture("wrap", 6001, before);
427 instance.measure([[6000, 60]], after);
428 append();
429 expect(instance.getLineHeight(0)).toBe(20);
430 expect(instance.getLineHeight(120)).toBe(20);
431 expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 20 });
432 },
433 );
434
435 it("repairs an edit before resize delivery when the real resize and render queues drain", async () => {
436 const { instance, append } = await makeFixture();
437 instance.measure([[6000, 60]]);
438 const deliverResize = instance.observeLayout();
439 instance.resizeContent(482.25);
440 const readsBeforeEdit = MeasuredElement.geometryReads;
441 append();
442 expect(MeasuredElement.geometryReads).toBe(readsBeforeEdit);
443 // No synchronous geometry read: the resize entry owns invalidation.
444 expect(instance.getLineHeight(0)).toBe(80);
445 deliverResize();
446 drainRenderFrames();
447 expect(instance.getLineHeight(0)).toBe(20);
448 expect(instance.getLinePosition(6001)).toEqual({ top: 120008, height: 60 });
449 });
450
451 it("keeps measured prefixes through same-width reconciliation and editing", async () => {
452 const { instance, append } = await makeFixture();
453 instance.measure([[6000, 60]]);
454 append();
455 expect(instance.getLineHeight(0)).toBe(80);
456 expect(instance.getLineHeight(120)).toBe(60);
457 expect(instance.getLinePosition(6001)).toEqual({ top: 120328, height: 20 });
458 });
459
460 it("preserves measurements on first and repeated same-width resize deliveries", async () => {
461 const { instance } = await makeFixture();
462 const before = instance.getVirtualizedHeight();
463 const deliverResize = instance.observeLayout();
464 deliverResize();
465 deliverResize();
466 drainRenderFrames();
467 expect(instance.getLineHeight(0)).toBe(80);
468 expect(instance.getVirtualizedHeight()).toBe(before);
469 });
470
471 it.each([
472 [226.25, 482.25],
473 [482.25, 226.25],
474 ])("handles a %spx to %spx resize before first observer delivery", async (before, after) => {
475 const { instance } = await makeFixture("wrap", 6001, before);
476 instance.measure([[6000, 20]], before);
477 const deliverResize = instance.observeLayout();
478 instance.resizeContent(after);
479 deliverResize();
480 drainRenderFrames();
481 expect(instance.getLineHeight(0)).toBe(20);
482 expect(instance.getVirtualizedHeight()).toBe(120036);
483 });
484
485 it("keeps width validity when a new editor attaches to the same file", async () => {
486 const { instance } = await makeFixture();
487 instance.measure([[6000, 20]]);
488 const deliverResize = instance.observeLayout();
489 vi.stubGlobal("SVGSVGElement", EditorElement);
490 vi.stubGlobal(
491 "document",
492 Object.assign(new EditorElement(), { createElement: () => new EditorElement() }),
493 );
494 const first = new Editor("file");
495 editors.push(first);
496 first.edit(instance);
497 first.cleanUp();
498 const second = new Editor("file");
499 editors.push(second);
500 second.edit(instance);
501 instance.resizeContent(482.25);
502 deliverResize();
503 drainRenderFrames();
504 expect(instance.getLineHeight(0)).toBe(20);
505 expect(instance.getVirtualizedHeight()).toBe(120036);
506 });
507
508 it("ignores stale resize deliveries after cleanup", async () => {
509 const { instance } = await makeFixture();
510 const deliverResize = instance.observeLayout();
511 instance.dispose();
512 clearRenderQueue();
513 animationFrames.clear();
514 deliverResize();
515 expect(animationFrames.size).toBe(0);
516 });
517
518 it("does not discard a stable code width for gutter subpixel rounding", async () => {
519 const { instance } = await makeFixture("wrap", 6001, 482.25);
520 const deliverResize = instance.observeLayout();
521 instance.resizeContent(482.234375, 539);
522 deliverResize();
523 drainRenderFrames();
524 expect(instance.getLineHeight(0)).toBe(80);
525 });
526
527 it("waits for a visible width instead of caching measurements while hidden", async () => {
528 const { instance } = await makeFixture();
529 instance.measure([[6000, 20]]);
530 const deliverResize = instance.observeLayout();
531 instance.resizeContent(0, 0);
532 deliverResize();
533 drainRenderFrames();
534 expect(instance.getLineHeight(0)).toBe(80);
535 instance.resizeContent(482.25);
536 deliverResize();
537 drainRenderFrames();
538 expect(instance.getLineHeight(0)).toBe(20);
539 expect(instance.getVirtualizedHeight()).toBe(120036);
540 });
541});
542
543// Supply inert DOM transport so public Editor edits execute its real tokenizer
544// and layout handoff. No native wrapping, observer delivery or scrolling is modeled.
545class EditorElement extends MeasuredElement {
546 style: Record<string, string> = {};
547 parentElement: EditorElement | null = null;
548
549 override appendChild(child: EditorElement) {
550 child.parentElement = this;
551 this.children.push(child);
552 return child;
553 }
554
555 append(child: EditorElement) {
556 this.appendChild(child);
557 }
558
559 prepend(child: EditorElement) {
560 child.parentElement = this;
561 this.children.unshift(child);
562 }
563
564 replaceChildren(...children: (EditorElement | string)[]) {
565 this.children = [];
566 for (const child of children) if (typeof child !== "string") this.appendChild(child);
567 }
568
569 setAttribute() {}
570 removeAttribute() {}
571 addEventListener() {}
572 removeEventListener() {}
573 after() {}
574
575 remove() {
576 if (this.parentElement) {
577 this.parentElement.children = this.parentElement.children.filter((child) => child !== this);
578 }
579 }
580
581 set innerHTML(value: string) {
582 expect(value.startsWith("<svg")).toBe(true);
583 this.replaceChildren(new EditorElement());
584 }
585
586 get firstElementChild() {
587 return this.children[0] ?? null;
588 }
589
590 querySelectorAll(selector: string) {
591 expect(selector).toBe("[data-code]");
592 return this.children.filter((child) => "code" in child.dataset);
593 }
594
595 querySelector(selector: string) {
596 expect(selector).toBe("[data-deletions]");
597 return null;
598 }
599
600 getContext() {
601 return { measureText: (text: string) => ({ width: text.length * 8 }) };
602 }
603}
604
605async function makeEditorFixture(lineCount: number) {
606 const { instance, file } = await makeFixture("wrap", lineCount);
607 vi.stubGlobal("SVGSVGElement", EditorElement);
608 vi.stubGlobal("Document", EditorElement);
609 vi.stubGlobal(
610 "document",
611 Object.assign(new EditorElement(), { createElement: () => new EditorElement() }),
612 );
613 vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) });
614 vi.stubGlobal("requestAnimationFrame", () => 1);
615 vi.stubGlobal("cancelAnimationFrame", () => {});
616 vi.stubGlobal("getComputedStyle", () => ({
617 paddingTop: "0px",
618 fontSize: "13px",
619 fontFamily: "monospace",
620 tabSize: "2",
621 lineHeight: "20px",
622 }));
623 vi.stubGlobal(
624 "ResizeObserver",
625 class {
626 observe() {}
627 disconnect() {}
628 },
629 );
630 instance.setOptions({
631 ...instance.options,
632 useTokenTransformer: true,
633 controlledSelection: true,
634 themeType: "dark",
635 });
636 const content = new EditorElement();
637 content.dataset.content = "";
638 const gutter = new EditorElement();
639 gutter.dataset.gutter = "";
640 const code = new EditorElement();
641 code.dataset.code = "";
642 code.appendChild(gutter);
643 code.appendChild(content);
644 const shadow = new EditorElement();
645 shadow.appendChild(code);
646 const host = Object.assign(new EditorElement(), { shadowRoot: shadow });
647 const highlighter = await getSharedHighlighter({
648 themes: ["pierre-dark"],
649 langs: ["text"],
650 preferredHighlighter: "shiki-wasm",
651 });
652 const editor = new Editor("file");
653 editors.push(editor);
654 editor.edit(instance);
655 editor.__syncRenderView({
656 highlighter,
657 fileContainer: measuredElement(host),
658 file,
659 lineAnnotations: undefined,
660 renderRange: { startingLine: 0, totalLines: 1, bufferBefore: 0, bufferAfter: 0 },
661 });
662 const append = (count: number) => {
663 const lines = editor.getText().split("\n");
664 const end = { line: lines.length - 1, character: lines.at(-1)!.length };
665 editor.applyEdits([{ range: { start: end, end }, newText: "\n".repeat(count) }]);
666 };
667 const remove = (count: number) => {
668 const lines = editor.getText().split("\n");
669 const startLine = lines.length - count - 1;
670 editor.applyEdits([
671 {
672 range: {
673 start: { line: startLine, character: lines[startLine]!.length },
674 end: { line: lines.length - 1, character: lines.at(-1)!.length },
675 },
676 newText: "",
677 },
678 ]);
679 };
680 return { instance, editor, append, remove };
681}
682
683describe("editor gutter-width changes", () => {
684 it.each([
685 [9999, 1],
686 [9998, 3],
687 ])(
688 "clears prefix measurements when %i lines grow by %i across a digit boundary",
689 async (lines, count) => {
690 const { instance, editor, append } = await makeEditorFixture(lines);
691 expect(instance.getLineHeight(0)).toBe(80);
692 append(count);
693 expect(editor.getText().split("\n")).toHaveLength(lines + count);
694 expect(instance.getLineHeight(0)).toBe(20);
695 expect(instance.getLineHeight(5000)).toBe(20);
696 },
697 );
698
699 it.each([
700 [10000, 1],
701 [10002, 4],
702 ])(
703 "clears prefix measurements when %i lines shrink by %i across a digit boundary",
704 async (lines, count) => {
705 const { instance, editor, remove } = await makeEditorFixture(lines);
706 remove(count);
707 expect(editor.getText().split("\n")).toHaveLength(lines - count);
708 expect(instance.getLineHeight(0)).toBe(20);
709 expect(instance.getLineHeight(5000)).toBe(20);
710 },
711 );
712
713 it("clears newly measured prefixes on undo and redo across a digit boundary", async () => {
714 const { instance, editor, append } = await makeEditorFixture(9999);
715 append(1);
716 instance.measure([[0, 100]]);
717 editor.undo();
718 expect(editor.getText().split("\n")).toHaveLength(9999);
719 expect(instance.getLineHeight(0)).toBe(20);
720 instance.measure([[0, 80]]);
721 editor.redo();
722 expect(editor.getText().split("\n")).toHaveLength(10000);
723 expect(instance.getLineHeight(0)).toBe(20);
724 });
725
726 it.each([
727 [9998, 1],
728 [10000, 2],
729 ])(
730 "retains prefix measurements when %i lines grow by %i without changing digit width",
731 async (lines, count) => {
732 const { instance, editor, append } = await makeEditorFixture(lines);
733 append(count);
734 expect(editor.getText().split("\n")).toHaveLength(lines + count);
735 expect(instance.getLineHeight(0)).toBe(80);
736 expect(instance.getLineHeight(5000)).toBe(80);
737 editor.undo();
738 expect(instance.getLineHeight(0)).toBe(80);
739 editor.redo();
740 expect(instance.getLineHeight(0)).toBe(80);
741 },
742 );
743});