bug fixes, and performance improvements, and drawer
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { collectCitationSources } from "../app/utils/citations";
|
||||
import { createMarkdownParser, getNodeChildren, getNodeTag } from "../app/utils/markdown";
|
||||
import type { MarkdownAstNode } from "../app/utils/markdown";
|
||||
import { readSearchResponse } from "../server/utils/search";
|
||||
|
||||
const citations = (nodes: MarkdownAstNode[]): MarkdownAstNode[] => nodes.flatMap(node =>
|
||||
getNodeTag(node) === "source-cite" ? [node] : citations(getNodeChildren(node)),
|
||||
);
|
||||
|
||||
describe("citation parsing", () => {
|
||||
test("keeps citation markers as text unless explicitly enabled", async () => {
|
||||
const content = "Claim [[cite:1234]] and [[cite:s7,s9]].";
|
||||
for (const options of [{}, { citations: false }]) {
|
||||
for (const streaming of [false, true]) {
|
||||
const tree = await createMarkdownParser(options)(content, { streaming });
|
||||
expect(citations(tree.nodes)).toEqual([]);
|
||||
expect(getNodeChildren(tree.nodes[0] ?? "")).toEqual([content]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("renders single and grouped references as dedicated nodes", async () => {
|
||||
const tree = await createMarkdownParser({ citations: true })("Claim [[cite:s7]]. Another [[cite:s7,s9]].");
|
||||
expect(citations(tree.nodes)).toEqual([
|
||||
["source-cite", { ids: "s7" }],
|
||||
["source-cite", { ids: "s7,s9" }],
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not interpret code, escaped references, or link labels", async () => {
|
||||
for (const content of [
|
||||
"`[[cite:s7]]`",
|
||||
"```text\n[[cite:s7]]\n```",
|
||||
"\\[[cite:s7]]",
|
||||
"[Claim [[cite:s7]]](https://example.com)",
|
||||
]) {
|
||||
const tree = await createMarkdownParser({ citations: true })(content);
|
||||
expect(citations(tree.nodes)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("streaming never auto-completes a citation", async () => {
|
||||
const parse = createMarkdownParser({ citations: true });
|
||||
const content = "Claim [[cite:s7,s9]]";
|
||||
for (let length = 1; length < content.length; length++) {
|
||||
expect(citations((await parse(content.slice(0, length), { streaming: true })).nodes)).toEqual([]);
|
||||
}
|
||||
expect(citations((await parse(content, { streaming: true })).nodes)).toHaveLength(1);
|
||||
expect(citations((await parse(content)).nodes)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("citation sources", () => {
|
||||
test("IDs survive storage, including duplicate URLs and multiple searches", () => {
|
||||
const first = readSearchResponse({ results: [
|
||||
{ title: "One", url: "https://example.com" },
|
||||
{ title: "Two", url: "https://example.com" },
|
||||
] });
|
||||
const second = readSearchResponse({ results: [{ title: "Three", url: "https://example.com" }] });
|
||||
expect(first.ok && second.ok).toBe(true);
|
||||
if (!first.ok || !second.ok) {
|
||||
return;
|
||||
}
|
||||
const parts = [first.data, second.data].map(output => ({
|
||||
toolCall: { toolName: "search", output: { type: "json", value: JSON.stringify(output) } },
|
||||
}));
|
||||
const sources = collectCitationSources(parts);
|
||||
expect(sources.size).toBe(3);
|
||||
expect(sources.get(first.data.results[0]?.sourceId ?? "")?.title).toBe("One");
|
||||
expect(collectCitationSources(JSON.parse(JSON.stringify(parts)))).toEqual(sources);
|
||||
expect(sources.has("invented")).toBe(false);
|
||||
expect(collectCitationSources([]).size).toBe(0);
|
||||
});
|
||||
|
||||
test("ignores legacy results, non-search tools, and unsafe URLs", () => {
|
||||
const output = { type: "json", value: JSON.stringify({ results: [
|
||||
{ title: "Legacy", link: "https://example.com" },
|
||||
{ sourceId: "bad", title: "Bad", link: "javascript:alert(1)" },
|
||||
] }) };
|
||||
expect(collectCitationSources([{ toolCall: { toolName: "search", output } }]).size).toBe(0);
|
||||
expect(collectCitationSources([{ toolCall: { toolName: "python", output } }]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { getComposerHeight } from '../app/utils/composer-size';
|
||||
|
||||
describe('composer height stability', () => {
|
||||
test('subpixel noise never oscillates around the two-line minimum', () => {
|
||||
const heights = [47.99999, 48, 48.01].map(height => getComposerHeight(height, 24, 0, 0, true));
|
||||
expect(heights).toEqual([48, 48, 48]);
|
||||
});
|
||||
|
||||
test('grows and shrinks for actual lines, capped at ten lines', () => {
|
||||
expect([24, 48, 72, 264, 72, 24].map(height => getComposerHeight(height, 24, 0, 0, true)))
|
||||
.toEqual([48, 48, 72, 240, 72, 48]);
|
||||
});
|
||||
|
||||
test('accounts for padding and borders according to box sizing', () => {
|
||||
expect(getComposerHeight(80, 24, 8, 2, true)).toBe(82);
|
||||
expect(getComposerHeight(80, 24, 8, 2, false)).toBe(72);
|
||||
expect(getComposerHeight(10, 24, 8, 2, true)).toBe(58);
|
||||
});
|
||||
|
||||
test('respects fractional typography rather than hardcoding a 24px line', () => {
|
||||
expect(getComposerHeight(40, 20.25, 0, 0, true)).toBe(41);
|
||||
expect(getComposerHeight(1000, 20.25, 0, 0, true)).toBe(203);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { MarkdownTree } from '../app/utils/markdown';
|
||||
import { expect, test } from 'bun:test';
|
||||
import { splitParagraphBreaks } from '../app/utils/markdown';
|
||||
|
||||
test('paragraph splitting preserves nested formatting without mutating a cached parser tree', () => {
|
||||
const tree = { nodes: [
|
||||
['p', {}, 'before', ['br', {}], ['strong', {}, 'after']],
|
||||
['blockquote', {}, 'one', ['br', {}], ['em', {}, 'two']],
|
||||
] } as MarkdownTree;
|
||||
const original = structuredClone(tree);
|
||||
const result = splitParagraphBreaks(tree);
|
||||
expect(result.nodes).toEqual([
|
||||
['p', {}, 'before'],
|
||||
['p', {}, ['strong', {}, 'after']],
|
||||
['blockquote', {}, ['p', {}, 'one'], ['p', {}, ['em', {}, 'two']]],
|
||||
]);
|
||||
expect(tree).toEqual(original);
|
||||
expect(splitParagraphBreaks(tree)).toEqual(result);
|
||||
});
|
||||
|
||||
test('br-free trees return by reference with no allocation', () => {
|
||||
const tree = { nodes: [
|
||||
['p', {}, 'plain ', ['strong', {}, 'text']],
|
||||
['ul', {}, ['li', {}, 'one'], ['li', {}, 'two']],
|
||||
] } as MarkdownTree;
|
||||
expect(splitParagraphBreaks(tree)).toBe(tree);
|
||||
});
|
||||
|
||||
test('single newlines still split into sibling paragraphs', () => {
|
||||
const tree = { nodes: [
|
||||
['p', {}, 'line one', ['br', {}], 'line two'],
|
||||
] } as MarkdownTree;
|
||||
expect(splitParagraphBreaks(tree).nodes).toEqual([
|
||||
['p', {}, 'line one'],
|
||||
['p', {}, 'line two'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('nested changes rebuild only the affected path', () => {
|
||||
const tree = { nodes: [
|
||||
['blockquote', {}, ['p', {}, 'kept']],
|
||||
['p', {}, 'split', ['br', {}], 'me'],
|
||||
] } as MarkdownTree;
|
||||
const result = splitParagraphBreaks(tree);
|
||||
expect(result).not.toBe(tree);
|
||||
expect(result.nodes[0]).toBe(tree.nodes[0]);
|
||||
expect(result.nodes).toEqual([
|
||||
['blockquote', {}, ['p', {}, 'kept']],
|
||||
['p', {}, 'split'],
|
||||
['p', {}, 'me'],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
buildFocusedMessageTree,
|
||||
buildMessageTree,
|
||||
getActiveMessage,
|
||||
getCurrentChildIndex,
|
||||
getMessageCount,
|
||||
getNextActiveChildId,
|
||||
} from "../utils/message";
|
||||
|
||||
describe("message focus and versioning", () => {
|
||||
test("reproduces and fixes the 3/2 focus bug after deleting root message and regenerating", () => {
|
||||
// Step 1: Generate initial assistant message (A)
|
||||
const msgA = {
|
||||
id: "msg_a",
|
||||
userId: "user_1",
|
||||
topicId: "topic_1",
|
||||
role: "assistant" as const,
|
||||
content: "Response 1",
|
||||
parentMessageId: null,
|
||||
generationId: null,
|
||||
activeChildId: null,
|
||||
deleted: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
parts: [],
|
||||
generation: null,
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
let tree = buildMessageTree([msgA]);
|
||||
expect(tree.length).toBe(1);
|
||||
expect(getMessageCount(tree[0]!)).toBe(1);
|
||||
expect(getCurrentChildIndex(tree[0]!)).toBe(0);
|
||||
expect(getActiveMessage(tree[0]!)?.id).toBe("msg_a");
|
||||
|
||||
// Step 2: Regenerate the message -> creates child B, activeChildId points to B
|
||||
const msgB = {
|
||||
...msgA,
|
||||
id: "msg_b",
|
||||
content: "Response 2",
|
||||
parentMessageId: "msg_a",
|
||||
};
|
||||
msgA.activeChildId = "msg_b";
|
||||
|
||||
tree = buildMessageTree([msgA, msgB]);
|
||||
expect(tree.length).toBe(1);
|
||||
expect(tree[0]!.children.length).toBe(1);
|
||||
expect(getMessageCount(tree[0]!)).toBe(2);
|
||||
// Variant 2 of 2: index should be 1 (displayed as 1 + 1 = 2)
|
||||
expect(getCurrentChildIndex(tree[0]!)).toBe(1);
|
||||
expect(getActiveMessage(tree[0]!)?.id).toBe("msg_b");
|
||||
|
||||
// Step 3: Switch focus to first, root message (A)
|
||||
const prevId = getNextActiveChildId(tree[0]!, -1);
|
||||
expect(prevId).toBeNull();
|
||||
msgA.activeChildId = null;
|
||||
|
||||
tree = buildMessageTree([msgA, msgB]);
|
||||
expect(getMessageCount(tree[0]!)).toBe(2);
|
||||
// Variant 1 of 2: index should be 0 (displayed as 0 + 1 = 1)
|
||||
expect(getCurrentChildIndex(tree[0]!)).toBe(0);
|
||||
expect(getActiveMessage(tree[0]!)?.id).toBe("msg_a");
|
||||
|
||||
// Step 4: Delete the root message (A)
|
||||
// Root is soft-deleted, active child switches to B
|
||||
msgA.deleted = true;
|
||||
msgA.activeChildId = "msg_b";
|
||||
|
||||
tree = buildMessageTree([msgA, msgB]);
|
||||
expect(tree.length).toBe(1);
|
||||
expect(tree[0]!.deleted).toBe(true);
|
||||
expect(getMessageCount(tree[0]!)).toBe(1);
|
||||
expect(getCurrentChildIndex(tree[0]!)).toBe(0);
|
||||
expect(getActiveMessage(tree[0]!)?.id).toBe("msg_b");
|
||||
|
||||
// Step 5: Regenerate the message -> creates child C with parentMessageId: msg_a
|
||||
const msgC = {
|
||||
...msgA,
|
||||
id: "msg_c",
|
||||
content: "Response 3",
|
||||
parentMessageId: "msg_a",
|
||||
deleted: false,
|
||||
};
|
||||
msgA.activeChildId = "msg_c";
|
||||
|
||||
tree = buildMessageTree([msgA, msgB, msgC]);
|
||||
expect(tree.length).toBe(1);
|
||||
expect(tree[0]!.children.length).toBe(2);
|
||||
expect(getMessageCount(tree[0]!)).toBe(2);
|
||||
// Crucial test: When C (second child) is active, index must be 1 (displayed as 2/2, NOT 3/2!)
|
||||
expect(getCurrentChildIndex(tree[0]!)).toBe(1);
|
||||
expect(getActiveMessage(tree[0]!)?.id).toBe("msg_c");
|
||||
|
||||
// Verify switching focus back to B works seamlessly
|
||||
const backToB = getNextActiveChildId(tree[0]!, -1);
|
||||
expect(backToB).toBe("msg_b");
|
||||
msgA.activeChildId = backToB;
|
||||
|
||||
tree = buildMessageTree([msgA, msgB, msgC]);
|
||||
expect(getCurrentChildIndex(tree[0]!)).toBe(0);
|
||||
expect(getActiveMessage(tree[0]!)?.id).toBe("msg_b");
|
||||
|
||||
// Verify switching forward to C works seamlessly
|
||||
const forwardToC = getNextActiveChildId(tree[0]!, 1);
|
||||
expect(forwardToC).toBe("msg_c");
|
||||
msgA.activeChildId = forwardToC;
|
||||
|
||||
tree = buildMessageTree([msgA, msgB, msgC]);
|
||||
expect(getCurrentChildIndex(tree[0]!)).toBe(1);
|
||||
expect(getActiveMessage(tree[0]!)?.id).toBe("msg_c");
|
||||
});
|
||||
|
||||
test("buildFocusedMessageTree excludes soft-deleted root messages", () => {
|
||||
const root = {
|
||||
id: "root",
|
||||
userId: "user_1",
|
||||
topicId: "topic_1",
|
||||
role: "assistant" as const,
|
||||
content: "Root",
|
||||
parentMessageId: null,
|
||||
generationId: null,
|
||||
activeChildId: "child_1",
|
||||
deleted: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
parts: [],
|
||||
generation: null,
|
||||
attachments: [],
|
||||
};
|
||||
const child1 = {
|
||||
...root,
|
||||
id: "child_1",
|
||||
content: "Child 1",
|
||||
parentMessageId: "root",
|
||||
activeChildId: null,
|
||||
deleted: false,
|
||||
};
|
||||
|
||||
const tree = buildMessageTree([root, child1]);
|
||||
const focused = buildFocusedMessageTree(tree);
|
||||
|
||||
expect(focused.length).toBe(1);
|
||||
expect(focused[0]!.id).toBe("child_1");
|
||||
expect(focused[0]!.deleted).toBe(false);
|
||||
});
|
||||
|
||||
test("buildMessageTree filters out deleted roots with no children", () => {
|
||||
const deletedEmptyRoot = {
|
||||
id: "deleted_root",
|
||||
userId: "user_1",
|
||||
topicId: "topic_1",
|
||||
role: "assistant" as const,
|
||||
content: "Empty",
|
||||
parentMessageId: null,
|
||||
generationId: null,
|
||||
activeChildId: null,
|
||||
deleted: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
parts: [],
|
||||
generation: null,
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
const tree = buildMessageTree([deletedEmptyRoot]);
|
||||
expect(tree.length).toBe(0);
|
||||
});
|
||||
|
||||
test("getNextActiveChildId clamps to bounds and handles null fallback", () => {
|
||||
const root = {
|
||||
id: "root",
|
||||
deleted: true,
|
||||
activeChildId: null as string | null,
|
||||
children: [
|
||||
{ id: "child_a" },
|
||||
{ id: "child_b" },
|
||||
],
|
||||
};
|
||||
|
||||
// If activeChildId is null and root is deleted, going left stays at child_a
|
||||
expect(getNextActiveChildId(root, -1)).toBe("child_a");
|
||||
// Going right moves to child_b
|
||||
expect(getNextActiveChildId(root, 1)).toBe("child_b");
|
||||
|
||||
// When at child_b, going right clamps to child_b
|
||||
root.activeChildId = "child_b";
|
||||
expect(getNextActiveChildId(root, 1)).toBe("child_b");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { addPendingRename, cancelPendingRename, completeRename } from "../server/utils/renames";
|
||||
import { provisionalTopicTitle } from "../utils/topic-title";
|
||||
|
||||
describe("rename attempt ownership", () => {
|
||||
test("an old completion cannot remove a newer attempt", () => {
|
||||
addPendingRename("topic", "old");
|
||||
const current = addPendingRename("topic", "new");
|
||||
completeRename("topic", "old");
|
||||
cancelPendingRename("topic", "new");
|
||||
expect(current.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test("an old cancellation cannot abort a newer attempt", () => {
|
||||
const current = addPendingRename("topic", "new");
|
||||
cancelPendingRename("topic", "old");
|
||||
expect(current.signal.aborted).toBe(false);
|
||||
completeRename("topic", "new");
|
||||
});
|
||||
|
||||
test("cancellation is safe after completion or restart", () => {
|
||||
cancelPendingRename("missing", "old");
|
||||
const current = addPendingRename("topic", "new");
|
||||
completeRename("topic", "new");
|
||||
cancelPendingRename("topic", "new");
|
||||
expect(current.signal.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provisional titles", () => {
|
||||
test("uses the first meaningful line and normalizes whitespace", () => {
|
||||
expect(provisionalTopicTitle({ content: "\n Help me debug this\nMore details" })).toBe("Help me debug this");
|
||||
});
|
||||
test("names attachment-only conversations", () => {
|
||||
expect(provisionalTopicTitle({ content: "", files: [{ name: "photo.jpg" }] })).toBe("photo.jpg");
|
||||
expect(provisionalTopicTitle({ content: "", fileIds: ["image"] })).toBe("Attached files");
|
||||
});
|
||||
test("bounds title length and handles empty input", () => {
|
||||
expect(provisionalTopicTitle({ content: "a".repeat(200) })).toHaveLength(100);
|
||||
expect(provisionalTopicTitle()).toBe("New Topic");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { topicEvents } from '../server/utils/events';
|
||||
import { createTopicSnapshotStream } from '../server/utils/topic-snapshot-stream';
|
||||
import { appendTextDelta, serializeTopicSnapshot } from '../utils/topic-sync';
|
||||
|
||||
const snapshot = () => ({
|
||||
id: 'topic',
|
||||
name: 'Example',
|
||||
messages: [{
|
||||
id: 'message',
|
||||
activeChildId: null as string | null,
|
||||
updatedAt: new Date('2026-09-27T00:00:00Z'),
|
||||
generation: { status: 'pending', tokens: null as unknown },
|
||||
parts: [{ id: 'part', type: 'text', content: 'hello', finished: false }],
|
||||
}],
|
||||
});
|
||||
const fingerprint = (value: unknown) => createHash('sha256').update(serializeTopicSnapshot(value)).digest('hex');
|
||||
const readEvent = async (reader: ReadableStreamDefaultReader<string>) => {
|
||||
const { value } = await reader.read();
|
||||
return JSON.parse(value!.slice(6));
|
||||
};
|
||||
|
||||
describe('topic snapshot synchronization', () => {
|
||||
test('matches database Dates and hydrated JSON regardless of object key order', () => {
|
||||
const topic = snapshot();
|
||||
const hydrated = JSON.parse(JSON.stringify(topic));
|
||||
expect(fingerprint({ messages: hydrated.messages, name: hydrated.name, id: hydrated.id })).toBe(fingerprint(topic));
|
||||
});
|
||||
|
||||
test('detects older-message, part, generation, variant, and topic changes without relying on count', () => {
|
||||
const original = fingerprint(snapshot());
|
||||
for (const change of [
|
||||
(topic: ReturnType<typeof snapshot>) => topic.name = 'Renamed',
|
||||
(topic: ReturnType<typeof snapshot>) => topic.messages[0]!.activeChildId = 'variant',
|
||||
(topic: ReturnType<typeof snapshot>) => topic.messages[0]!.parts[0]!.content = 'edited',
|
||||
(topic: ReturnType<typeof snapshot>) => topic.messages[0]!.parts[0]!.finished = true,
|
||||
(topic: ReturnType<typeof snapshot>) => topic.messages[0]!.generation.tokens = { output: 123 },
|
||||
]) {
|
||||
const topic = snapshot();
|
||||
change(topic);
|
||||
expect(fingerprint(topic)).not.toBe(original);
|
||||
}
|
||||
});
|
||||
|
||||
test('matching state receives a small ready event, then live changes', async () => {
|
||||
const topic = snapshot();
|
||||
const subscription = createTopicSnapshotStream('match', fingerprint(topic));
|
||||
const result = await subscription.open(async () => topic);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
const reader = result.data.getReader();
|
||||
try {
|
||||
expect(await readEvent(reader)).toEqual({ type: 'synced', payload: { id: 'match' } });
|
||||
await topicEvents.emit('match', { type: 'topic_updated', payload: { name: 'New' } });
|
||||
expect((await readEvent(reader)).payload).toEqual({ name: 'New' });
|
||||
} finally {
|
||||
await reader.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
test('stale or missing fingerprints receive a full replacement snapshot', async () => {
|
||||
for (const offered of [undefined, fingerprint(snapshot())]) {
|
||||
const topic = snapshot();
|
||||
topic.messages[0]!.parts[0]!.content = 'changed';
|
||||
const subscription = createTopicSnapshotStream('stale', offered);
|
||||
const result = await subscription.open(async () => topic);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) continue;
|
||||
const reader = result.data.getReader();
|
||||
try {
|
||||
const event = await readEvent(reader);
|
||||
expect(event.type).toBe('initial_state');
|
||||
expect(event.payload.messages[0].parts[0].content).toBe('changed');
|
||||
} finally {
|
||||
await reader.cancel();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('captures events during the DB query and includes text not yet persisted', async () => {
|
||||
const id = 'query-race';
|
||||
const initial = snapshot();
|
||||
await topicEvents.emit(id, {
|
||||
type: 'text-start', payload: { messageId: 'message', part: initial.messages[0]!.parts[0] },
|
||||
});
|
||||
// This delta predates subscription and has not reached the database yet.
|
||||
await topicEvents.emit(id, {
|
||||
type: 'text-delta', payload: { messageId: 'message', partId: 'part', content: ' world', offset: 5 },
|
||||
});
|
||||
const pending = Promise.withResolvers<ReturnType<typeof snapshot>>();
|
||||
const subscription = createTopicSnapshotStream(id, fingerprint(initial));
|
||||
const opening = subscription.open(() => pending.promise);
|
||||
await topicEvents.emit(id, {
|
||||
type: 'text-delta', payload: { messageId: 'message', partId: 'part', content: '!', offset: 11 },
|
||||
});
|
||||
pending.resolve(snapshot());
|
||||
const result = await opening;
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
const reader = result.data.getReader();
|
||||
try {
|
||||
const state = await readEvent(reader);
|
||||
expect(state.type).toBe('initial_state');
|
||||
const text = state.payload.messages[0].parts[0].content;
|
||||
expect(text).toBe('hello world!');
|
||||
const replay = await readEvent(reader);
|
||||
expect(replay.replay).toBe(true);
|
||||
expect(appendTextDelta(text, replay.payload.content, replay.payload.offset)).toBe(text);
|
||||
} finally {
|
||||
await reader.cancel();
|
||||
topicEvents.clearLiveText(id, 'message');
|
||||
}
|
||||
});
|
||||
|
||||
test('cancellation during snapshot loading does not establish a stream', async () => {
|
||||
const pending = Promise.withResolvers<ReturnType<typeof snapshot>>();
|
||||
const subscription = createTopicSnapshotStream('cancelled', undefined);
|
||||
const opening = subscription.open(() => pending.promise);
|
||||
subscription.close();
|
||||
pending.resolve(snapshot());
|
||||
expect((await opening).ok).toBe(false);
|
||||
});
|
||||
|
||||
test('text offsets handle partial overlaps, duplicates, and gaps', () => {
|
||||
expect(appendTextDelta('hello w', ' world', 5)).toBe('hello world');
|
||||
expect(appendTextDelta('hello world', ' world', 5)).toBe('hello world');
|
||||
expect(appendTextDelta('hello', '!', 5)).toBe('hello!');
|
||||
expect(appendTextDelta('hello', '!', 10)).toBe('hello');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user