Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "../runtime-api.js";
const sendModuleMocks = vi.hoisted(() => {
const loadConfigMock = vi.fn(() => ({}));
const resolveTextChunkLimitMock = vi.fn<
(cfg: unknown, channel: unknown, accountId?: unknown) => number
>(() => 4000);
const resolveChunkModeMock = vi.fn<
(cfg: unknown, channel: unknown, accountId?: unknown) => string
>(() => "length");
const chunkMarkdownTextWithModeMock = vi.fn((text: string) => (text ? [text] : []));
const convertMarkdownTablesMock = vi.fn((text: string) => text);
const prepareMatrixSingleText = vi.fn(
(text: string, opts: { cfg?: unknown; accountId?: string } = {}) => {
const trimmedText = text.trim();
const convertedText = convertMarkdownTablesMock(trimmedText);
const singleEventLimit = Math.min(
resolveTextChunkLimitMock(opts.cfg ?? {}, "matrix", opts.accountId),
4000,
);
return {
trimmedText,
convertedText,
singleEventLimit,
fitsInSingleEvent: convertedText.length <= singleEventLimit,
};
},
);
const sendSingleTextMessageMatrix = vi.fn(
async (
roomId: string,
text: string,
opts: {
client?: {
sendMessage: (roomId: string, content: Record<string, unknown>) => Promise<string>;
};
cfg?: unknown;
accountId?: string;
msgtype?: string;
includeMentions?: boolean;
live?: boolean;
} = {},
) => {
const prepared = prepareMatrixSingleText(text, {
cfg: opts.cfg,
accountId: opts.accountId,
});
if (!prepared.trimmedText) {
throw new Error("Matrix single-message send requires text");
}
if (!prepared.fitsInSingleEvent) {
throw new Error("Matrix single-message text exceeds limit");
}
const content: Record<string, unknown> = {
msgtype: opts.msgtype ?? "m.text",
body: prepared.convertedText,
};
if (opts.live) {
content["org.matrix.msc4357.live"] = {};
}
const eventId = await opts.client?.sendMessage(roomId, content);
return {
messageId: eventId ?? "unknown",
roomId,
primaryMessageId: eventId ?? "unknown",
messageIds: eventId ? [eventId] : [],
};
},
);
const editMessageMatrix = vi.fn(
async (
roomId: string,
originalEventId: string,
newText: string,
opts: {
client?: {
sendMessage: (roomId: string, content: Record<string, unknown>) => Promise<string>;
};
msgtype?: string;
live?: boolean;
} = {},
) => {
const convertedText = convertMarkdownTablesMock(newText);
const newContent: Record<string, unknown> = {
msgtype: opts.msgtype ?? "m.text",
body: convertedText,
};
if (opts.live) {
newContent["org.matrix.msc4357.live"] = {};
}
const content: Record<string, unknown> = {
...newContent,
body: `* ${convertedText}`,
"m.new_content": newContent,
"m.relates_to": {
rel_type: "m.replace",
event_id: originalEventId,
},
};
if (opts.live) {
content["org.matrix.msc4357.live"] = {};
}
return (await opts.client?.sendMessage(roomId, content)) ?? "";
},
);
return {
chunkMarkdownTextWithModeMock,
convertMarkdownTablesMock,
editMessageMatrix,
loadConfigMock,
prepareMatrixSingleText,
resolveChunkModeMock,
resolveTextChunkLimitMock,
sendSingleTextMessageMatrix,
};
});
const {
chunkMarkdownTextWithModeMock,
convertMarkdownTablesMock,
loadConfigMock,
resolveChunkModeMock,
resolveTextChunkLimitMock,
} = sendModuleMocks;
vi.mock("./send.js", () => ({
editMessageMatrix: sendModuleMocks.editMessageMatrix,
prepareMatrixSingleText: sendModuleMocks.prepareMatrixSingleText,
sendSingleTextMessageMatrix: sendModuleMocks.sendSingleTextMessageMatrix,
}));
const runtimeStub = {
config: { loadConfig: () => loadConfigMock() },
channel: {
text: {
resolveTextChunkLimit: (cfg: unknown, channel: unknown, accountId?: unknown) =>
resolveTextChunkLimitMock(cfg, channel, accountId),
resolveChunkMode: (cfg: unknown, channel: unknown, accountId?: unknown) =>
resolveChunkModeMock(cfg, channel, accountId),
chunkMarkdownText: (text: string) => (text ? [text] : []),
chunkMarkdownTextWithMode: (text: string) => chunkMarkdownTextWithModeMock(text),
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
},
},
} as unknown as PluginRuntime;
let createMatrixDraftStream: typeof import("./draft-stream.js").createMatrixDraftStream;
const sendMessageMock = vi.fn();
const sendEventMock = vi.fn();
const joinedRoomsMock = vi.fn().mockResolvedValue([]);
function createMockClient() {
sendMessageMock.mockReset().mockResolvedValue("$evt1");
sendEventMock.mockReset().mockResolvedValue("$evt2");
joinedRoomsMock.mockReset().mockResolvedValue(["!room:test"]);
return {
sendMessage: sendMessageMock,
sendEvent: sendEventMock,
getJoinedRooms: joinedRoomsMock,
prepareForOneOff: vi.fn().mockResolvedValue(undefined),
start: vi.fn().mockResolvedValue(undefined),
} as unknown as import("./sdk.js").MatrixClient;
}
beforeAll(async () => {
const runtimeModule = await import("../runtime.js");
runtimeModule.setMatrixRuntime(runtimeStub);
({ createMatrixDraftStream } = await import("./draft-stream.js"));
});
describe("createMatrixDraftStream", () => {
let client: ReturnType<typeof createMockClient>;
beforeEach(() => {
vi.useFakeTimers();
client = createMockClient();
resolveTextChunkLimitMock.mockReset().mockReturnValue(4000);
resolveChunkModeMock.mockReset().mockReturnValue("length");
chunkMarkdownTextWithModeMock
.mockReset()
.mockImplementation((text: string) => (text ? [text] : []));
convertMarkdownTablesMock.mockReset().mockImplementation((text: string) => text);
sendModuleMocks.editMessageMatrix.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it("sends a normal text preview on first partial update", async () => {
const stream = createMatrixDraftStream({
roomId: "!room:test",
client,
cfg: {} as import("../types.js").CoreConfig,
});
stream.update("Hello");
await stream.flush();
expect(sendMessageMock).toHaveBeenCalledTimes(1);
expect(sendMessageMock.mock.calls[0]?.[1]).toMatchObject({
msgtype: "m.text",
});
expect(stream.eventId()).toBe("$evt1");
});
it("sends quiet preview notices when quiet mode is enabled", async () => {
const stream = createMatrixDraftStream({
roomId: "!room:test",
client,
cfg: {} as import("../types.js").CoreConfig,
mode: "quiet",
});
stream.update("Hello");
await stream.flush();
expect(sendMessageMock).toHaveBeenCalledTimes(1);
expect(sendMessageMock.mock.calls[0]?.[1]).toMatchObject({
msgtype: "m.notice",
});
expect(sendMessageMock.mock.calls[0]?.[1]).not.toHaveProperty("m.mentions");
});
it("edits the message on subsequent quiet updates", async () => {
const stream = createMatrixDraftStream({
roomId: "!room:test",
client,
cfg: {} as import("../types.js").CoreConfig,
mode: "quiet",
});
stream.update("Hello");
await stream.flush();
expect(sendMessageMock).toHaveBeenCalledTimes(1);
// Advance past throttle window so the next update fires immediately.
vi.advanceTimersByTime(1000);
stream.update("Hello world");
await stream.flush();
// First call = initial send, second call = edit (both go through sendMessage)
expect(sendMessageMock).toHaveBeenCalledTimes(2);
expect(sendMessageMock.mock.calls[1]?.[1]).toMatchObject({
msgtype: "m.notice",
"m.new_content": { msgtype: "m.notice" },
});
});
it("coalesces rapid quiet updates within throttle window", async () => {
const stream = createMatrixDraftStream({
roomId: "!room:test",
client,
cfg: {} as import("../types.js").CoreConfig,
mode: "quiet",
});
stream.update("A");
stream.update("AB");
stream.update("ABC");
await stream.flush();
// First update fires immediately (fresh throttle window), then AB/ABC
// coalesce into a single edit with the latest text.
expect(sendMessageMock).toHaveBeenCalledTimes(2);
expect(sendMessageMock.mock.calls[0][1]).toMatchObject({ body: "A" });
// Edit uses "* <text>" prefix per Matrix m.replace spec.
expect(sendMessageMock.mock.calls[1][1]).toMatchObject({ body: "* ABC" });
expect(sendMessageMock.mock.calls[0][1]).toMatchObject({ msgtype: "m.notice" });
expect(sendMessageMock.mock.calls[1][1]).toMatchObject({
msgtype: "m.notice",
"m.new_content": { msgtype: "m.notice" },
});
});
it("skips no-op updates", async () => {
const stream = createMatrixDraftStream({
roomId: "!room:test",
client,
cfg: {} as import("../types.js").CoreConfig,
});
stream.update("Hello");
await stream.flush();
const callCount = sendMessageMock.mock.calls.length;
vi.advanceTimersByTime(1000);
// Same text again — should not send
stream.update("Hello");
await stream.flush();
expect(sendMessageMock).toHaveBeenCalledTimes(callCount);
});
it("ignores updates after stop", async () => {
const stream = createMatrixDraftStream({
roomId: "!room:test",
client,
cfg: {} as import("../types.js").CoreConfig,
});
stream.update("Hello");
await stream.stop();
const callCount = sendMessageMock.mock.calls.length;
stream.update("Ignored");
await stream.flush();
expect(sendMessageMock).toHaveBeenCalledTimes(callCount);
});
it("stop returns the event ID", async () => {
const stream = createMatrixDraftStream({
roomId: "!room:test",
client,
cfg: {} as import("../types.js").CoreConfig,
});
stream.update("Hello");
const eventId = await stream.stop();
expect(eventId).toBe("$evt1");
});
it("stop does not finalize live drafts on its own", async () => {
const stream = createMatrixDraftStream({
roomId: "!room:test",
client,
cfg: {} as import("../types.js").CoreConfig,
mode: "partial",
});
stream.update("Hello");
await stream.stop();
expect(sendMessageMock).toHaveBeenCalledTimes(1);
expect(sendMessageMock.mock.calls[0]?.[1]).toHaveProperty("org.matrix.msc4357.live");
});
it("finalizeLive clears the live marker at most once", async () => {
const stream = createMatrixDraftStream({
roomId: "!room:test",
client,
cfg: {} as import("../types.js").CoreConfig,
mode: "partial",
});
stream.update("Hello");
await stream.stop();
await stream.finalizeLive();
await stream.finalizeLive();
expect(sendMessageMock).toHaveBeenCalledTimes(2);
expect(sendMessageMock.mock.calls[1]?.[1]).not.toHaveProperty("org.matrix.msc4357.live");
});
it("marks live finalize failures for normal final delivery fallback", async () => {
sendMessageMock.mockResolvedValueOnce("$evt1").mockRejectedValueOnce(new Error("rate limited"));
const stream = createMatrixDraftStream({
roomId: "!room:test",
client,
cfg: {} as import("../types.js").CoreConfig,
mode: "partial",
});
stream.update("Hello");
await stream.stop();
await expect(stream.finalizeLive()).resolves.toBe(false);
expect(stream.mustDeliverFinalNormally()).toBe(true);
});
it("reset allows reuse for next block", async () => {
sendMessageMock.mockResolvedValueOnce("$first").mockResolvedValueOnce("$second");
const stream = createMatrixDraftStream({
roomId: "!room:test",
client,
cfg: {} as import("../types.js").CoreConfig,
mode: "quiet",
});
stream.update("Block 1");
await stream.stop();
expect(stream.eventId()).toBe("$first");
stream.reset();
expect(stream.eventId()).toBeUndefined();
stream.update("Block 2");
await stream.stop();
expect(stream.eventId()).toBe("$second");
});
it("stops retrying after send failure", async () => {
sendMessageMock.mockRejectedValueOnce(new Error("network error"));
const log = vi.fn();
const stream = createMatrixDraftStream({
roomId: "!room:test",
client,
cfg: {} as import("../types.js").CoreConfig,
log,
});
stream.update("Hello");
await stream.flush();
// Should have logged the failure
expect(log).toHaveBeenCalledWith(expect.stringContaining("send/edit failed"));
vi.advanceTimersByTime(1000);
// Further updates should not attempt sends (stream is stopped)
stream.update("More text");
await stream.flush();
// Only the initial failed attempt
expect(sendMessageMock).toHaveBeenCalledTimes(1);
expect(stream.eventId()).toBeUndefined();
});
it("skips empty/whitespace text", async () => {
const stream = createMatrixDraftStream({
roomId: "!room:test",
client,
cfg: {} as import("../types.js").CoreConfig,
});
stream.update(" ");
await stream.flush();
expect(sendMessageMock).not.toHaveBeenCalled();
});
it("stops on edit failure mid-stream", async () => {
sendMessageMock
.mockResolvedValueOnce("$evt1") // initial send succeeds
.mockRejectedValueOnce(new Error("rate limited")); // edit fails
const log = vi.fn();
const stream = createMatrixDraftStream({
roomId: "!room:test",
client,
cfg: {} as import("../types.js").CoreConfig,
log,
});
stream.update("Hello");
await stream.flush();
expect(stream.eventId()).toBe("$evt1");
vi.advanceTimersByTime(1000);
stream.update("Hello world");
await stream.flush();
expect(log).toHaveBeenCalledWith(expect.stringContaining("send/edit failed"));
vi.advanceTimersByTime(1000);
// Stream should be stopped — further updates are ignored
stream.update("More text");
await stream.flush();
expect(sendMessageMock).toHaveBeenCalledTimes(2);
});
it("bypasses newline chunking for the draft preview message", async () => {
resolveChunkModeMock.mockReturnValue("newline");
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("\n"));
const stream = createMatrixDraftStream({
roomId: "!room:test",
client,
cfg: {} as import("../types.js").CoreConfig,
});
stream.update("line 1\nline 2");
await stream.flush();
expect(sendMessageMock).toHaveBeenCalledTimes(1);
expect(sendMessageMock.mock.calls[0]?.[1]).toMatchObject({ body: "line 1\nline 2" });
});
it("falls back to normal delivery when preview text exceeds one Matrix event", async () => {
const log = vi.fn();
resolveTextChunkLimitMock.mockReturnValue(5);
const stream = createMatrixDraftStream({
roomId: "!room:test",
client,
cfg: {} as import("../types.js").CoreConfig,
log,
});
stream.update("123456");
await stream.flush();
expect(sendMessageMock).not.toHaveBeenCalled();
expect(stream.eventId()).toBeUndefined();
expect(log).toHaveBeenCalledWith(
expect.stringContaining("preview exceeded single-event limit"),
);
});
it("discardPending cancels pending updates without creating another preview event", async () => {
const stream = createMatrixDraftStream({
roomId: "!room:test",
client,
cfg: {} as import("../types.js").CoreConfig,
});
stream.update("First draft");
await stream.flush();
stream.update("Pending draft");
await stream.discardPending();
await stream.flush();
expect(sendMessageMock).toHaveBeenCalledTimes(1);
expect(sendModuleMocks.editMessageMatrix).not.toHaveBeenCalled();
expect(stream.eventId()).toBe("$evt1");
});
it("uses converted Matrix text when checking the single-event preview limit", async () => {
const log = vi.fn();
resolveTextChunkLimitMock.mockReturnValue(5);
convertMarkdownTablesMock.mockImplementation(() => "123456");
const stream = createMatrixDraftStream({
roomId: "!room:test",
client,
cfg: {} as import("../types.js").CoreConfig,
log,
});
stream.update("1234");
await stream.flush();
expect(sendMessageMock).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(
expect.stringContaining("preview exceeded single-event limit"),
);
});
});
¤ Dauer der Verarbeitung: 0.22 Sekunden
(vorverarbeitet am 2026-04-27)
¤
*© Formatika GbR, Deutschland
|
|