summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/tests/attachment-tokens.test.ts
blob: 7208cf32b7eef0174aca17d0019f9364a87a2c51 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
import { describe, expect, it } from "vitest";
import {
	computeTokenDeletion,
	findTokens,
	generateTokenId,
	intactTokenIds,
	makeAttachmentToken,
	markerFor,
	parseDraft,
	type StagedAttachment,
} from "../src/lib/attachment-tokens.js";

function img(id: string): StagedAttachment {
	return { id, kind: "image", mediaType: "image/png", data: "QQ==" };
}
function pdf(id: string): StagedAttachment {
	return { id, kind: "pdf", mediaType: "application/pdf", data: "QQ==", name: "doc.pdf" };
}

describe("token helpers", () => {
	it("round-trips make/find", () => {
		const tok = makeAttachmentToken("image", "abc123");
		expect(tok).toBe("【image:abc123】");
		const found = findTokens(`x ${tok} y`);
		expect(found).toHaveLength(1);
		expect(found[0]).toMatchObject({ id: "abc123", kind: "image", start: 2, end: 2 + tok.length });
	});

	it("generates 6-char lowercase-alnum ids", () => {
		for (let i = 0; i < 20; i++) {
			expect(generateTokenId()).toMatch(/^[a-z0-9]{6}$/);
		}
	});

	it("finds multiple tokens in order and reports intact ids", () => {
		const text = `a ${makeAttachmentToken("image", "aaaaaa")} b ${makeAttachmentToken("pdf", "bbbbbb")}`;
		const found = findTokens(text);
		expect(found.map((t) => t.id)).toEqual(["aaaaaa", "bbbbbb"]);
		expect(intactTokenIds(text)).toEqual(new Set(["aaaaaa", "bbbbbb"]));
	});

	it("does not treat a partially-broken token as intact", () => {
		// Missing closing bracket → not a valid token.
		expect(intactTokenIds("【image:aaaaaa").size).toBe(0);
	});
});

describe("computeTokenDeletion", () => {
	const tok = makeAttachmentToken("image", "abcabc");
	const text = `hi ${tok}!`; // token spans indices 3..3+len
	const tokStart = 3;
	const tokEnd = 3 + tok.length;

	it("returns null when no tokens exist", () => {
		expect(computeTokenDeletion("plain", 2, 2, "Backspace")).toBeNull();
	});

	it("Backspace just after a token removes the whole token atomically", () => {
		const res = computeTokenDeletion(text, tokEnd, tokEnd, "Backspace");
		expect(res).not.toBeNull();
		expect(res?.text).toBe("hi !");
		expect(res?.caret).toBe(tokStart);
		expect(res?.removedIds).toEqual(["abcabc"]);
	});

	it("Delete just before a token removes the whole token atomically", () => {
		const res = computeTokenDeletion(text, tokStart, tokStart, "Delete");
		expect(res?.text).toBe("hi !");
		expect(res?.caret).toBe(tokStart);
		expect(res?.removedIds).toEqual(["abcabc"]);
	});

	it("Backspace NOT adjacent to a token returns null (default editing)", () => {
		// Caret at index 2 (after "hi"), token is further along.
		expect(computeTokenDeletion(text, 2, 2, "Backspace")).toBeNull();
	});

	it("a selection overlapping a token expands to cover the whole token", () => {
		// Select from inside "hi " through the middle of the token.
		const res = computeTokenDeletion(text, 1, tokStart + 3, "Backspace");
		expect(res).not.toBeNull();
		// Deletion starts at min(selStart, tokStart)=1 and ends at tokEnd.
		expect(res?.text).toBe("h!");
		expect(res?.removedIds).toEqual(["abcabc"]);
	});

	it("a range selection touching no token returns null", () => {
		expect(computeTokenDeletion(text, 0, 2, "Backspace")).toBeNull();
	});
});

describe("parseDraft", () => {
	it("returns plain text + null content when there are no attachments", () => {
		const res = parseDraft("just text", new Map());
		expect(res.displayText).toBe("just text");
		expect(res.content).toBeNull();
	});

	it("interleaves text and attachment parts in order", () => {
		const a = img("aaaaaa");
		const b = pdf("bbbbbb");
		const map = new Map([
			[a.id, a],
			[b.id, b],
		]);
		const draft = `A: ${makeAttachmentToken("image", a.id)} B: ${makeAttachmentToken("pdf", b.id)} end`;
		const res = parseDraft(draft, map);

		// displayText swaps tokens for markers.
		expect(res.displayText).toBe(`A: ${markerFor("image")} B: ${markerFor("pdf")} end`);

		// content interleaves the surrounding text with the attachment parts.
		expect(res.content).toEqual([
			{ type: "text", text: "A: " },
			{ type: "attachment", mediaType: "image/png", data: "QQ==" },
			{ type: "text", text: " B: " },
			{ type: "attachment", mediaType: "application/pdf", data: "QQ==", name: "doc.pdf" },
			{ type: "text", text: " end" },
		]);
	});

	it("treats an orphan token (no staged attachment) as plain text", () => {
		// Token present in text but not in the attachments map.
		const draft = `x ${makeAttachmentToken("image", "zzzzzz")} y`;
		const res = parseDraft(draft, new Map());
		expect(res.displayText).toBe(`x ${markerFor("image")} y`);
		// No real attachment → null content (plain-text send).
		expect(res.content).toBeNull();
	});
});