@llblab/pi-telegram 0.2.8 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +4 -0
- package/README.md +6 -6
- package/docs/architecture.md +11 -5
- package/index.ts +13 -10
- package/lib/preview.ts +212 -0
- package/lib/rendering.ts +583 -46
- package/lib/replies.ts +2 -181
- package/package.json +1 -1
- package/tests/menu.test.ts +46 -15
- package/tests/preview.test.ts +441 -0
- package/tests/queue.test.ts +3 -0
- package/tests/rendering.test.ts +124 -2
- package/tests/replies.test.ts +2 -222
- package/tests/updates.test.ts +10 -4
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for the Telegram preview domain
|
|
3
|
+
* Covers preview snapshot decisions, transport selection, runtime flushing, and finalization behavior
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
buildTelegramPreviewFlushText,
|
|
11
|
+
buildTelegramPreviewSnapshot,
|
|
12
|
+
renderTelegramMessage,
|
|
13
|
+
type TelegramRenderedChunk,
|
|
14
|
+
type TelegramRenderMode,
|
|
15
|
+
} from "../lib/rendering.ts";
|
|
16
|
+
import {
|
|
17
|
+
buildTelegramPreviewFinalText,
|
|
18
|
+
clearTelegramPreview,
|
|
19
|
+
finalizeTelegramMarkdownPreview,
|
|
20
|
+
finalizeTelegramPreview,
|
|
21
|
+
flushTelegramPreview,
|
|
22
|
+
shouldUseTelegramDraftPreview,
|
|
23
|
+
} from "../lib/preview.ts";
|
|
24
|
+
|
|
25
|
+
function createPreviewRuntimeHarness(state?: {
|
|
26
|
+
mode: "draft" | "message";
|
|
27
|
+
draftId?: number;
|
|
28
|
+
messageId?: number;
|
|
29
|
+
pendingText: string;
|
|
30
|
+
lastSentText: string;
|
|
31
|
+
lastSentParseMode?: "HTML";
|
|
32
|
+
lastSentStrategy?: "plain" | "rich-stable-blocks";
|
|
33
|
+
flushTimer?: ReturnType<typeof setTimeout>;
|
|
34
|
+
}) {
|
|
35
|
+
let previewState = state;
|
|
36
|
+
let draftSupport: "unknown" | "supported" | "unsupported" = "unknown";
|
|
37
|
+
let nextDraftId = 10;
|
|
38
|
+
const events: string[] = [];
|
|
39
|
+
return {
|
|
40
|
+
events,
|
|
41
|
+
getState: () => previewState,
|
|
42
|
+
getDraftSupport: () => draftSupport,
|
|
43
|
+
setDraftSupport: (support: "unknown" | "supported" | "unsupported") => {
|
|
44
|
+
draftSupport = support;
|
|
45
|
+
},
|
|
46
|
+
deps: {
|
|
47
|
+
getState: () => previewState,
|
|
48
|
+
setState: (nextState: typeof previewState) => {
|
|
49
|
+
previewState = nextState;
|
|
50
|
+
},
|
|
51
|
+
clearScheduledFlush: (nextState: NonNullable<typeof previewState>) => {
|
|
52
|
+
if (!nextState.flushTimer) return;
|
|
53
|
+
clearTimeout(nextState.flushTimer);
|
|
54
|
+
nextState.flushTimer = undefined;
|
|
55
|
+
events.push("clear-timer");
|
|
56
|
+
},
|
|
57
|
+
maxMessageLength: 50,
|
|
58
|
+
renderPreviewText: (markdown: string) => markdown.replaceAll("*", ""),
|
|
59
|
+
getDraftSupport: () => draftSupport,
|
|
60
|
+
setDraftSupport: (support: "unknown" | "supported" | "unsupported") => {
|
|
61
|
+
draftSupport = support;
|
|
62
|
+
},
|
|
63
|
+
allocateDraftId: () => nextDraftId++,
|
|
64
|
+
sendDraft: async (chatId: number, draftId: number, text: string) => {
|
|
65
|
+
events.push(`draft:${chatId}:${draftId}:${text}`);
|
|
66
|
+
},
|
|
67
|
+
sendMessage: async (
|
|
68
|
+
chatId: number,
|
|
69
|
+
text: string,
|
|
70
|
+
options?: { parseMode?: "HTML" },
|
|
71
|
+
) => {
|
|
72
|
+
events.push(`send:${chatId}:${text}:${options?.parseMode ?? "plain"}`);
|
|
73
|
+
return { message_id: 77 };
|
|
74
|
+
},
|
|
75
|
+
editMessageText: async (
|
|
76
|
+
chatId: number,
|
|
77
|
+
messageId: number,
|
|
78
|
+
text: string,
|
|
79
|
+
options?: { parseMode?: "HTML" },
|
|
80
|
+
) => {
|
|
81
|
+
events.push(
|
|
82
|
+
`edit:${chatId}:${messageId}:${text}:${options?.parseMode ?? "plain"}`,
|
|
83
|
+
);
|
|
84
|
+
},
|
|
85
|
+
renderTelegramMessage: (
|
|
86
|
+
text: string,
|
|
87
|
+
options?: { mode?: TelegramRenderMode },
|
|
88
|
+
): TelegramRenderedChunk[] =>
|
|
89
|
+
options?.mode === "markdown"
|
|
90
|
+
? [{ text: `markdown:${text}`, parseMode: "HTML" as const }]
|
|
91
|
+
: [{ text: `${options?.mode ?? "plain"}:${text}` }],
|
|
92
|
+
sendRenderedChunks: async (
|
|
93
|
+
chatId: number,
|
|
94
|
+
chunks: Array<{ text: string }>,
|
|
95
|
+
) => {
|
|
96
|
+
events.push(
|
|
97
|
+
`render-send:${chatId}:${chunks.map((chunk) => chunk.text).join("|")}`,
|
|
98
|
+
);
|
|
99
|
+
return 88;
|
|
100
|
+
},
|
|
101
|
+
editRenderedMessage: async (
|
|
102
|
+
chatId: number,
|
|
103
|
+
messageId: number,
|
|
104
|
+
chunks: Array<{ text: string }>,
|
|
105
|
+
) => {
|
|
106
|
+
events.push(
|
|
107
|
+
`render-edit:${chatId}:${messageId}:${chunks.map((chunk) => chunk.text).join("|")}`,
|
|
108
|
+
);
|
|
109
|
+
return messageId;
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
test("Preview helpers build flush text only when the preview changed", () => {
|
|
116
|
+
assert.equal(
|
|
117
|
+
buildTelegramPreviewFlushText({
|
|
118
|
+
state: {
|
|
119
|
+
pendingText: "**hello**",
|
|
120
|
+
lastSentText: "",
|
|
121
|
+
},
|
|
122
|
+
maxMessageLength: 4096,
|
|
123
|
+
renderPreviewText: (markdown) => markdown.replaceAll("*", ""),
|
|
124
|
+
}),
|
|
125
|
+
"hello",
|
|
126
|
+
);
|
|
127
|
+
assert.equal(
|
|
128
|
+
buildTelegramPreviewFlushText({
|
|
129
|
+
state: {
|
|
130
|
+
pendingText: "**hello**",
|
|
131
|
+
lastSentText: "hello",
|
|
132
|
+
},
|
|
133
|
+
maxMessageLength: 4096,
|
|
134
|
+
renderPreviewText: (markdown) => markdown.replaceAll("*", ""),
|
|
135
|
+
}),
|
|
136
|
+
undefined,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("Preview snapshots prefer stable rich blocks and fall back to plain text", () => {
|
|
141
|
+
const richSnapshot = buildTelegramPreviewSnapshot({
|
|
142
|
+
state: {
|
|
143
|
+
pendingText: "```ts\nconst value = 1\n```",
|
|
144
|
+
lastSentText: "",
|
|
145
|
+
},
|
|
146
|
+
maxMessageLength: 100,
|
|
147
|
+
renderPreviewText: (markdown) => markdown.replaceAll("*", ""),
|
|
148
|
+
renderTelegramMessage: (text, options) =>
|
|
149
|
+
options?.mode === "markdown"
|
|
150
|
+
? [{ text: `<b>${text}</b>`, parseMode: "HTML" as const }]
|
|
151
|
+
: [{ text }],
|
|
152
|
+
});
|
|
153
|
+
assert.deepEqual(richSnapshot, {
|
|
154
|
+
text: "<b>```ts\nconst value = 1\n```</b>",
|
|
155
|
+
parseMode: "HTML",
|
|
156
|
+
sourceText: "```ts\nconst value = 1\n```",
|
|
157
|
+
strategy: "rich-stable-blocks",
|
|
158
|
+
});
|
|
159
|
+
const plainSnapshot = buildTelegramPreviewSnapshot({
|
|
160
|
+
state: {
|
|
161
|
+
pendingText: "**hello**",
|
|
162
|
+
lastSentText: "",
|
|
163
|
+
},
|
|
164
|
+
maxMessageLength: 5,
|
|
165
|
+
renderPreviewText: (markdown) => markdown.replaceAll("*", ""),
|
|
166
|
+
renderTelegramMessage: () => [
|
|
167
|
+
{ text: "markdown:too-long", parseMode: "HTML" as const },
|
|
168
|
+
],
|
|
169
|
+
});
|
|
170
|
+
assert.deepEqual(plainSnapshot, {
|
|
171
|
+
text: "hello",
|
|
172
|
+
sourceText: "**hello**",
|
|
173
|
+
strategy: "plain",
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("Preview snapshots append conservative plain tails for incomplete fences, quotes, and lists", () => {
|
|
178
|
+
const renderTelegramMessage = (
|
|
179
|
+
text: string,
|
|
180
|
+
options?: { mode?: TelegramRenderMode },
|
|
181
|
+
) =>
|
|
182
|
+
options?.mode === "markdown"
|
|
183
|
+
? [{ text: `<b>${text}</b>`, parseMode: "HTML" as const }]
|
|
184
|
+
: [{ text }];
|
|
185
|
+
const fenceSnapshot = buildTelegramPreviewSnapshot({
|
|
186
|
+
state: {
|
|
187
|
+
pendingText: "## Intro\n\n```ts\nconst value = 1",
|
|
188
|
+
lastSentText: "",
|
|
189
|
+
},
|
|
190
|
+
maxMessageLength: 200,
|
|
191
|
+
renderPreviewText: (markdown) => markdown,
|
|
192
|
+
renderTelegramMessage,
|
|
193
|
+
});
|
|
194
|
+
assert.deepEqual(fenceSnapshot, {
|
|
195
|
+
text: "<b>## Intro</b>\n\n```ts\nconst value = 1",
|
|
196
|
+
parseMode: "HTML",
|
|
197
|
+
sourceText: "## Intro\n\n```ts\nconst value = 1",
|
|
198
|
+
strategy: "rich-stable-blocks",
|
|
199
|
+
});
|
|
200
|
+
const quoteSnapshot = buildTelegramPreviewSnapshot({
|
|
201
|
+
state: {
|
|
202
|
+
pendingText: "## Intro\n\n\n> quoted line",
|
|
203
|
+
lastSentText: "",
|
|
204
|
+
},
|
|
205
|
+
maxMessageLength: 200,
|
|
206
|
+
renderPreviewText: (markdown) => markdown,
|
|
207
|
+
renderTelegramMessage,
|
|
208
|
+
});
|
|
209
|
+
assert.deepEqual(quoteSnapshot, {
|
|
210
|
+
text: "<b>## Intro</b>\n\n\n> quoted line",
|
|
211
|
+
parseMode: "HTML",
|
|
212
|
+
sourceText: "## Intro\n\n\n> quoted line",
|
|
213
|
+
strategy: "rich-stable-blocks",
|
|
214
|
+
});
|
|
215
|
+
const listSnapshot = buildTelegramPreviewSnapshot({
|
|
216
|
+
state: {
|
|
217
|
+
pendingText: "## Intro\n\n\n- first\n- second",
|
|
218
|
+
lastSentText: "",
|
|
219
|
+
},
|
|
220
|
+
maxMessageLength: 200,
|
|
221
|
+
renderPreviewText: (markdown) => markdown,
|
|
222
|
+
renderTelegramMessage,
|
|
223
|
+
});
|
|
224
|
+
assert.deepEqual(listSnapshot, {
|
|
225
|
+
text: "<b>## Intro</b>\n\n\n- first\n- second",
|
|
226
|
+
parseMode: "HTML",
|
|
227
|
+
sourceText: "## Intro\n\n\n- first\n- second",
|
|
228
|
+
strategy: "rich-stable-blocks",
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("Preview snapshots omit unstable tails when long-message limits leave no room", () => {
|
|
233
|
+
const snapshot = buildTelegramPreviewSnapshot({
|
|
234
|
+
state: {
|
|
235
|
+
pendingText: "## Intro\n\n- first\n- second\n- third",
|
|
236
|
+
lastSentText: "",
|
|
237
|
+
},
|
|
238
|
+
maxMessageLength: 18,
|
|
239
|
+
renderPreviewText: (markdown) => markdown,
|
|
240
|
+
renderTelegramMessage: (text, options) =>
|
|
241
|
+
options?.mode === "markdown"
|
|
242
|
+
? [{ text: `<b>${text}</b>`, parseMode: "HTML" as const }]
|
|
243
|
+
: [{ text }],
|
|
244
|
+
});
|
|
245
|
+
assert.deepEqual(snapshot, {
|
|
246
|
+
text: "<b>## Intro</b>",
|
|
247
|
+
parseMode: "HTML",
|
|
248
|
+
sourceText: "## Intro\n\n- first\n- second\n- third",
|
|
249
|
+
strategy: "rich-stable-blocks",
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("Preview helpers compute final text fallback without reusing rich HTML snapshots", () => {
|
|
254
|
+
assert.equal(
|
|
255
|
+
buildTelegramPreviewFinalText({
|
|
256
|
+
mode: "message",
|
|
257
|
+
pendingText: " ",
|
|
258
|
+
lastSentText: "saved",
|
|
259
|
+
lastSentStrategy: "plain",
|
|
260
|
+
}),
|
|
261
|
+
"saved",
|
|
262
|
+
);
|
|
263
|
+
assert.equal(
|
|
264
|
+
buildTelegramPreviewFinalText({
|
|
265
|
+
mode: "message",
|
|
266
|
+
pendingText: " ",
|
|
267
|
+
lastSentText: "<b>saved</b>",
|
|
268
|
+
lastSentParseMode: "HTML",
|
|
269
|
+
lastSentStrategy: "rich-stable-blocks",
|
|
270
|
+
}),
|
|
271
|
+
undefined,
|
|
272
|
+
);
|
|
273
|
+
assert.equal(
|
|
274
|
+
buildTelegramPreviewFinalText({
|
|
275
|
+
mode: "message",
|
|
276
|
+
pendingText: " ",
|
|
277
|
+
lastSentText: " ",
|
|
278
|
+
}),
|
|
279
|
+
undefined,
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("Preview helpers use drafts only for plain preview snapshots", () => {
|
|
284
|
+
assert.equal(
|
|
285
|
+
shouldUseTelegramDraftPreview({ draftSupport: "unknown" }),
|
|
286
|
+
true,
|
|
287
|
+
);
|
|
288
|
+
assert.equal(
|
|
289
|
+
shouldUseTelegramDraftPreview({
|
|
290
|
+
draftSupport: "supported",
|
|
291
|
+
snapshot: { text: "preview", sourceText: "preview", strategy: "plain" },
|
|
292
|
+
}),
|
|
293
|
+
true,
|
|
294
|
+
);
|
|
295
|
+
assert.equal(
|
|
296
|
+
shouldUseTelegramDraftPreview({
|
|
297
|
+
draftSupport: "supported",
|
|
298
|
+
snapshot: {
|
|
299
|
+
text: "<b>preview</b>",
|
|
300
|
+
parseMode: "HTML",
|
|
301
|
+
sourceText: "preview",
|
|
302
|
+
strategy: "rich-stable-blocks",
|
|
303
|
+
},
|
|
304
|
+
}),
|
|
305
|
+
false,
|
|
306
|
+
);
|
|
307
|
+
assert.equal(
|
|
308
|
+
shouldUseTelegramDraftPreview({ draftSupport: "unsupported" }),
|
|
309
|
+
false,
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("Preview runtime prefers editable rich previews when stable blocks are available", async () => {
|
|
314
|
+
const harness = createPreviewRuntimeHarness({
|
|
315
|
+
mode: "draft",
|
|
316
|
+
pendingText: "## Intro\n\nTail",
|
|
317
|
+
lastSentText: "",
|
|
318
|
+
flushTimer: setTimeout(() => {}, 1000),
|
|
319
|
+
});
|
|
320
|
+
await flushTelegramPreview(7, harness.deps);
|
|
321
|
+
assert.deepEqual(harness.events, ["send:7:markdown:## Intro\n\nTail:HTML"]);
|
|
322
|
+
assert.equal(harness.getState()?.mode, "message");
|
|
323
|
+
assert.equal(harness.getState()?.messageId, 77);
|
|
324
|
+
assert.equal(harness.getState()?.lastSentText, "markdown:## Intro\n\nTail");
|
|
325
|
+
assert.equal(harness.getState()?.lastSentParseMode, "HTML");
|
|
326
|
+
assert.equal(harness.getState()?.lastSentStrategy, "rich-stable-blocks");
|
|
327
|
+
assert.equal(harness.getDraftSupport(), "unknown");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("Preview runtime preserves original blank-line spacing around conservative tails", async () => {
|
|
331
|
+
const cases = [
|
|
332
|
+
{
|
|
333
|
+
markdown: "Para\n\n\n> Quote",
|
|
334
|
+
expectedEvent: "send:7:markdown:Para\n\n\n> Quote:HTML",
|
|
335
|
+
expectedText: "markdown:Para\n\n\n> Quote",
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
markdown: "Para\n\n\n- item",
|
|
339
|
+
expectedEvent: "send:7:markdown:Para\n\n\n- item:HTML",
|
|
340
|
+
expectedText: "markdown:Para\n\n\n- item",
|
|
341
|
+
},
|
|
342
|
+
];
|
|
343
|
+
for (const testCase of cases) {
|
|
344
|
+
const harness = createPreviewRuntimeHarness({
|
|
345
|
+
mode: "draft",
|
|
346
|
+
pendingText: testCase.markdown,
|
|
347
|
+
lastSentText: "",
|
|
348
|
+
flushTimer: setTimeout(() => {}, 1000),
|
|
349
|
+
});
|
|
350
|
+
await flushTelegramPreview(7, harness.deps);
|
|
351
|
+
assert.deepEqual(harness.events, [testCase.expectedEvent]);
|
|
352
|
+
assert.equal(harness.getState()?.lastSentText, testCase.expectedText);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("Preview runtime keeps heading-to-code spacing readable without source blank lines", async () => {
|
|
357
|
+
const harness = createPreviewRuntimeHarness({
|
|
358
|
+
mode: "draft",
|
|
359
|
+
pendingText: "### Title\n```ts\nconst x = 1\n```",
|
|
360
|
+
lastSentText: "",
|
|
361
|
+
flushTimer: setTimeout(() => {}, 1000),
|
|
362
|
+
});
|
|
363
|
+
harness.deps.renderTelegramMessage = renderTelegramMessage;
|
|
364
|
+
harness.deps.maxMessageLength = 4096;
|
|
365
|
+
await flushTelegramPreview(7, harness.deps);
|
|
366
|
+
assert.deepEqual(harness.events, [
|
|
367
|
+
'send:7:<b>Title</b>\n\n<pre><code class="language-ts">const x = 1</code></pre>:HTML',
|
|
368
|
+
]);
|
|
369
|
+
assert.equal(
|
|
370
|
+
harness.getState()?.lastSentText,
|
|
371
|
+
'<b>Title</b>\n\n<pre><code class="language-ts">const x = 1</code></pre>',
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("Preview runtime can still use and clear plain draft previews", async () => {
|
|
376
|
+
const harness = createPreviewRuntimeHarness({
|
|
377
|
+
mode: "draft",
|
|
378
|
+
pendingText: "**hello**",
|
|
379
|
+
lastSentText: "",
|
|
380
|
+
flushTimer: setTimeout(() => {}, 1000),
|
|
381
|
+
});
|
|
382
|
+
harness.deps.renderTelegramMessage = () => [];
|
|
383
|
+
await flushTelegramPreview(7, harness.deps);
|
|
384
|
+
assert.deepEqual(harness.events, ["draft:7:10:hello"]);
|
|
385
|
+
assert.equal(harness.getState()?.mode, "draft");
|
|
386
|
+
assert.equal(harness.getState()?.draftId, 10);
|
|
387
|
+
assert.equal(harness.getState()?.lastSentText, "hello");
|
|
388
|
+
assert.equal(harness.getState()?.lastSentStrategy, "plain");
|
|
389
|
+
assert.equal(harness.getDraftSupport(), "supported");
|
|
390
|
+
await clearTelegramPreview(7, harness.deps);
|
|
391
|
+
assert.deepEqual(harness.events, ["draft:7:10:hello", "draft:7:10:"]);
|
|
392
|
+
assert.equal(harness.getState(), undefined);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("Preview runtime falls back to editable plain messages when draft delivery fails", async () => {
|
|
396
|
+
const harness = createPreviewRuntimeHarness({
|
|
397
|
+
mode: "draft",
|
|
398
|
+
pendingText: "abcdef",
|
|
399
|
+
lastSentText: "",
|
|
400
|
+
});
|
|
401
|
+
harness.deps.renderTelegramMessage = () => [];
|
|
402
|
+
harness.deps.sendDraft = async () => {
|
|
403
|
+
throw new Error("draft unsupported");
|
|
404
|
+
};
|
|
405
|
+
await flushTelegramPreview(7, harness.deps);
|
|
406
|
+
assert.deepEqual(harness.events, ["send:7:abcdef:plain"]);
|
|
407
|
+
assert.equal(harness.getState()?.mode, "message");
|
|
408
|
+
assert.equal(harness.getState()?.messageId, 77);
|
|
409
|
+
assert.equal(harness.getState()?.lastSentStrategy, "plain");
|
|
410
|
+
assert.equal(harness.getDraftSupport(), "unsupported");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("Preview runtime finalizes plain and markdown previews", async () => {
|
|
414
|
+
const plainHarness = createPreviewRuntimeHarness({
|
|
415
|
+
mode: "message",
|
|
416
|
+
messageId: 44,
|
|
417
|
+
pendingText: "done",
|
|
418
|
+
lastSentText: "",
|
|
419
|
+
});
|
|
420
|
+
plainHarness.setDraftSupport("unsupported");
|
|
421
|
+
plainHarness.deps.renderTelegramMessage = () => [];
|
|
422
|
+
assert.equal(await finalizeTelegramPreview(7, plainHarness.deps), true);
|
|
423
|
+
assert.deepEqual(plainHarness.events, ["edit:7:44:done:plain"]);
|
|
424
|
+
assert.equal(plainHarness.getState(), undefined);
|
|
425
|
+
const markdownHarness = createPreviewRuntimeHarness({
|
|
426
|
+
mode: "message",
|
|
427
|
+
messageId: 55,
|
|
428
|
+
pendingText: "done",
|
|
429
|
+
lastSentText: "",
|
|
430
|
+
});
|
|
431
|
+
markdownHarness.setDraftSupport("unsupported");
|
|
432
|
+
assert.equal(
|
|
433
|
+
await finalizeTelegramMarkdownPreview(7, "**done**", markdownHarness.deps),
|
|
434
|
+
true,
|
|
435
|
+
);
|
|
436
|
+
assert.deepEqual(markdownHarness.events, [
|
|
437
|
+
"edit:7:55:done:plain",
|
|
438
|
+
"render-edit:7:55:markdown:**done**",
|
|
439
|
+
]);
|
|
440
|
+
assert.equal(markdownHarness.getState(), undefined);
|
|
441
|
+
});
|
package/tests/queue.test.ts
CHANGED
|
@@ -1154,6 +1154,7 @@ test("Extension runtime finalizes a drafted preview into the final Telegram repl
|
|
|
1154
1154
|
});
|
|
1155
1155
|
const draftTexts: string[] = [];
|
|
1156
1156
|
const sentTexts: string[] = [];
|
|
1157
|
+
const editedTexts: string[] = [];
|
|
1157
1158
|
const pi = {
|
|
1158
1159
|
on: (
|
|
1159
1160
|
event: string,
|
|
@@ -1225,6 +1226,7 @@ test("Extension runtime finalizes a drafted preview into the final Telegram repl
|
|
|
1225
1226
|
return { json: async () => ({ ok: true, result: true }) } as Response;
|
|
1226
1227
|
}
|
|
1227
1228
|
if (method === "editMessageText") {
|
|
1229
|
+
editedTexts.push(String(body?.text ?? ""));
|
|
1228
1230
|
return { json: async () => ({ ok: true, result: true }) } as Response;
|
|
1229
1231
|
}
|
|
1230
1232
|
throw new Error(`Unexpected Telegram API method: ${method}`);
|
|
@@ -1284,6 +1286,7 @@ test("Extension runtime finalizes a drafted preview into the final Telegram repl
|
|
|
1284
1286
|
assert.deepEqual(draftTexts, ["Draft preview", "Final answer", ""]);
|
|
1285
1287
|
assert.equal(sentTexts.length, 1);
|
|
1286
1288
|
assert.match(sentTexts[0] ?? "", /Final <b>answer<\/b>/);
|
|
1289
|
+
assert.deepEqual(editedTexts, []);
|
|
1287
1290
|
await handlers.get("session_shutdown")?.({}, ctx);
|
|
1288
1291
|
} finally {
|
|
1289
1292
|
globalThis.fetch = originalFetch;
|
package/tests/rendering.test.ts
CHANGED
|
@@ -7,7 +7,10 @@ import assert from "node:assert/strict";
|
|
|
7
7
|
import test from "node:test";
|
|
8
8
|
|
|
9
9
|
import { __telegramTestUtils } from "../index.ts";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
buildTelegramPreviewSnapshot,
|
|
12
|
+
renderMarkdownPreviewText,
|
|
13
|
+
} from "../lib/rendering.ts";
|
|
11
14
|
|
|
12
15
|
test("Nested lists stay out of code blocks", () => {
|
|
13
16
|
const chunks = __telegramTestUtils.renderTelegramMessage(
|
|
@@ -93,13 +96,88 @@ test("Leading indentation on the first markdown line stays intact", () => {
|
|
|
93
96
|
mode: "markdown",
|
|
94
97
|
});
|
|
95
98
|
assert.equal(chunks.length, 1);
|
|
96
|
-
assert.match(
|
|
99
|
+
assert.match(
|
|
100
|
+
chunks[0]?.text ?? "",
|
|
101
|
+
/^\u00A0\u00A0<code>-<\/code> nested bullet/m,
|
|
102
|
+
);
|
|
97
103
|
assert.match(
|
|
98
104
|
chunks[0]?.text ?? "",
|
|
99
105
|
/^\u00A0\u00A0\u00A0\u00A0<code>-<\/code> nested child/m,
|
|
100
106
|
);
|
|
101
107
|
});
|
|
102
108
|
|
|
109
|
+
test("Preview and final rendering preserve multiple blank lines between blocks", () => {
|
|
110
|
+
const markdown = "# Title\n\n\nParagraph\n\n\n> Quote";
|
|
111
|
+
assert.equal(
|
|
112
|
+
renderMarkdownPreviewText(markdown),
|
|
113
|
+
"Title\n\n\nParagraph\n\n\n> Quote",
|
|
114
|
+
);
|
|
115
|
+
const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
|
|
116
|
+
mode: "markdown",
|
|
117
|
+
});
|
|
118
|
+
assert.equal(chunks.length, 1);
|
|
119
|
+
assert.match(
|
|
120
|
+
chunks[0]?.text ?? "",
|
|
121
|
+
/<b>Title<\/b>\n\n\nParagraph\n\n\n<blockquote>Quote<\/blockquote>/,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("Rendering preserves original blank-line spacing across block transitions", () => {
|
|
126
|
+
const cases = [
|
|
127
|
+
{
|
|
128
|
+
markdown: "Para\n\n\n```ts\nconst x = 1\n```",
|
|
129
|
+
finalText:
|
|
130
|
+
'Para\n\n\n<pre><code class="language-ts">const x = 1</code></pre>',
|
|
131
|
+
previewText:
|
|
132
|
+
'Para\n\n\n<pre><code class="language-ts">const x = 1</code></pre>',
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
markdown: "```ts\nconst x = 1\n```\n\n\nPara",
|
|
136
|
+
finalText:
|
|
137
|
+
'<pre><code class="language-ts">const x = 1</code></pre>\n\n\nPara',
|
|
138
|
+
previewText:
|
|
139
|
+
'<pre><code class="language-ts">const x = 1</code></pre>\n\n\nPara',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
markdown: "Para\n\n\n- item",
|
|
143
|
+
finalText: "Para\n\n\n<code>-</code> item",
|
|
144
|
+
previewText: "Para\n\n\n- item",
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
markdown: "Para\n\n\n> Quote",
|
|
148
|
+
finalText: "Para\n\n\n<blockquote>Quote</blockquote>",
|
|
149
|
+
previewText: "Para\n\n\n> Quote",
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
for (const testCase of cases) {
|
|
153
|
+
const finalChunks = __telegramTestUtils.renderTelegramMessage(
|
|
154
|
+
testCase.markdown,
|
|
155
|
+
{ mode: "markdown" },
|
|
156
|
+
);
|
|
157
|
+
assert.equal(finalChunks.length, 1);
|
|
158
|
+
assert.equal(finalChunks[0]?.text ?? "", testCase.finalText);
|
|
159
|
+
const preview = buildTelegramPreviewSnapshot({
|
|
160
|
+
state: { pendingText: testCase.markdown, lastSentText: "" },
|
|
161
|
+
maxMessageLength: __telegramTestUtils.MAX_MESSAGE_LENGTH,
|
|
162
|
+
renderPreviewText: renderMarkdownPreviewText,
|
|
163
|
+
renderTelegramMessage: __telegramTestUtils.renderTelegramMessage,
|
|
164
|
+
});
|
|
165
|
+
assert.equal(preview?.text ?? "", testCase.previewText);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("Headings keep visible spacing before following code blocks even without source blank lines", () => {
|
|
170
|
+
const markdown = "### Title\n```ts\nconst x = 1\n```";
|
|
171
|
+
const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
|
|
172
|
+
mode: "markdown",
|
|
173
|
+
});
|
|
174
|
+
assert.equal(chunks.length, 1);
|
|
175
|
+
assert.match(
|
|
176
|
+
chunks[0]?.text ?? "",
|
|
177
|
+
/<b>Title<\/b>\n\n<pre><code class="language-ts">const x = 1<\/code><\/pre>/,
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
103
181
|
test("Standalone checkbox-looking prose stays literal outside task lists", () => {
|
|
104
182
|
const markdown = "Use [ ] as a placeholder and keep [x] literal";
|
|
105
183
|
assert.equal(renderMarkdownPreviewText(markdown), markdown);
|
|
@@ -154,6 +232,50 @@ test("Links, code spans, and underscore-heavy text coexist safely", () => {
|
|
|
154
232
|
assert.equal((chunks[0]?.text ?? "").includes("<i>bar</i>"), false);
|
|
155
233
|
});
|
|
156
234
|
|
|
235
|
+
test("Links degrade or normalize safely across supported and unsupported markdown forms", () => {
|
|
236
|
+
const markdown = [
|
|
237
|
+
"[**Bold** label](https://example.com/path)",
|
|
238
|
+
"[Docs](https://example.com/a_(b))",
|
|
239
|
+
'[Title](https://example.com/path "Tooltip")',
|
|
240
|
+
"[Relative](./docs/README.md)",
|
|
241
|
+
"[Ref][docs]",
|
|
242
|
+
"",
|
|
243
|
+
"[docs]: https://example.com/ref",
|
|
244
|
+
"",
|
|
245
|
+
"Footnote[^1]",
|
|
246
|
+
"",
|
|
247
|
+
"[^1]: Footnote body",
|
|
248
|
+
].join("\n");
|
|
249
|
+
const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
|
|
250
|
+
mode: "markdown",
|
|
251
|
+
});
|
|
252
|
+
assert.equal(chunks.length, 1);
|
|
253
|
+
assert.match(
|
|
254
|
+
chunks[0]?.text ?? "",
|
|
255
|
+
/<a href="https:\/\/example.com\/path">Bold label<\/a>/,
|
|
256
|
+
);
|
|
257
|
+
assert.match(
|
|
258
|
+
chunks[0]?.text ?? "",
|
|
259
|
+
/<a href="https:\/\/example.com\/a_\(b\)">Docs<\/a>/,
|
|
260
|
+
);
|
|
261
|
+
assert.match(
|
|
262
|
+
chunks[0]?.text ?? "",
|
|
263
|
+
/<a href="https:\/\/example.com\/path">Title<\/a>/,
|
|
264
|
+
);
|
|
265
|
+
assert.equal(
|
|
266
|
+
(chunks[0]?.text ?? "").includes('<a href="./docs/README.md">'),
|
|
267
|
+
false,
|
|
268
|
+
);
|
|
269
|
+
assert.match(chunks[0]?.text ?? "", /Relative/);
|
|
270
|
+
assert.equal(
|
|
271
|
+
(chunks[0]?.text ?? "").includes('<a href="https://example.com/ref">'),
|
|
272
|
+
false,
|
|
273
|
+
);
|
|
274
|
+
assert.match(chunks[0]?.text ?? "", /\[Ref\]\[docs\]/);
|
|
275
|
+
assert.match(chunks[0]?.text ?? "", /Footnote\[\^1\]/);
|
|
276
|
+
assert.match(chunks[0]?.text ?? "", /\[\^1\]: Footnote body/);
|
|
277
|
+
});
|
|
278
|
+
|
|
157
279
|
test("Long quoted blocks stay chunked with balanced blockquote tags", () => {
|
|
158
280
|
const markdown = Array.from(
|
|
159
281
|
{ length: 500 },
|