1import type { LegendListRef } from "@legendapp/list/react";
2import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4
5import {
6 observeAssistantCitationCommentSource,
7 observeAssistantCitationSource,
8 type AssistantCitationTarget,
9} from "./AssistantCitationSource";
10
11const mocks = vi.hoisted(() => ({ resolveRange: vi.fn(), toast: vi.fn() }));
12vi.mock("~/lib/assistantTextSelection", () => ({
13 resolveAssistantCitationRange: mocks.resolveRange,
14}));
15vi.mock("../ui/toast", () => ({ toastManager: { add: mocks.toast } }));
16
17function rect(top: number, height = 20) {
18 return { top, bottom: top + height, height, width: 100 } as DOMRect;
19}
20
21class TestAnimation {
22 currentTime = 0;
23 id = "";
24 private resolveFinished!: () => void;
25 finished = new Promise<void>((resolve) => {
26 this.resolveFinished = resolve;
27 });
28 cancel = vi.fn();
29 finish() {
30 this.resolveFinished();
31 }
32}
33
34class TestElement {
35 parentNode: TestElement | null = null;
36 dataset: Record<string, string> = {};
37 focus = vi.fn();
38 animations: TestAnimation[] = [];
39 animate = vi.fn(() => {
40 const animation = new TestAnimation();
41 this.animations.push(animation);
42 return animation as unknown as Animation;
43 });
44 isConnected = true;
45 scrollTop = 8586;
46 scrollHeight = 9334;
47 clientHeight = 748;
48 getBoundingClientRect = () => rect(0, this.clientHeight);
49 ownerDocument = {
50 getSelection: () => null as TestSelection | null,
51 };
52
53 contains(node: TestElement | null): boolean {
54 return node === this || (node?.parentNode ? this.contains(node.parentNode) : false);
55 }
56}
57
58class TestRange {
59 startOffset = 0;
60 endOffset = 5;
61 collapsed = false;
62 endContainer: TestElement;
63
64 constructor(
65 public startContainer: TestElement,
66 readonly readRect: () => DOMRect,
67 ) {
68 this.endContainer = startContainer;
69 }
70
71 getBoundingClientRect() {
72 return this.collapsed ? rect(0, 0) : this.readRect();
73 }
74
75 cloneRange() {
76 const copy = new TestRange(this.startContainer, this.readRect);
77 copy.startOffset = this.startOffset;
78 copy.endOffset = this.endOffset;
79 return copy;
80 }
81
82 collapseTo(parent: TestElement) {
83 this.startContainer = this.endContainer = parent;
84 this.startOffset = this.endOffset = 0;
85 this.collapsed = true;
86 }
87
88 setStart(node: TestElement, offset: number) {
89 this.startContainer = node;
90 this.startOffset = offset;
91 this.collapsed =
92 this.startContainer === this.endContainer && this.startOffset === this.endOffset;
93 }
94
95 setEnd(node: TestElement, offset: number) {
96 this.endContainer = node;
97 this.endOffset = offset;
98 this.collapsed =
99 this.startContainer === this.endContainer && this.startOffset === this.endOffset;
100 }
101
102 toString() {
103 return this.collapsed ? "" : "quote";
104 }
105}
106
107class TestSelection {
108 ranges: TestRange[] = [];
109 get rangeCount() {
110 return this.ranges.length;
111 }
112 getRangeAt(index: number) {
113 return this.ranges[index]!;
114 }
115 addRange(range: TestRange) {
116 this.ranges.push(range);
117 }
118 removeRange(range: TestRange) {
119 this.ranges = this.ranges.filter((current) => current !== range);
120 }
121 removeAllRanges() {
122 this.ranges = [];
123 }
124}
125
126function createSource({ reducedMotion = false } = {}) {
127 vi.stubGlobal("window", { matchMedia: vi.fn(() => ({ matches: reducedMotion })) });
128 let now = 0;
129 vi.stubGlobal("performance", { now: () => now });
130 const frames = new Map<number, FrameRequestCallback>();
131 let nextFrame = 0;
132 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
133 frames.set(++nextFrame, callback);
134 return nextFrame;
135 });
136 vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame));
137 const flushFrame = () => {
138 const callbacks = [...frames.values()];
139 frames.clear();
140 for (const callback of callbacks) callback(0);
141 };
142 const mutationCallbacks = new Set<MutationCallback>();
143 let resize: ResizeObserverCallback = () => {};
144 const resizeEntries: ResizeObserverEntry[] = [];
145 vi.stubGlobal(
146 "MutationObserver",
147 class {
148 constructor(readonly callback: MutationCallback) {}
149 observe() {
150 mutationCallbacks.add(this.callback);
151 }
152 disconnect() {
153 mutationCallbacks.delete(this.callback);
154 }
155 },
156 );
157 vi.stubGlobal(
158 "ResizeObserver",
159 class {
160 constructor(callback: ResizeObserverCallback) {
161 resize = callback;
162 }
163 observe(target: Element) {
164 resizeEntries.push({ target } as ResizeObserverEntry);
165 }
166 disconnect() {
167 resize = () => {};
168 }
169 },
170 );
171 vi.stubGlobal("HTMLElement", TestElement);
172 const highlights = new Map<string, Set<TestRange>>();
173 vi.stubGlobal(
174 "Highlight",
175 class extends Set<TestRange> {
176 constructor(...ranges: TestRange[]) {
177 super(ranges);
178 }
179 },
180 );
181 vi.stubGlobal("CSS", { highlights });
182
183 const scrollNode = new TestElement();
184 const container = new TestElement();
185 container.parentNode = scrollNode;
186 const root = new TestElement();
187 root.parentNode = container;
188 const text = new TestElement();
189 text.parentNode = root;
190 let sourceTop = 200;
191 let measured = true;
192 let rangeHeight = 20;
193 root.getBoundingClientRect = () => rect(sourceTop - scrollNode.scrollTop, 300);
194 mocks.resolveRange.mockImplementation(
195 () => new TestRange(text, () => rect(sourceTop - scrollNode.scrollTop, rangeHeight)),
196 );
197 const listeners = new Set<() => void>();
198 const listen = (callback: () => void) => {
199 listeners.add(callback);
200 return () => listeners.delete(callback);
201 };
202 const layoutChanged = () => {
203 for (const listener of listeners) listener();
204 };
205 let pending: { options: { offset: number }; resolve: () => void } | null = null;
206 const scrollToOffset = vi.fn((options: { offset: number; animated?: boolean }) => {
207 pending?.resolve();
208 return new Promise<void>((resolve) => {
209 pending = { options, resolve };
210 });
211 });
212 const finishScroll = async () => {
213 const scroll = pending;
214 if (!scroll) throw new Error("No citation scroll is pending");
215 pending = null;
216 scrollNode.scrollTop = scroll.options.offset;
217 scroll.resolve();
218 await Promise.resolve();
219 flushFrame();
220 };
221 const list = {
222 getScrollableNode: () => scrollNode,
223 getState: () => ({
224 scroll: scrollNode.scrollTop,
225 indexByKey: () => 1,
226 sizeAtIndex: () => (measured ? 300 : undefined),
227 listenToPosition: (_key: string, callback: () => void) => listen(callback),
228 listen: (_signal: string, callback: () => void) => listen(callback),
229 }),
230 scrollToOffset,
231 } as unknown as LegendListRef;
232 const target: AssistantCitationTarget = {
233 key: "activation-one",
234 citation: {
235 version: 1,
236 environmentId: EnvironmentId.make("environment"),
237 threadId: ThreadId.make("thread"),
238 messageId: MessageId.make("source"),
239 text: "quote",
240 start: 0,
241 end: 5,
242 prefix: "",
243 suffix: "",
244 },
245 activationRef: { current: { scrolled: false, dismissed: false } },
246 onComplete: vi.fn(),
247 };
248 const cleanups: Array<() => void> = [];
249 const mount = (request = target) => {
250 const cleanup = observeAssistantCitationSource({
251 root: root as unknown as HTMLElement,
252 itemKey: "source-row",
253 request,
254 list,
255 });
256 if (cleanup) cleanups.push(cleanup);
257 return cleanup;
258 };
259 const mountComment = (
260 range = new TestRange(text, () => rect(sourceTop - scrollNode.scrollTop, rangeHeight)),
261 ) => {
262 const onUnavailable = vi.fn();
263 const cleanup = observeAssistantCitationCommentSource({
264 anchor: {
265 source: root as unknown as HTMLElement,
266 range: range as unknown as Range,
267 viewport: scrollNode as unknown as HTMLElement,
268 },
269 citation: target.citation,
270 onUnavailable,
271 });
272 cleanups.push(cleanup);
273 return { range, onUnavailable, cleanup };
274 };
275 const mutation = (changed: TestElement, removed: TestElement[] = []) => {
276 for (const callback of mutationCallbacks) {
277 callback(
278 [{ target: changed, addedNodes: [], removedNodes: removed }] as unknown as MutationRecord[],
279 {} as MutationObserver,
280 );
281 }
282 };
283 return {
284 target,
285 root,
286 text,
287 container,
288 scrollNode,
289 highlights,
290 scrollToOffset,
291 mount,
292 mountComment,
293 flushFrame,
294 finishScroll,
295 layoutChanged,
296 mutation,
297 advanceTime: (milliseconds: number) => {
298 now += milliseconds;
299 },
300 finishPulse: async () => {
301 root.animations.at(-1)?.finish();
302 await Promise.resolve();
303 },
304 resize: () => resize(resizeEntries, {} as ResizeObserver),
305 setMeasured: (value: boolean) => {
306 measured = value;
307 },
308 setRangeHeight: (value: number) => {
309 rangeHeight = value;
310 },
311 setSourceTop: (value: number) => {
312 sourceTop = value;
313 },
314 highlight: () => highlights.get("t3-assistant-citation")?.values().next().value,
315 commentHighlight: () => highlights.get("t3-assistant-citation-comment"),
316 cleanup: () => {
317 for (const cleanup of cleanups) cleanup();
318 },
319 };
320}
321
322afterEach(() => {
323 vi.unstubAllGlobals();
324 vi.clearAllMocks();
325});
326
327describe("assistant citation source lifecycle", () => {
328 it("waits for a mounted measurement and rechecks geometry after the list settles", async () => {
329 const source = createSource();
330 source.setMeasured(false);
331 source.mount();
332 source.flushFrame();
333 expect(source.scrollToOffset).not.toHaveBeenCalled();
334
335 source.setMeasured(true);
336 source.setRangeHeight(0);
337 source.resize();
338 source.flushFrame();
339 expect(source.target.activationRef.current.scrolled).toBe(false);
340 expect(source.scrollToOffset).not.toHaveBeenCalled();
341
342 source.setRangeHeight(20);
343 source.layoutChanged();
344 source.flushFrame();
345 expect(source.scrollToOffset).toHaveBeenCalledExactlyOnceWith({ offset: 80, animated: true });
346 expect(source.highlight()).toBeUndefined();
347 expect(source.root.animations).toHaveLength(0);
348
349 source.scrollNode.scrollTop = 4200;
350 source.advanceTime(700);
351 source.setSourceTop(260);
352 source.layoutChanged();
353 source.flushFrame();
354 expect(source.scrollToOffset).toHaveBeenCalledTimes(1);
355 expect(source.highlight()).toBeUndefined();
356 expect(source.target.onComplete).not.toHaveBeenCalled();
357 await source.finishScroll();
358 expect(source.target.onComplete).not.toHaveBeenCalled();
359 expect(source.scrollToOffset).toHaveBeenLastCalledWith({ offset: 140, animated: true });
360 await source.finishScroll();
361 expect(source.target.onComplete).toHaveBeenCalledOnce();
362 expect(source.highlight()?.getBoundingClientRect().top).toBe(120);
363 expect(source.root.animations).toHaveLength(1);
364 expect(source.root.focus).not.toHaveBeenCalled();
365 source.cleanup();
366 expect(source.highlight()).toBeUndefined();
367 });
368
369 it("completes a measured citation at the clamped beginning of the list", async () => {
370 const source = createSource();
371 source.setSourceTop(40);
372 source.mount();
373 source.flushFrame();
374 await source.finishScroll();
375 expect(source.scrollNode.scrollTop).toBe(0);
376 expect(source.highlight()?.toString()).toBe("quote");
377 expect(source.target.onComplete).toHaveBeenCalledOnce();
378 source.cleanup();
379 });
380
381 it("repairs collapsed ranges after an ancestor move without following the user's scroll", async () => {
382 const source = createSource();
383 const unmount = source.mount();
384 source.flushFrame();
385 await source.finishScroll();
386 const originalRange = source.highlight()!;
387 source.scrollNode.scrollTop = 4000;
388 originalRange.collapseTo(source.scrollNode);
389 source.mutation(source.scrollNode, [source.container]);
390 expect(source.highlight()).toBeUndefined();
391 expect(source.root.dataset.citationHighlighted).toBeUndefined();
392 source.flushFrame();
393 expect(source.highlight()?.toString()).toBe("quote");
394 expect(source.root.contains(source.highlight()!.startContainer)).toBe(true);
395 expect(source.scrollNode.scrollTop).toBe(4000);
396 expect(source.root.animations).toHaveLength(1);
397
398 const resolutions = mocks.resolveRange.mock.calls.length;
399 source.mutation(new TestElement());
400 source.flushFrame();
401 expect(mocks.resolveRange).toHaveBeenCalledTimes(resolutions);
402
403 unmount?.();
404 source.advanceTime(700);
405 source.mount();
406 source.flushFrame();
407 expect(source.highlight()?.toString()).toBe("quote");
408 expect(source.scrollToOffset).toHaveBeenCalledTimes(1);
409 expect(source.target.onComplete).toHaveBeenCalledOnce();
410 expect(source.root.animations).toHaveLength(2);
411 expect(source.root.animations[1]?.currentTime).toBe(700);
412 source.root.animations[0]?.finish();
413 await Promise.resolve();
414 expect(source.highlight()?.toString()).toBe("quote");
415 await source.finishPulse();
416 expect(source.highlight()).toBeUndefined();
417 source.cleanup();
418 });
419
420 it("removes the quote after the pulse and replays it only for a fresh citation activation", async () => {
421 const source = createSource();
422 const unmount = source.mount();
423 source.flushFrame();
424 await source.finishScroll();
425 await source.finishPulse();
426 expect(source.highlight()).toBeUndefined();
427 expect(source.root.dataset.citationHighlighted).toBeUndefined();
428 source.layoutChanged();
429 source.flushFrame();
430 expect(source.mount()).toBeUndefined();
431 expect(source.root.animations).toHaveLength(1);
432
433 unmount?.();
434 source.mount({
435 ...source.target,
436 key: "activation-two",
437 activationRef: { current: { scrolled: false, dismissed: false } },
438 });
439 source.flushFrame();
440 expect(source.highlight()?.toString()).toBe("quote");
441 expect(source.root.animations).toHaveLength(2);
442 expect(source.root.animations[1]?.currentTime).toBe(0);
443 source.cleanup();
444 });
445
446 it("uses instant navigation and removes the temporary quote highlight for reduced motion", async () => {
447 const source = createSource({ reducedMotion: true });
448 source.mount();
449 source.flushFrame();
450 expect(source.scrollToOffset).toHaveBeenCalledWith({ offset: 80, animated: false });
451 expect(source.highlight()).toBeUndefined();
452 await source.finishScroll();
453 expect(source.target.activationRef.current.pulse?.reducedMotion).toBe(true);
454 expect(source.highlight()?.toString()).toBe("quote");
455 await source.finishPulse();
456 expect(source.highlight()).toBeUndefined();
457 expect(source.root.focus).not.toHaveBeenCalled();
458 source.cleanup();
459 });
460
461 it("does not revive a quote when remounting after its original pulse deadline", async () => {
462 const source = createSource();
463 const unmount = source.mount();
464 source.flushFrame();
465 await source.finishScroll();
466 unmount?.();
467 source.advanceTime(3100);
468 source.mount();
469 source.flushFrame();
470 expect(source.highlight()).toBeUndefined();
471 expect(source.root.animations).toHaveLength(1);
472 expect(source.target.activationRef.current.dismissed).toBe(true);
473 source.cleanup();
474 });
475
476 it("dismisses an active text pulse without reviving it on remount", async () => {
477 const source = createSource();
478 source.mount();
479 source.flushFrame();
480 await source.finishScroll();
481 source.target.activationRef.current.dismissed = true;
482 source.cleanup();
483 expect(source.root.animations[0]?.cancel).toHaveBeenCalledOnce();
484 expect(source.highlight()).toBeUndefined();
485 expect(source.mount()).toBeUndefined();
486 source.flushFrame();
487 expect(source.root.animations).toHaveLength(1);
488 });
489
490 it("does not highlight a parent when the saved quote no longer matches", async () => {
491 const source = createSource();
492 mocks.resolveRange.mockReturnValue(null);
493 source.mount();
494 source.flushFrame();
495 await source.finishScroll();
496 expect(source.highlight()).toBeUndefined();
497 expect(source.root.animations).toHaveLength(0);
498 expect(source.root.focus).not.toHaveBeenCalled();
499 expect(source.target.onComplete).toHaveBeenCalledOnce();
500 source.cleanup();
501 });
502
503 it("cancels deferred positioning without capturing a pre-gesture offset or reviving dismissal", async () => {
504 const source = createSource();
505 source.mount();
506 source.flushFrame();
507 expect(source.scrollToOffset).toHaveBeenCalledWith({ offset: 80, animated: true });
508 source.target.activationRef.current.dismissed = true;
509 source.target.activationRef.current.cancelScroll?.();
510 source.scrollNode.scrollTop = 4300;
511 expect(source.scrollToOffset).toHaveBeenLastCalledWith({ offset: 4300, animated: false });
512 await source.finishScroll();
513 source.layoutChanged();
514 source.flushFrame();
515 expect(source.scrollNode.scrollTop).toBe(4300);
516 expect(source.target.activationRef.current.scrolled).toBe(false);
517 expect(source.target.onComplete).not.toHaveBeenCalled();
518 expect(source.highlight()).toBeUndefined();
519 expect(source.root.animations).toHaveLength(0);
520 source.cleanup();
521 expect(source.mount()).toBeUndefined();
522 source.flushFrame();
523 expect(source.scrollToOffset).toHaveBeenCalledTimes(2);
524 });
525
526 it("does not erase another highlight when its source unmounts", async () => {
527 const source = createSource();
528 source.mount();
529 source.flushFrame();
530 await source.finishScroll();
531 const replacement = new Set<TestRange>();
532 source.highlights.set("t3-assistant-citation", replacement);
533 source.cleanup();
534 expect(source.highlights.get("t3-assistant-citation")).toBe(replacement);
535 });
536
537 it("preserves a user's replacement selection in the native-selection fallback", async () => {
538 const source = createSource();
539 vi.stubGlobal("Highlight", undefined);
540 const selection = new TestSelection();
541 source.root.ownerDocument.getSelection = () => selection;
542 source.mount();
543 source.flushFrame();
544 await source.finishScroll();
545 expect(selection.ranges).toHaveLength(1);
546 expect(selection.ranges[0]?.startContainer).toBe(source.text);
547
548 const userRange = new TestRange(new TestElement(), () => rect(50));
549 selection.ranges = [userRange];
550 source.mutation(source.root);
551 source.flushFrame();
552 source.cleanup();
553 source.mount();
554 source.flushFrame();
555 expect(selection.ranges).toEqual([userRange]);
556 expect(source.scrollToOffset).toHaveBeenCalledTimes(1);
557 source.cleanup();
558 });
559
560 it("clears its own native-selection fallback when the pulse expires", async () => {
561 const source = createSource();
562 vi.stubGlobal("Highlight", undefined);
563 const selection = new TestSelection();
564 source.root.ownerDocument.getSelection = () => selection;
565 source.mount();
566 source.flushFrame();
567 await source.finishScroll();
568 expect(selection.ranges).toHaveLength(1);
569 await source.finishPulse();
570 expect(selection.ranges).toHaveLength(0);
571 source.cleanup();
572 });
573});
574
575describe("assistant citation comment source lifecycle", () => {
576 it("keeps the exact live range highlighted until cleanup without changing selection or navigation", () => {
577 const source = createSource();
578 const selection = new TestSelection();
579 const selectedRange = new TestRange(source.text, () => rect(40));
580 selection.addRange(selectedRange);
581 const getSelection = vi.fn(() => selection);
582 source.root.ownerDocument.getSelection = getSelection;
583 const navigationHighlight = new Set([new TestRange(source.text, () => rect(20))]);
584 source.highlights.set("t3-assistant-citation", navigationHighlight);
585 const comment = source.mountComment();
586
587 expect(source.commentHighlight()?.size).toBe(1);
588 expect(source.commentHighlight()?.values().next().value).toBe(comment.range);
589 expect(mocks.resolveRange).not.toHaveBeenCalled();
590 source.advanceTime(5000);
591 source.flushFrame();
592 expect(source.commentHighlight()?.has(comment.range)).toBe(true);
593 expect(source.root.animations).toHaveLength(0);
594 expect(source.scrollToOffset).not.toHaveBeenCalled();
595 expect(getSelection).not.toHaveBeenCalled();
596
597 comment.cleanup();
598 comment.cleanup();
599 expect(source.commentHighlight()).toBeUndefined();
600 expect(source.highlights.get("t3-assistant-citation")).toBe(navigationHighlight);
601 expect(selection.ranges).toEqual([selectedRange]);
602 expect(comment.onUnavailable).not.toHaveBeenCalled();
603 source.mutation(source.root);
604 expect(mocks.resolveRange).not.toHaveBeenCalled();
605 });
606
607 it("repairs changed source text in the same Range used by the popup anchor", () => {
608 const source = createSource();
609 const comment = source.mountComment();
610 const replacementText = new TestElement();
611 replacementText.parentNode = source.root;
612 const repaired = new TestRange(replacementText, () => rect(70));
613 repaired.startOffset = 7;
614 repaired.endOffset = 12;
615 mocks.resolveRange.mockReturnValue(repaired);
616
617 source.mutation(source.root);
618
619 expect(mocks.resolveRange).toHaveBeenCalledOnce();
620 expect(comment.range.startContainer).toBe(replacementText);
621 expect(comment.range.endContainer).toBe(replacementText);
622 expect(comment.range.startOffset).toBe(7);
623 expect(comment.range.endOffset).toBe(12);
624 expect(comment.range.collapsed).toBe(false);
625 expect(source.commentHighlight()?.size).toBe(1);
626 expect(source.commentHighlight()?.values().next().value).toBe(comment.range);
627 expect(source.commentHighlight()?.has(repaired)).toBe(false);
628 expect(comment.onUnavailable).not.toHaveBeenCalled();
629 source.cleanup();
630 });
631
632 it.each(["collapsed", "escaped"])("repairs a %s range after a virtual ancestor move", (state) => {
633 const source = createSource();
634 const comment = source.mountComment();
635 if (state === "collapsed") {
636 comment.range.collapseTo(source.scrollNode);
637 } else {
638 comment.range.startContainer = new TestElement();
639 }
640
641 source.mutation(source.scrollNode, [source.container]);
642
643 expect(comment.range.startContainer).toBe(source.text);
644 expect(comment.range.endContainer).toBe(source.text);
645 expect(comment.range.collapsed).toBe(false);
646 expect(source.commentHighlight()?.has(comment.range)).toBe(true);
647 expect(comment.onUnavailable).not.toHaveBeenCalled();
648 source.cleanup();
649 });
650
651 it("clears and disconnects once when the selected quote can no longer be repaired", () => {
652 const source = createSource();
653 const comment = source.mountComment();
654 mocks.resolveRange.mockReturnValue(null);
655
656 source.mutation(source.root);
657
658 expect(comment.onUnavailable).toHaveBeenCalledOnce();
659 expect(source.commentHighlight()).toBeUndefined();
660 source.mutation(source.root);
661 comment.cleanup();
662 expect(mocks.resolveRange).toHaveBeenCalledOnce();
663 expect(comment.onUnavailable).toHaveBeenCalledOnce();
664 });
665
666 it.each(["disconnected", "outside viewport"])("closes once when the source is %s", (state) => {
667 const source = createSource();
668 const comment = source.mountComment();
669 if (state === "disconnected") {
670 source.root.isConnected = false;
671 } else {
672 source.root.parentNode = new TestElement();
673 }
674
675 source.mutation(source.scrollNode, [source.container]);
676 source.mutation(source.scrollNode, [source.container]);
677 comment.cleanup();
678
679 expect(comment.onUnavailable).toHaveBeenCalledOnce();
680 expect(source.commentHighlight()).toBeUndefined();
681 expect(mocks.resolveRange).not.toHaveBeenCalled();
682 });
683
684 it("rejects an already detached source without registering a highlight", () => {
685 const source = createSource();
686 source.root.isConnected = false;
687 const comment = source.mountComment();
688
689 expect(comment.onUnavailable).toHaveBeenCalledOnce();
690 expect(source.commentHighlight()).toBeUndefined();
691 source.mutation(source.root);
692 comment.cleanup();
693 expect(comment.onUnavailable).toHaveBeenCalledOnce();
694 expect(mocks.resolveRange).not.toHaveBeenCalled();
695 });
696
697 it("keeps distinct editors' ranges independent through one shared highlight", () => {
698 const source = createSource();
699 const first = source.mountComment();
700 const second = source.mountComment();
701 expect(source.commentHighlight()?.size).toBe(2);
702
703 first.cleanup();
704 first.cleanup();
705
706 expect(source.commentHighlight()?.size).toBe(1);
707 expect(source.commentHighlight()?.has(second.range)).toBe(true);
708 expect(second.onUnavailable).not.toHaveBeenCalled();
709 second.cleanup();
710 expect(source.commentHighlight()).toBeUndefined();
711 });
712
713 it("does not delete a replacement highlight when an older editor closes", () => {
714 const source = createSource();
715 const comment = source.mountComment();
716 const original = source.commentHighlight();
717 const replacement = new Set([new TestRange(source.text, () => rect(90))]);
718 source.highlights.set("t3-assistant-citation-comment", replacement);
719
720 comment.cleanup();
721
722 expect(original?.size).toBe(0);
723 expect(source.commentHighlight()).toBe(replacement);
724 expect(replacement.size).toBe(1);
725 });
726
727 it("keeps native selection untouched when CSS highlights are unavailable", () => {
728 const source = createSource();
729 vi.stubGlobal("Highlight", undefined);
730 const selection = new TestSelection();
731 const selectedRange = new TestRange(source.text, () => rect(40));
732 selection.addRange(selectedRange);
733 const getSelection = vi.fn(() => selection);
734 source.root.ownerDocument.getSelection = getSelection;
735 const comment = source.mountComment();
736 source.mutation(source.root);
737
738 expect(source.commentHighlight()).toBeUndefined();
739 expect(getSelection).not.toHaveBeenCalled();
740 expect(selection.ranges).toEqual([selectedRange]);
741 expect(comment.onUnavailable).not.toHaveBeenCalled();
742 source.root.isConnected = false;
743 source.mutation(source.scrollNode, [source.container]);
744 expect(comment.onUnavailable).toHaveBeenCalledOnce();
745 source.cleanup();
746 });
747});