- 薄自建 LLM 网关:OpenAI 兼容适配器(DeepSeek) + instructor 结构化输出 + usage_ledger 记账 + 档位路由 - 记忆服务 assemble:确定性选择(显式+主角+近况) + 渲染卡 + 缓存断点(中性文本) - LangGraph 写章节点 + Postgres checkpointer + SSE 归一(token/done/error) - API:立项 + 写章 draft(SSE) + PUT 自动保存 + 提供商凭据(Fernet 加密/测试连接) - 前端:AppShell + 作品库 + 5 步立项向导 + 写作工作台(流式打字机+自动保存) + 设置页 - M1 E2E:真实 DB + mock 网关零 token 走通闭环
97 lines
3.1 KiB
TypeScript
97 lines
3.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
SseFrameBuffer,
|
|
initialStreamState,
|
|
parseSseBlock,
|
|
reduceStream,
|
|
type SseEvent,
|
|
} from "./sse";
|
|
|
|
describe("parseSseBlock", () => {
|
|
it("parses a token frame", () => {
|
|
const evt = parseSseBlock('event:token\ndata:{"text":"夜"}');
|
|
expect(evt).toEqual({ event: "token", data: { text: "夜" } });
|
|
});
|
|
|
|
it("parses a done frame", () => {
|
|
const evt = parseSseBlock('event:done\ndata:{"length":12}');
|
|
expect(evt).toEqual({ event: "done", data: { length: 12 } });
|
|
});
|
|
|
|
it("parses an error frame with request_id", () => {
|
|
const evt = parseSseBlock(
|
|
'event:error\ndata:{"code":"INTERNAL","message":"boom","request_id":"r1"}',
|
|
);
|
|
expect(evt).toEqual({
|
|
event: "error",
|
|
data: { code: "INTERNAL", message: "boom", request_id: "r1" },
|
|
});
|
|
});
|
|
|
|
it("ignores comment/heartbeat lines and tolerates leading space after colon", () => {
|
|
const evt = parseSseBlock(': keep-alive\nevent: token\ndata: {"text":"x"}');
|
|
expect(evt).toEqual({ event: "token", data: { text: "x" } });
|
|
});
|
|
|
|
it("returns null for unknown event types", () => {
|
|
expect(parseSseBlock('event:section\ndata:{"x":1}')).toBeNull();
|
|
});
|
|
|
|
it("returns null for malformed json", () => {
|
|
expect(parseSseBlock("event:token\ndata:{not json")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("SseFrameBuffer", () => {
|
|
it("emits complete blocks split by blank line and buffers the tail", () => {
|
|
const buf = new SseFrameBuffer();
|
|
const first = buf.push('event:token\ndata:{"text":"a"}\n\nevent:tok');
|
|
expect(first).toEqual([{ event: "token", data: { text: "a" } }]);
|
|
const second = buf.push('en\ndata:{"text":"b"}\n\n');
|
|
expect(second).toEqual([{ event: "token", data: { text: "b" } }]);
|
|
});
|
|
|
|
it("handles CRLF boundaries", () => {
|
|
const buf = new SseFrameBuffer();
|
|
const out = buf.push('event:token\r\ndata:{"text":"z"}\r\n\r\n');
|
|
expect(out).toEqual([{ event: "token", data: { text: "z" } }]);
|
|
});
|
|
});
|
|
|
|
describe("reduceStream", () => {
|
|
it("accumulates token text and marks streaming", () => {
|
|
const tokens: SseEvent[] = [
|
|
{ event: "token", data: { text: "夜色" } },
|
|
{ event: "token", data: { text: "如墨" } },
|
|
];
|
|
const state = tokens.reduce(reduceStream, initialStreamState);
|
|
expect(state.text).toBe("夜色如墨");
|
|
expect(state.phase).toBe("streaming");
|
|
});
|
|
|
|
it("marks done on done event preserving text", () => {
|
|
let s = reduceStream(initialStreamState, {
|
|
event: "token",
|
|
data: { text: "abc" },
|
|
});
|
|
s = reduceStream(s, { event: "done", data: { length: 3 } });
|
|
expect(s.phase).toBe("done");
|
|
expect(s.text).toBe("abc");
|
|
});
|
|
|
|
it("captures error and retains partial text", () => {
|
|
let s = reduceStream(initialStreamState, {
|
|
event: "token",
|
|
data: { text: "partial" },
|
|
});
|
|
s = reduceStream(s, {
|
|
event: "error",
|
|
data: { code: "LLM_UNAVAILABLE", message: "no key" },
|
|
});
|
|
expect(s.phase).toBe("error");
|
|
expect(s.text).toBe("partial");
|
|
expect(s.error?.code).toBe("LLM_UNAVAILABLE");
|
|
});
|
|
});
|