1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 COMPOSER_MENTION_DRAG_TYPE,
5 type ComposerMentionDropHost,
6 composerMentionFromTreePath,
7 dataTransferHasComposerMention,
8 makeComposerMentionDragHandlers,
9} from "./composerMentionDrag.ts";
10
11const makeDragEvent = (options?: { mention?: string; types?: ReadonlyArray<string> }) => {
12 const mention = options?.mention ?? "[index.md](docs/index.md)";
13 const calls: Array<string> = [];
14 const event = {
15 dataTransfer: {
16 types: options?.types ?? [COMPOSER_MENTION_DRAG_TYPE, "text/plain"],
17 getData: (format: string) => (format === COMPOSER_MENTION_DRAG_TYPE ? mention : ""),
18 dropEffect: "none",
19 },
20 nativeEvent: {
21 stopPropagation: () => void calls.push("nativeStopPropagation"),
22 },
23 preventDefault: () => void calls.push("preventDefault"),
24 stopPropagation: () => void calls.push("stopPropagation"),
25 };
26 return { event, calls };
27};
28
29const makeHost = (insertResult = true) => {
30 const log: Array<string> = [];
31 const host: ComposerMentionDropHost = {
32 insertMentionAtEnd: (text) => {
33 log.push(`insert:${text}`);
34 return insertResult;
35 },
36 setDragActive: (active) => void log.push(`active:${active}`),
37 onInsertRejected: () => void log.push("rejected"),
38 };
39 return { host, log };
40};
41
42describe("composerMentionFromTreePath", () => {
43 it("serializes a file path into a mention", () => {
44 expect(composerMentionFromTreePath("docs/index.md")).toBe("[index.md](docs/index.md)");
45 });
46
47 it("strips the trailing slash directory rows carry", () => {
48 expect(composerMentionFromTreePath("docs/architecture/")).toBe(
49 "[architecture](docs/architecture)",
50 );
51 });
52
53 it("rejects drags that carry no path", () => {
54 expect(composerMentionFromTreePath("")).toBeNull();
55 expect(composerMentionFromTreePath("/")).toBeNull();
56 });
57});
58
59describe("dataTransferHasComposerMention", () => {
60 it("detects the mention payload among drag types", () => {
61 expect(dataTransferHasComposerMention([COMPOSER_MENTION_DRAG_TYPE, "text/plain"])).toBe(true);
62 expect(dataTransferHasComposerMention(["Files"])).toBe(false);
63 expect(dataTransferHasComposerMention([])).toBe(false);
64 });
65});
66
67describe("makeComposerMentionDragHandlers", () => {
68 it("leaves drags without the mention payload alone", () => {
69 const { host, log } = makeHost();
70 const handlers = makeComposerMentionDragHandlers(host);
71 const { event, calls } = makeDragEvent({ types: ["Files"] });
72 handlers.onDragEnter(event);
73 handlers.onDragOver(event);
74 handlers.onDrop(event);
75 expect(calls).toEqual([]);
76 expect(log).toEqual([]);
77 });
78
79 it("stops the native event too, not just the synthetic one", () => {
80 // React's stopPropagation only halts synthetic dispatch; without the
81 // native stop, the editor's own DOM listeners process the drop and sync
82 // their stale state back over the inserted mention.
83 const { host } = makeHost();
84 const handlers = makeComposerMentionDragHandlers(host);
85 const { event, calls } = makeDragEvent();
86 handlers.onDrop(event);
87 expect(calls).toContain("preventDefault");
88 expect(calls).toContain("stopPropagation");
89 expect(calls).toContain("nativeStopPropagation");
90 });
91
92 it('answers dragover with the "move" effect the tree allows', () => {
93 // Naming an effect outside the source's effectAllowed makes the browser
94 // cancel the drop without ever firing it.
95 const { host } = makeHost();
96 const handlers = makeComposerMentionDragHandlers(host);
97 const { event } = makeDragEvent();
98 handlers.onDragOver(event);
99 expect(event.dataTransfer.dropEffect).toBe("move");
100 });
101
102 it("inserts the mention with its trailing space and clears the highlight", () => {
103 const { host, log } = makeHost();
104 const handlers = makeComposerMentionDragHandlers(host);
105 handlers.onDragEnter(makeDragEvent().event);
106 handlers.onDrop(makeDragEvent().event);
107 expect(log).toEqual(["active:true", "active:false", "insert:[index.md](docs/index.md) "]);
108 });
109
110 it("reports a rejected insert instead of failing silently", () => {
111 const { host, log } = makeHost(false);
112 const handlers = makeComposerMentionDragHandlers(host);
113 handlers.onDrop(makeDragEvent().event);
114 expect(log).toContain("rejected");
115 });
116
117 it("ignores a drop whose payload is empty", () => {
118 const { host, log } = makeHost();
119 const handlers = makeComposerMentionDragHandlers(host);
120 handlers.onDrop(makeDragEvent({ mention: "" }).event);
121 expect(log).toEqual(["active:false"]);
122 });
123});