feat: M1 — 立项→写章草稿(SSE)→自动保存;连一家 provider
- 薄自建 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 走通闭环
This commit is contained in:
61
apps/web/lib/autosave/autosave.test.ts
Normal file
61
apps/web/lib/autosave/autosave.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createDebouncedSaver, formatSavedAt } from "./autosave";
|
||||
|
||||
describe("createDebouncedSaver", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("saves only once after the debounce window for rapid edits", () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
const saver = createDebouncedSaver(save, 1000);
|
||||
saver.schedule("a");
|
||||
saver.schedule("ab");
|
||||
saver.schedule("abc");
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(save).toHaveBeenCalledWith("abc");
|
||||
});
|
||||
|
||||
it("flush triggers the pending save immediately", () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
const saver = createDebouncedSaver(save, 1000);
|
||||
saver.schedule("hello");
|
||||
saver.flush();
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(save).toHaveBeenCalledWith("hello");
|
||||
// No double-fire when the timer would have elapsed.
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancel prevents a scheduled save", () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
const saver = createDebouncedSaver(save, 1000);
|
||||
saver.schedule("x");
|
||||
saver.cancel();
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("each new schedule resets the timer (only last value persists)", () => {
|
||||
const save = vi.fn().mockResolvedValue(undefined);
|
||||
const saver = createDebouncedSaver(save, 1000);
|
||||
saver.schedule("one");
|
||||
vi.advanceTimersByTime(800);
|
||||
saver.schedule("two");
|
||||
vi.advanceTimersByTime(800);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(save).toHaveBeenCalledWith("two");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSavedAt", () => {
|
||||
it("formats as 保存于 hh:mm zero-padded", () => {
|
||||
const d = new Date(2026, 5, 18, 9, 4);
|
||||
expect(formatSavedAt(d)).toBe("保存于 09:04");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user